new features
This commit is contained in:
@@ -101,7 +101,7 @@ import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
|||||||
import settingsRoutes from './routes/settings.routes';
|
import settingsRoutes from './routes/settings.routes';
|
||||||
import bannerRoutes from './routes/banner.routes';
|
import bannerRoutes from './routes/banner.routes';
|
||||||
import permissionRoutes from './routes/permission.routes';
|
import permissionRoutes from './routes/permission.routes';
|
||||||
import shopRoutes from './routes/shop.routes';
|
import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes';
|
||||||
import issueRoutes from './routes/issue.routes';
|
import issueRoutes from './routes/issue.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
@@ -126,7 +126,7 @@ app.use('/api/admin/settings', settingsRoutes);
|
|||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/banners', bannerRoutes);
|
app.use('/api/banners', bannerRoutes);
|
||||||
app.use('/api/permissions', permissionRoutes);
|
app.use('/api/permissions', permissionRoutes);
|
||||||
app.use('/api/shop', shopRoutes);
|
app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes);
|
||||||
app.use('/api/issues', issueRoutes);
|
app.use('/api/issues', issueRoutes);
|
||||||
|
|
||||||
// Static file serving for uploads (authenticated)
|
// Static file serving for uploads (authenticated)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import shopService from '../services/shop.service';
|
import ausruestungsanfrageService from '../services/ausruestungsanfrage.service';
|
||||||
import notificationService from '../services/notification.service';
|
import notificationService from '../services/notification.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
class ShopController {
|
class AusruestungsanfrageController {
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Catalog Items
|
// Catalog Items
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -12,10 +12,10 @@ class ShopController {
|
|||||||
try {
|
try {
|
||||||
const kategorie = req.query.kategorie as string | undefined;
|
const kategorie = req.query.kategorie as string | undefined;
|
||||||
const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined;
|
const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined;
|
||||||
const items = await shopService.getItems({ kategorie, aktiv });
|
const items = await ausruestungsanfrageService.getItems({ kategorie, aktiv });
|
||||||
res.status(200).json({ success: true, data: items });
|
res.status(200).json({ success: true, data: items });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.getItems error', { error });
|
logger.error('AusruestungsanfrageController.getItems error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Artikel konnten nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Artikel konnten nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,14 +23,14 @@ class ShopController {
|
|||||||
async getItemById(req: Request, res: Response): Promise<void> {
|
async getItemById(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const item = await shopService.getItemById(id);
|
const item = await ausruestungsanfrageService.getItemById(id);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
res.status(404).json({ success: false, message: 'Artikel nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Artikel nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.status(200).json({ success: true, data: item });
|
res.status(200).json({ success: true, data: item });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.getItemById error', { error });
|
logger.error('AusruestungsanfrageController.getItemById error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Artikel konnte nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Artikel konnte nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,10 +42,10 @@ class ShopController {
|
|||||||
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = await shopService.createItem(req.body, req.user!.id);
|
const item = await ausruestungsanfrageService.createItem(req.body, req.user!.id);
|
||||||
res.status(201).json({ success: true, data: item });
|
res.status(201).json({ success: true, data: item });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.createItem error', { error });
|
logger.error('AusruestungsanfrageController.createItem error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Artikel konnte nicht erstellt werden' });
|
res.status(500).json({ success: false, message: 'Artikel konnte nicht erstellt werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,14 +53,14 @@ class ShopController {
|
|||||||
async updateItem(req: Request, res: Response): Promise<void> {
|
async updateItem(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const item = await shopService.updateItem(id, req.body, req.user!.id);
|
const item = await ausruestungsanfrageService.updateItem(id, req.body, req.user!.id);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
res.status(404).json({ success: false, message: 'Artikel nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Artikel nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.status(200).json({ success: true, data: item });
|
res.status(200).json({ success: true, data: item });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.updateItem error', { error });
|
logger.error('AusruestungsanfrageController.updateItem error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Artikel konnte nicht aktualisiert werden' });
|
res.status(500).json({ success: false, message: 'Artikel konnte nicht aktualisiert werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,20 +68,20 @@ class ShopController {
|
|||||||
async deleteItem(req: Request, res: Response): Promise<void> {
|
async deleteItem(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
await shopService.deleteItem(id);
|
await ausruestungsanfrageService.deleteItem(id);
|
||||||
res.status(200).json({ success: true, message: 'Artikel gelöscht' });
|
res.status(200).json({ success: true, message: 'Artikel gelöscht' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.deleteItem error', { error });
|
logger.error('AusruestungsanfrageController.deleteItem error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Artikel konnte nicht gelöscht werden' });
|
res.status(500).json({ success: false, message: 'Artikel konnte nicht gelöscht werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCategories(_req: Request, res: Response): Promise<void> {
|
async getCategories(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const categories = await shopService.getCategories();
|
const categories = await ausruestungsanfrageService.getCategories();
|
||||||
res.status(200).json({ success: true, data: categories });
|
res.status(200).json({ success: true, data: categories });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.getCategories error', { error });
|
logger.error('AusruestungsanfrageController.getCategories error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,20 +94,20 @@ class ShopController {
|
|||||||
try {
|
try {
|
||||||
const status = req.query.status as string | undefined;
|
const status = req.query.status as string | undefined;
|
||||||
const anfrager_id = req.query.anfrager_id as string | undefined;
|
const anfrager_id = req.query.anfrager_id as string | undefined;
|
||||||
const requests = await shopService.getRequests({ status, anfrager_id });
|
const requests = await ausruestungsanfrageService.getRequests({ status, anfrager_id });
|
||||||
res.status(200).json({ success: true, data: requests });
|
res.status(200).json({ success: true, data: requests });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.getRequests error', { error });
|
logger.error('AusruestungsanfrageController.getRequests error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMyRequests(req: Request, res: Response): Promise<void> {
|
async getMyRequests(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const requests = await shopService.getMyRequests(req.user!.id);
|
const requests = await ausruestungsanfrageService.getMyRequests(req.user!.id);
|
||||||
res.status(200).json({ success: true, data: requests });
|
res.status(200).json({ success: true, data: requests });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.getMyRequests error', { error });
|
logger.error('AusruestungsanfrageController.getMyRequests error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,14 +115,14 @@ class ShopController {
|
|||||||
async getRequestById(req: Request, res: Response): Promise<void> {
|
async getRequestById(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const request = await shopService.getRequestById(id);
|
const request = await ausruestungsanfrageService.getRequestById(id);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.status(200).json({ success: true, data: request });
|
res.status(200).json({ success: true, data: request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.getRequestById error', { error });
|
logger.error('AusruestungsanfrageController.getRequestById error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Anfrage konnte nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Anfrage konnte nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,10 +150,10 @@ class ShopController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = await shopService.createRequest(req.user!.id, items, notizen);
|
const request = await ausruestungsanfrageService.createRequest(req.user!.id, items, notizen);
|
||||||
res.status(201).json({ success: true, data: request });
|
res.status(201).json({ success: true, data: request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.createRequest error', { error });
|
logger.error('AusruestungsanfrageController.createRequest error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Anfrage konnte nicht erstellt werden' });
|
res.status(500).json({ success: false, message: 'Anfrage konnte nicht erstellt werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,13 +178,13 @@ class ShopController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch request to get anfrager_id for notification
|
// Fetch request to get anfrager_id for notification
|
||||||
const existing = await shopService.getRequestById(id);
|
const existing = await ausruestungsanfrageService.getRequestById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await shopService.updateRequestStatus(id, status, admin_notizen, req.user!.id);
|
const updated = await ausruestungsanfrageService.updateRequestStatus(id, status, admin_notizen, req.user!.id);
|
||||||
|
|
||||||
// Notify requester on status changes
|
// Notify requester on status changes
|
||||||
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
|
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
|
||||||
@@ -193,19 +193,19 @@ class ShopController {
|
|||||||
: `#${id}`;
|
: `#${id}`;
|
||||||
await notificationService.createNotification({
|
await notificationService.createNotification({
|
||||||
user_id: existing.anfrager_id,
|
user_id: existing.anfrager_id,
|
||||||
typ: 'shop_anfrage',
|
typ: 'ausruestung_anfrage',
|
||||||
titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`,
|
titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`,
|
||||||
nachricht: `Deine Shop-Anfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
|
nachricht: `Deine Ausrüstungsanfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
|
||||||
schwere: status === 'abgelehnt' ? 'warnung' : 'info',
|
schwere: status === 'abgelehnt' ? 'warnung' : 'info',
|
||||||
link: '/shop',
|
link: '/ausruestungsanfrage',
|
||||||
quell_id: String(id),
|
quell_id: String(id),
|
||||||
quell_typ: 'shop_anfrage',
|
quell_typ: 'ausruestung_anfrage',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: updated });
|
res.status(200).json({ success: true, data: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.updateRequestStatus error', { error });
|
logger.error('AusruestungsanfrageController.updateRequestStatus error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
|
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,10 +213,10 @@ class ShopController {
|
|||||||
async deleteRequest(req: Request, res: Response): Promise<void> {
|
async deleteRequest(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
await shopService.deleteRequest(id);
|
await ausruestungsanfrageService.deleteRequest(id);
|
||||||
res.status(200).json({ success: true, message: 'Anfrage gelöscht' });
|
res.status(200).json({ success: true, message: 'Anfrage gelöscht' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.deleteRequest error', { error });
|
logger.error('AusruestungsanfrageController.deleteRequest error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Anfrage konnte nicht gelöscht werden' });
|
res.status(500).json({ success: false, message: 'Anfrage konnte nicht gelöscht werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,10 +227,10 @@ class ShopController {
|
|||||||
|
|
||||||
async getOverview(_req: Request, res: Response): Promise<void> {
|
async getOverview(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const overview = await shopService.getOverview();
|
const overview = await ausruestungsanfrageService.getOverview();
|
||||||
res.status(200).json({ success: true, data: overview });
|
res.status(200).json({ success: true, data: overview });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.getOverview error', { error });
|
logger.error('AusruestungsanfrageController.getOverview error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,10 +249,10 @@ class ShopController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await shopService.linkToOrder(anfrageId, bestellung_id);
|
await ausruestungsanfrageService.linkToOrder(anfrageId, bestellung_id);
|
||||||
res.status(200).json({ success: true, message: 'Verknüpfung erstellt' });
|
res.status(200).json({ success: true, message: 'Verknüpfung erstellt' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.linkToOrder error', { error });
|
logger.error('AusruestungsanfrageController.linkToOrder error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht erstellt werden' });
|
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht erstellt werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,13 +261,13 @@ class ShopController {
|
|||||||
try {
|
try {
|
||||||
const anfrageId = Number(req.params.id);
|
const anfrageId = Number(req.params.id);
|
||||||
const bestellungId = Number(req.params.bestellungId);
|
const bestellungId = Number(req.params.bestellungId);
|
||||||
await shopService.unlinkFromOrder(anfrageId, bestellungId);
|
await ausruestungsanfrageService.unlinkFromOrder(anfrageId, bestellungId);
|
||||||
res.status(200).json({ success: true, message: 'Verknüpfung entfernt' });
|
res.status(200).json({ success: true, message: 'Verknüpfung entfernt' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ShopController.unlinkFromOrder error', { error });
|
logger.error('AusruestungsanfrageController.unlinkFromOrder error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht entfernt werden' });
|
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht entfernt werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ShopController();
|
export default new AusruestungsanfrageController();
|
||||||
@@ -67,13 +67,25 @@ class IssueController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
const groups: string[] = (req.user as any).groups || [];
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
const canManage = permissionService.hasPermission(groups, 'issues:manage');
|
const canManage = permissionService.hasPermission(groups, 'issues:manage');
|
||||||
if (!canManage) {
|
|
||||||
|
const existing = await issueService.getIssueById(id);
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = existing.erstellt_von === userId;
|
||||||
|
if (!canManage && !isOwner) {
|
||||||
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const issue = await issueService.updateIssue(id, req.body);
|
|
||||||
|
// Owners without manage permission can only change status
|
||||||
|
const updateData = canManage ? req.body : { status: req.body.status };
|
||||||
|
const issue = await issueService.updateIssue(id, updateData);
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -242,6 +242,64 @@ class PermissionController {
|
|||||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Benutzer' });
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Benutzer' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* GET /api/permissions/debug/:userId
|
||||||
|
* Returns debug info for a specific user: their groups, resolved permissions,
|
||||||
|
* and maintenance flags. Admin only.
|
||||||
|
*/
|
||||||
|
async debugUser(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.params.userId as string;
|
||||||
|
|
||||||
|
// Fetch user's Authentik groups from DB
|
||||||
|
const { pool } = await import('../config/database');
|
||||||
|
const userResult = await pool.query(
|
||||||
|
'SELECT authentik_groups, email, name FROM users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
const groups: string[] = user.authentik_groups ?? [];
|
||||||
|
const isAdmin = groups.includes('dashboard_admin');
|
||||||
|
|
||||||
|
// Resolve permissions for those groups
|
||||||
|
let permissions: string[];
|
||||||
|
if (isAdmin) {
|
||||||
|
const matrix = await permissionService.getMatrix();
|
||||||
|
permissions = matrix.permissions.map(p => p.id);
|
||||||
|
} else {
|
||||||
|
permissions = permissionService.getEffectivePermissions(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenance flags
|
||||||
|
const maintenance = permissionService.getMaintenanceFlags();
|
||||||
|
const maintenanceActive = Object.entries(maintenance)
|
||||||
|
.filter(([, active]) => active)
|
||||||
|
.map(([featureGroup]) => featureGroup);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
authentikGroups: groups,
|
||||||
|
isAdmin,
|
||||||
|
permissions,
|
||||||
|
maintenance,
|
||||||
|
maintenanceActiveFeatureGroups: maintenanceActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to debug user permissions', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Debug-Informationen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new PermissionController();
|
export default new PermissionController();
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- Migration 046: Rename Shop → Ausrüstungsanfrage
|
||||||
|
-- Renames all shop_* tables and updates permission references.
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Rename tables
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS shop_artikel RENAME TO ausruestung_artikel;
|
||||||
|
ALTER TABLE IF EXISTS shop_anfragen RENAME TO ausruestung_anfragen;
|
||||||
|
ALTER TABLE IF EXISTS shop_anfrage_positionen RENAME TO ausruestung_anfrage_positionen;
|
||||||
|
ALTER TABLE IF EXISTS shop_anfrage_bestellung RENAME TO ausruestung_anfrage_bestellung;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Rename indexes
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER INDEX IF EXISTS idx_shop_artikel_kategorie RENAME TO idx_ausruestung_artikel_kategorie;
|
||||||
|
ALTER INDEX IF EXISTS idx_shop_artikel_aktiv RENAME TO idx_ausruestung_artikel_aktiv;
|
||||||
|
ALTER INDEX IF EXISTS idx_shop_anfragen_anfrager RENAME TO idx_ausruestung_anfragen_anfrager;
|
||||||
|
ALTER INDEX IF EXISTS idx_shop_anfragen_status RENAME TO idx_ausruestung_anfragen_status;
|
||||||
|
ALTER INDEX IF EXISTS idx_shop_anfrage_positionen_anfrage RENAME TO idx_ausruestung_anfrage_positionen_anfrage;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Rename triggers
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TRIGGER IF EXISTS trg_shop_artikel_aktualisiert ON ausruestung_artikel RENAME TO trg_ausruestung_artikel_aktualisiert;
|
||||||
|
ALTER TRIGGER IF EXISTS trg_shop_anfragen_aktualisiert ON ausruestung_anfragen RENAME TO trg_ausruestung_anfragen_aktualisiert;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Update feature_groups
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
UPDATE feature_groups SET id = 'ausruestungsanfrage' WHERE id = 'shop';
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Update permissions
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
UPDATE permissions SET
|
||||||
|
id = REPLACE(id, 'shop:', 'ausruestungsanfrage:'),
|
||||||
|
feature_group_id = 'ausruestungsanfrage'
|
||||||
|
WHERE feature_group_id = 'shop';
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 6. Update group_permissions
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
UPDATE group_permissions SET
|
||||||
|
permission_id = REPLACE(permission_id, 'shop:', 'ausruestungsanfrage:')
|
||||||
|
WHERE permission_id LIKE 'shop:%';
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 7. Update notification quell_typ references
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
UPDATE benachrichtigungen SET quell_typ = 'ausruestung_anfrage' WHERE quell_typ = 'shop_anfrage';
|
||||||
|
UPDATE benachrichtigungen SET typ = 'ausruestung_anfrage' WHERE typ = 'shop_anfrage';
|
||||||
|
UPDATE benachrichtigungen SET link = '/ausruestungsanfrage' WHERE link = '/shop';
|
||||||
@@ -265,6 +265,41 @@ router.delete(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reset / Truncate endpoints (no olderThanDays, just ?confirm=true)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ResetTarget = 'reset-bestellungen' | 'reset-ausruestung-anfragen' | 'reset-issues' | 'issues-all';
|
||||||
|
|
||||||
|
const RESET_TARGETS: Record<ResetTarget, (confirm: boolean) => Promise<{ count: number; deleted: boolean }>> = {
|
||||||
|
'reset-bestellungen': (c) => cleanupService.resetBestellungenSequence(c),
|
||||||
|
'reset-ausruestung-anfragen': (c) => cleanupService.resetAusruestungAnfragenSequence(c),
|
||||||
|
'reset-issues': (c) => cleanupService.resetIssuesSequence(c),
|
||||||
|
'issues-all': (c) => cleanupService.resetIssuesSequence(c),
|
||||||
|
};
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/cleanup/:resetTarget(reset-bestellungen|reset-ausruestung-anfragen|reset-issues|issues-all)',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('admin:write'),
|
||||||
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const target = req.params.resetTarget as ResetTarget;
|
||||||
|
const handler = RESET_TARGETS[target];
|
||||||
|
if (!handler) {
|
||||||
|
res.status(400).json({ success: false, message: `Unknown reset target: ${target}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirm = req.query.confirm === 'true';
|
||||||
|
const result = await handler(confirm);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Reset failed', { error, target: req.params.resetTarget });
|
||||||
|
res.status(500).json({ success: false, message: 'Reset failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DELETE /api/admin/users/:userId/sync-data — selective sync data deletion
|
// DELETE /api/admin/users/:userId/sync-data — selective sync data deletion
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
44
backend/src/routes/ausruestungsanfrage.routes.ts
Normal file
44
backend/src/routes/ausruestungsanfrage.routes.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import ausruestungsanfrageController from '../controllers/ausruestungsanfrage.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalog Items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/items', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getItems.bind(ausruestungsanfrageController));
|
||||||
|
router.get('/items/:id', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getItemById.bind(ausruestungsanfrageController));
|
||||||
|
router.post('/items', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.createItem.bind(ausruestungsanfrageController));
|
||||||
|
router.patch('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.updateItem.bind(ausruestungsanfrageController));
|
||||||
|
router.delete('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.deleteItem.bind(ausruestungsanfrageController));
|
||||||
|
|
||||||
|
router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getCategories.bind(ausruestungsanfrageController));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:view_overview'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Requests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/requests', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.getRequests.bind(ausruestungsanfrageController));
|
||||||
|
router.get('/requests/my', authenticate, ausruestungsanfrageController.getMyRequests.bind(ausruestungsanfrageController));
|
||||||
|
router.get('/requests/:id', authenticate, ausruestungsanfrageController.getRequestById.bind(ausruestungsanfrageController));
|
||||||
|
router.post('/requests', authenticate, requirePermission('ausruestungsanfrage:create_request'), ausruestungsanfrageController.createRequest.bind(ausruestungsanfrageController));
|
||||||
|
router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController));
|
||||||
|
router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Linking requests to orders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -19,5 +19,6 @@ router.put('/admin/group/:groupName', authenticate, requirePermission('admin:wri
|
|||||||
router.delete('/admin/group/:groupName', authenticate, requirePermission('admin:write'), permissionController.deleteGroup.bind(permissionController));
|
router.delete('/admin/group/:groupName', authenticate, requirePermission('admin:write'), permissionController.deleteGroup.bind(permissionController));
|
||||||
router.put('/admin/bulk', authenticate, requirePermission('admin:write'), permissionController.setBulkPermissions.bind(permissionController));
|
router.put('/admin/bulk', authenticate, requirePermission('admin:write'), permissionController.setBulkPermissions.bind(permissionController));
|
||||||
router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController));
|
router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController));
|
||||||
|
router.get('/debug/:userId', authenticate, requirePermission('admin:write'), permissionController.debugUser.bind(permissionController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import shopController from '../controllers/shop.controller';
|
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
|
||||||
import { requirePermission } from '../middleware/rbac.middleware';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Catalog Items
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
router.get('/items', authenticate, requirePermission('shop:view'), shopController.getItems.bind(shopController));
|
|
||||||
router.get('/items/:id', authenticate, requirePermission('shop:view'), shopController.getItemById.bind(shopController));
|
|
||||||
router.post('/items', authenticate, requirePermission('shop:manage_catalog'), shopController.createItem.bind(shopController));
|
|
||||||
router.patch('/items/:id', authenticate, requirePermission('shop:manage_catalog'), shopController.updateItem.bind(shopController));
|
|
||||||
router.delete('/items/:id', authenticate, requirePermission('shop:manage_catalog'), shopController.deleteItem.bind(shopController));
|
|
||||||
|
|
||||||
router.get('/categories', authenticate, requirePermission('shop:view'), shopController.getCategories.bind(shopController));
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Overview
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
router.get('/overview', authenticate, requirePermission('shop:view_overview'), shopController.getOverview.bind(shopController));
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Requests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
router.get('/requests', authenticate, requirePermission('shop:approve_requests'), shopController.getRequests.bind(shopController));
|
|
||||||
router.get('/requests/my', authenticate, shopController.getMyRequests.bind(shopController));
|
|
||||||
router.get('/requests/:id', authenticate, shopController.getRequestById.bind(shopController));
|
|
||||||
router.post('/requests', authenticate, requirePermission('shop:create_request'), shopController.createRequest.bind(shopController));
|
|
||||||
router.patch('/requests/:id/status', authenticate, requirePermission('shop:approve_requests'), shopController.updateRequestStatus.bind(shopController));
|
|
||||||
router.delete('/requests/:id', authenticate, requirePermission('shop:approve_requests'), shopController.deleteRequest.bind(shopController));
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Linking requests to orders
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
router.post('/requests/:id/link', authenticate, requirePermission('shop:link_orders'), shopController.linkToOrder.bind(shopController));
|
|
||||||
router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('shop:link_orders'), shopController.unlinkFromOrder.bind(shopController));
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -2,7 +2,7 @@ import pool from '../config/database';
|
|||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Catalog Items (shop_artikel)
|
// Catalog Items (ausruestung_artikel)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) {
|
async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) {
|
||||||
@@ -20,14 +20,14 @@ async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) {
|
|||||||
|
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT * FROM shop_artikel ${where} ORDER BY kategorie, bezeichnung`,
|
`SELECT * FROM ausruestung_artikel ${where} ORDER BY kategorie, bezeichnung`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getItemById(id: number) {
|
async function getItemById(id: number) {
|
||||||
const result = await pool.query('SELECT * FROM shop_artikel WHERE id = $1', [id]);
|
const result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ async function createItem(
|
|||||||
userId: string,
|
userId: string,
|
||||||
) {
|
) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von)
|
`INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von)
|
||||||
VALUES ($1, $2, $3, $4, COALESCE($5, true), $6)
|
VALUES ($1, $2, $3, $4, COALESCE($5, true), $6)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId],
|
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId],
|
||||||
@@ -94,25 +94,25 @@ async function updateItem(
|
|||||||
|
|
||||||
params.push(id);
|
params.push(id);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE shop_artikel SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
`UPDATE ausruestung_artikel SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteItem(id: number) {
|
async function deleteItem(id: number) {
|
||||||
await pool.query('DELETE FROM shop_artikel WHERE id = $1', [id]);
|
await pool.query('DELETE FROM ausruestung_artikel WHERE id = $1', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCategories() {
|
async function getCategories() {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT DISTINCT kategorie FROM shop_artikel WHERE kategorie IS NOT NULL ORDER BY kategorie',
|
'SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL ORDER BY kategorie',
|
||||||
);
|
);
|
||||||
return result.rows.map((r: { kategorie: string }) => r.kategorie);
|
return result.rows.map((r: { kategorie: string }) => r.kategorie);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Requests (shop_anfragen)
|
// Requests (ausruestung_anfragen)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function getRequests(filters?: { status?: string; anfrager_id?: string }) {
|
async function getRequests(filters?: { status?: string; anfrager_id?: string }) {
|
||||||
@@ -133,8 +133,8 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string })
|
|||||||
`SELECT a.*,
|
`SELECT a.*,
|
||||||
u.vorname || ' ' || u.nachname AS anfrager_name,
|
u.vorname || ' ' || u.nachname AS anfrager_name,
|
||||||
u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name,
|
u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name,
|
||||||
(SELECT COUNT(*)::int FROM shop_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count
|
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count
|
||||||
FROM shop_anfragen a
|
FROM ausruestung_anfragen a
|
||||||
LEFT JOIN users u ON u.id = a.anfrager_id
|
LEFT JOIN users u ON u.id = a.anfrager_id
|
||||||
LEFT JOIN users u2 ON u2.id = a.bearbeitet_von
|
LEFT JOIN users u2 ON u2.id = a.bearbeitet_von
|
||||||
${where}
|
${where}
|
||||||
@@ -147,8 +147,8 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string })
|
|||||||
async function getMyRequests(userId: string) {
|
async function getMyRequests(userId: string) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT a.*,
|
`SELECT a.*,
|
||||||
(SELECT COUNT(*)::int FROM shop_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count
|
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count
|
||||||
FROM shop_anfragen a
|
FROM ausruestung_anfragen a
|
||||||
WHERE a.anfrager_id = $1
|
WHERE a.anfrager_id = $1
|
||||||
ORDER BY a.erstellt_am DESC`,
|
ORDER BY a.erstellt_am DESC`,
|
||||||
[userId],
|
[userId],
|
||||||
@@ -161,7 +161,7 @@ async function getRequestById(id: number) {
|
|||||||
`SELECT a.*,
|
`SELECT a.*,
|
||||||
u.vorname || ' ' || u.nachname AS anfrager_name,
|
u.vorname || ' ' || u.nachname AS anfrager_name,
|
||||||
u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name
|
u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name
|
||||||
FROM shop_anfragen a
|
FROM ausruestung_anfragen a
|
||||||
LEFT JOIN users u ON u.id = a.anfrager_id
|
LEFT JOIN users u ON u.id = a.anfrager_id
|
||||||
LEFT JOIN users u2 ON u2.id = a.bearbeitet_von
|
LEFT JOIN users u2 ON u2.id = a.bearbeitet_von
|
||||||
WHERE a.id = $1`,
|
WHERE a.id = $1`,
|
||||||
@@ -171,8 +171,8 @@ async function getRequestById(id: number) {
|
|||||||
|
|
||||||
const positionen = await pool.query(
|
const positionen = await pool.query(
|
||||||
`SELECT p.*, sa.bezeichnung AS artikel_bezeichnung, sa.kategorie AS artikel_kategorie
|
`SELECT p.*, sa.bezeichnung AS artikel_bezeichnung, sa.kategorie AS artikel_kategorie
|
||||||
FROM shop_anfrage_positionen p
|
FROM ausruestung_anfrage_positionen p
|
||||||
LEFT JOIN shop_artikel sa ON sa.id = p.artikel_id
|
LEFT JOIN ausruestung_artikel sa ON sa.id = p.artikel_id
|
||||||
WHERE p.anfrage_id = $1
|
WHERE p.anfrage_id = $1
|
||||||
ORDER BY p.id`,
|
ORDER BY p.id`,
|
||||||
[id],
|
[id],
|
||||||
@@ -180,7 +180,7 @@ async function getRequestById(id: number) {
|
|||||||
|
|
||||||
const bestellungen = await pool.query(
|
const bestellungen = await pool.query(
|
||||||
`SELECT b.*
|
`SELECT b.*
|
||||||
FROM shop_anfrage_bestellung ab
|
FROM ausruestung_anfrage_bestellung ab
|
||||||
JOIN bestellungen b ON b.id = ab.bestellung_id
|
JOIN bestellungen b ON b.id = ab.bestellung_id
|
||||||
WHERE ab.anfrage_id = $1`,
|
WHERE ab.anfrage_id = $1`,
|
||||||
[id],
|
[id],
|
||||||
@@ -206,14 +206,14 @@ async function createRequest(
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const maxResult = await client.query(
|
const maxResult = await client.query(
|
||||||
`SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr
|
`SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr
|
||||||
FROM shop_anfragen
|
FROM ausruestung_anfragen
|
||||||
WHERE bestell_jahr = $1`,
|
WHERE bestell_jahr = $1`,
|
||||||
[currentYear],
|
[currentYear],
|
||||||
);
|
);
|
||||||
const nextNr = maxResult.rows[0].next_nr;
|
const nextNr = maxResult.rows[0].next_nr;
|
||||||
|
|
||||||
const anfrageResult = await client.query(
|
const anfrageResult = await client.query(
|
||||||
`INSERT INTO shop_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr)
|
`INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[userId, notizen || null, nextNr, currentYear],
|
[userId, notizen || null, nextNr, currentYear],
|
||||||
@@ -226,7 +226,7 @@ async function createRequest(
|
|||||||
// If artikel_id is provided, copy bezeichnung from catalog
|
// If artikel_id is provided, copy bezeichnung from catalog
|
||||||
if (item.artikel_id) {
|
if (item.artikel_id) {
|
||||||
const artikelResult = await client.query(
|
const artikelResult = await client.query(
|
||||||
'SELECT bezeichnung FROM shop_artikel WHERE id = $1',
|
'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1',
|
||||||
[item.artikel_id],
|
[item.artikel_id],
|
||||||
);
|
);
|
||||||
if (artikelResult.rows.length > 0) {
|
if (artikelResult.rows.length > 0) {
|
||||||
@@ -235,7 +235,7 @@ async function createRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO shop_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
|
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
[anfrage.id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null],
|
[anfrage.id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null],
|
||||||
);
|
);
|
||||||
@@ -245,7 +245,7 @@ async function createRequest(
|
|||||||
return getRequestById(anfrage.id);
|
return getRequestById(anfrage.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
logger.error('shopService.createRequest failed', { error });
|
logger.error('ausruestungsanfrageService.createRequest failed', { error });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
@@ -259,7 +259,7 @@ async function updateRequestStatus(
|
|||||||
bearbeitetVon?: string,
|
bearbeitetVon?: string,
|
||||||
) {
|
) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE shop_anfragen
|
`UPDATE ausruestung_anfragen
|
||||||
SET status = $1,
|
SET status = $1,
|
||||||
admin_notizen = COALESCE($2, admin_notizen),
|
admin_notizen = COALESCE($2, admin_notizen),
|
||||||
bearbeitet_von = COALESCE($3, bearbeitet_von),
|
bearbeitet_von = COALESCE($3, bearbeitet_von),
|
||||||
@@ -272,16 +272,16 @@ async function updateRequestStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRequest(id: number) {
|
async function deleteRequest(id: number) {
|
||||||
await pool.query('DELETE FROM shop_anfragen WHERE id = $1', [id]);
|
await pool.query('DELETE FROM ausruestung_anfragen WHERE id = $1', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Linking (shop_anfrage_bestellung)
|
// Linking (ausruestung_anfrage_bestellung)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function linkToOrder(anfrageId: number, bestellungId: number) {
|
async function linkToOrder(anfrageId: number, bestellungId: number) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO shop_anfrage_bestellung (anfrage_id, bestellung_id)
|
`INSERT INTO ausruestung_anfrage_bestellung (anfrage_id, bestellung_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
ON CONFLICT DO NOTHING`,
|
ON CONFLICT DO NOTHING`,
|
||||||
[anfrageId, bestellungId],
|
[anfrageId, bestellungId],
|
||||||
@@ -290,7 +290,7 @@ async function linkToOrder(anfrageId: number, bestellungId: number) {
|
|||||||
|
|
||||||
async function unlinkFromOrder(anfrageId: number, bestellungId: number) {
|
async function unlinkFromOrder(anfrageId: number, bestellungId: number) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'DELETE FROM shop_anfrage_bestellung WHERE anfrage_id = $1 AND bestellung_id = $2',
|
'DELETE FROM ausruestung_anfrage_bestellung WHERE anfrage_id = $1 AND bestellung_id = $2',
|
||||||
[anfrageId, bestellungId],
|
[anfrageId, bestellungId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -298,7 +298,7 @@ async function unlinkFromOrder(anfrageId: number, bestellungId: number) {
|
|||||||
async function getLinkedOrders(anfrageId: number) {
|
async function getLinkedOrders(anfrageId: number) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT b.*
|
`SELECT b.*
|
||||||
FROM shop_anfrage_bestellung ab
|
FROM ausruestung_anfrage_bestellung ab
|
||||||
JOIN bestellungen b ON b.id = ab.bestellung_id
|
JOIN bestellungen b ON b.id = ab.bestellung_id
|
||||||
WHERE ab.anfrage_id = $1`,
|
WHERE ab.anfrage_id = $1`,
|
||||||
[anfrageId],
|
[anfrageId],
|
||||||
@@ -315,8 +315,8 @@ async function getOverview() {
|
|||||||
`SELECT p.bezeichnung,
|
`SELECT p.bezeichnung,
|
||||||
SUM(p.menge)::int AS total_menge,
|
SUM(p.menge)::int AS total_menge,
|
||||||
COUNT(DISTINCT p.anfrage_id)::int AS anfrage_count
|
COUNT(DISTINCT p.anfrage_id)::int AS anfrage_count
|
||||||
FROM shop_anfrage_positionen p
|
FROM ausruestung_anfrage_positionen p
|
||||||
JOIN shop_anfragen a ON a.id = p.anfrage_id
|
JOIN ausruestung_anfragen a ON a.id = p.anfrage_id
|
||||||
WHERE a.status IN ('offen', 'genehmigt')
|
WHERE a.status IN ('offen', 'genehmigt')
|
||||||
GROUP BY p.bezeichnung
|
GROUP BY p.bezeichnung
|
||||||
ORDER BY total_menge DESC, p.bezeichnung`,
|
ORDER BY total_menge DESC, p.bezeichnung`,
|
||||||
@@ -327,10 +327,10 @@ async function getOverview() {
|
|||||||
COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count,
|
COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count,
|
||||||
COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count,
|
COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count,
|
||||||
COALESCE(SUM(sub.total), 0)::int AS total_items
|
COALESCE(SUM(sub.total), 0)::int AS total_items
|
||||||
FROM shop_anfragen a
|
FROM ausruestung_anfragen a
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT SUM(p.menge) AS total
|
SELECT SUM(p.menge) AS total
|
||||||
FROM shop_anfrage_positionen p
|
FROM ausruestung_anfrage_positionen p
|
||||||
WHERE p.anfrage_id = a.id
|
WHERE p.anfrage_id = a.id
|
||||||
) sub ON true
|
) sub ON true
|
||||||
WHERE a.status IN ('offen', 'genehmigt')`,
|
WHERE a.status IN ('offen', 'genehmigt')`,
|
||||||
@@ -197,8 +197,8 @@ async function createOrder(data: { bezeichnung: string; lieferant_id?: number; b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
|
||||||
return order;
|
return order;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
|
|||||||
@@ -126,6 +126,45 @@ class CleanupService {
|
|||||||
logger.info(`Cleanup: deleted ${rowCount} equipment history entries older than ${olderThanDays} days`);
|
logger.info(`Cleanup: deleted ${rowCount} equipment history entries older than ${olderThanDays} days`);
|
||||||
return { count: rowCount ?? 0, deleted: true };
|
return { count: rowCount ?? 0, deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resetBestellungenSequence(confirm: boolean): Promise<CleanupResult> {
|
||||||
|
if (!confirm) {
|
||||||
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM bestellungen');
|
||||||
|
return { count: rows[0].count, deleted: false };
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM bestellungen');
|
||||||
|
const count = rows[0].count;
|
||||||
|
await pool.query('TRUNCATE bestellungen CASCADE');
|
||||||
|
await pool.query('ALTER SEQUENCE bestellungen_id_seq RESTART WITH 1');
|
||||||
|
logger.info(`Cleanup: truncated bestellungen (${count} rows) and reset sequence`);
|
||||||
|
return { count, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetAusruestungAnfragenSequence(confirm: boolean): Promise<CleanupResult> {
|
||||||
|
if (!confirm) {
|
||||||
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM ausruestung_anfragen');
|
||||||
|
return { count: rows[0].count, deleted: false };
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM ausruestung_anfragen');
|
||||||
|
const count = rows[0].count;
|
||||||
|
await pool.query('TRUNCATE ausruestung_anfragen CASCADE');
|
||||||
|
await pool.query('ALTER SEQUENCE ausruestung_anfragen_id_seq RESTART WITH 1');
|
||||||
|
logger.info(`Cleanup: truncated ausruestung_anfragen (${count} rows) and reset sequence`);
|
||||||
|
return { count, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetIssuesSequence(confirm: boolean): Promise<CleanupResult> {
|
||||||
|
if (!confirm) {
|
||||||
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues');
|
||||||
|
return { count: rows[0].count, deleted: false };
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues');
|
||||||
|
const count = rows[0].count;
|
||||||
|
await pool.query('TRUNCATE issues CASCADE');
|
||||||
|
await pool.query('ALTER SEQUENCE issues_id_seq RESTART WITH 1');
|
||||||
|
logger.info(`Cleanup: truncated issues (${count} rows) and reset sequence`);
|
||||||
|
return { count, deleted: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CleanupService();
|
export default new CleanupService();
|
||||||
|
|||||||
@@ -714,7 +714,8 @@ class EventsService {
|
|||||||
FROM users
|
FROM users
|
||||||
WHERE authentik_groups IS NOT NULL
|
WHERE authentik_groups IS NOT NULL
|
||||||
) g
|
) g
|
||||||
WHERE group_name != 'dashboard_admin'
|
WHERE group_name LIKE 'dashboard_%'
|
||||||
|
AND group_name != 'dashboard_admin'
|
||||||
ORDER BY group_name`
|
ORDER BY group_name`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import VeranstaltungKategorien from './pages/VeranstaltungKategorien';
|
|||||||
import Wissen from './pages/Wissen';
|
import Wissen from './pages/Wissen';
|
||||||
import Bestellungen from './pages/Bestellungen';
|
import Bestellungen from './pages/Bestellungen';
|
||||||
import BestellungDetail from './pages/BestellungDetail';
|
import BestellungDetail from './pages/BestellungDetail';
|
||||||
import Shop from './pages/Shop';
|
import Ausruestungsanfrage from './pages/Ausruestungsanfrage';
|
||||||
import Issues from './pages/Issues';
|
import Issues from './pages/Issues';
|
||||||
import AdminDashboard from './pages/AdminDashboard';
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
import AdminSettings from './pages/AdminSettings';
|
import AdminSettings from './pages/AdminSettings';
|
||||||
@@ -237,10 +237,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/shop"
|
path="/ausruestungsanfrage"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Shop />
|
<Ausruestungsanfrage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import {
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { bestellungApi } from '../../services/bestellung';
|
import { bestellungApi } from '../../services/bestellung';
|
||||||
import { shopApi } from '../../services/shop';
|
import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage';
|
||||||
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types';
|
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types';
|
||||||
import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../../types/shop.types';
|
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../../types/ausruestungsanfrage.types';
|
||||||
import type { BestellungStatus } from '../../types/bestellung.types';
|
import type { BestellungStatus } from '../../types/bestellung.types';
|
||||||
import type { ShopAnfrageStatus } from '../../types/shop.types';
|
import type { AusruestungAnfrageStatus } from '../../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
function BestellungenTab() {
|
function BestellungenTab() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -35,8 +35,8 @@ function BestellungenTab() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: requests, isLoading: requestsLoading } = useQuery({
|
const { data: requests, isLoading: requestsLoading } = useQuery({
|
||||||
queryKey: ['admin-shop-requests'],
|
queryKey: ['admin-ausruestungsanfrage-requests'],
|
||||||
queryFn: () => shopApi.getRequests({ status: 'offen' }),
|
queryFn: () => ausruestungsanfrageApi.getRequests({ status: 'offen' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (value?: number) =>
|
const formatCurrency = (value?: number) =>
|
||||||
@@ -44,11 +44,11 @@ function BestellungenTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{/* Pending Shop Requests */}
|
{/* Pending Ausrüstungsanfragen */}
|
||||||
{(requests?.length ?? 0) > 0 && (
|
{(requests?.length ?? 0) > 0 && (
|
||||||
<Paper sx={{ p: 2 }}>
|
<Paper sx={{ p: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Offene Shop-Anfragen ({requests?.length})
|
Offene Ausrüstungsanfragen ({requests?.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
{requestsLoading ? (
|
{requestsLoading ? (
|
||||||
<CircularProgress size={24} />
|
<CircularProgress size={24} />
|
||||||
@@ -69,14 +69,14 @@ function BestellungenTab() {
|
|||||||
key={req.id}
|
key={req.id}
|
||||||
hover
|
hover
|
||||||
sx={{ cursor: 'pointer' }}
|
sx={{ cursor: 'pointer' }}
|
||||||
onClick={() => navigate('/shop?tab=2')}
|
onClick={() => navigate('/ausruestungsanfrage?tab=2')}
|
||||||
>
|
>
|
||||||
<TableCell>{req.id}</TableCell>
|
<TableCell>{req.id}</TableCell>
|
||||||
<TableCell>{req.anfrager_name || '–'}</TableCell>
|
<TableCell>{req.anfrager_name || '–'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<Chip
|
||||||
label={SHOP_STATUS_LABELS[req.status as ShopAnfrageStatus]}
|
label={AUSRUESTUNG_STATUS_LABELS[req.status as AusruestungAnfrageStatus]}
|
||||||
color={SHOP_STATUS_COLORS[req.status as ShopAnfrageStatus]}
|
color={AUSRUESTUNG_STATUS_COLORS[req.status as AusruestungAnfrageStatus]}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
CircularProgress, Divider,
|
CircularProgress, Divider,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
||||||
|
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
|
||||||
@@ -25,6 +26,18 @@ const SECTIONS: CleanupSection[] = [
|
|||||||
{ key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 },
|
{ key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface ResetSection {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESET_SECTIONS: ResetSection[] = [
|
||||||
|
{ key: 'reset-bestellungen', label: 'Bestellungen zuruecksetzen', description: 'Alle Bestellungen, Positionen, Dateien, Erinnerungen und Historie loeschen und Nummern zuruecksetzen.' },
|
||||||
|
{ key: 'reset-ausruestung-anfragen', label: 'Ausruestungsanfragen zuruecksetzen', description: 'Alle Ausruestungsanfragen und zugehoerige Positionen loeschen und Nummern zuruecksetzen.' },
|
||||||
|
{ key: 'reset-issues', label: 'Issues zuruecksetzen', description: 'Alle Issues und Kommentare loeschen und Nummern zuruecksetzen.' },
|
||||||
|
];
|
||||||
|
|
||||||
interface SectionState {
|
interface SectionState {
|
||||||
days: number;
|
days: number;
|
||||||
previewCount: number | null;
|
previewCount: number | null;
|
||||||
@@ -41,6 +54,13 @@ export default function DataManagementTab() {
|
|||||||
const [confirmDialog, setConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null);
|
const [confirmDialog, setConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Reset sections state
|
||||||
|
const [resetStates, setResetStates] = useState<Record<string, { previewCount: number | null; loading: boolean }>>(() =>
|
||||||
|
Object.fromEntries(RESET_SECTIONS.map(s => [s.key, { previewCount: null, loading: false }]))
|
||||||
|
);
|
||||||
|
const [resetConfirmDialog, setResetConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null);
|
||||||
|
const [resetDeleting, setResetDeleting] = useState(false);
|
||||||
|
|
||||||
const updateState = useCallback((key: string, partial: Partial<SectionState>) => {
|
const updateState = useCallback((key: string, partial: Partial<SectionState>) => {
|
||||||
setStates(prev => ({ ...prev, [key]: { ...prev[key], ...partial } }));
|
setStates(prev => ({ ...prev, [key]: { ...prev[key], ...partial } }));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -76,6 +96,34 @@ export default function DataManagementTab() {
|
|||||||
}
|
}
|
||||||
}, [confirmDialog, states, updateState, showSuccess, showError]);
|
}, [confirmDialog, states, updateState, showSuccess, showError]);
|
||||||
|
|
||||||
|
const handleResetPreview = useCallback(async (key: string) => {
|
||||||
|
setResetStates(prev => ({ ...prev, [key]: { previewCount: null, loading: true } }));
|
||||||
|
try {
|
||||||
|
const res = await api.delete(`/api/admin/cleanup/${key}`);
|
||||||
|
setResetStates(prev => ({ ...prev, [key]: { previewCount: res.data.data.count, loading: false } }));
|
||||||
|
} catch {
|
||||||
|
showError('Vorschau konnte nicht geladen werden');
|
||||||
|
setResetStates(prev => ({ ...prev, [key]: { ...prev[key], loading: false } }));
|
||||||
|
}
|
||||||
|
}, [showError]);
|
||||||
|
|
||||||
|
const handleResetDelete = useCallback(async () => {
|
||||||
|
if (!resetConfirmDialog) return;
|
||||||
|
const { key } = resetConfirmDialog;
|
||||||
|
setResetDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await api.delete(`/api/admin/cleanup/${key}?confirm=true`);
|
||||||
|
const deleted = res.data.data.count;
|
||||||
|
showSuccess(`${deleted} Eintraege geloescht und Nummern zurueckgesetzt`);
|
||||||
|
setResetStates(prev => ({ ...prev, [key]: { previewCount: null, loading: false } }));
|
||||||
|
} catch {
|
||||||
|
showError('Zuruecksetzen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setResetDeleting(false);
|
||||||
|
setResetConfirmDialog(null);
|
||||||
|
}
|
||||||
|
}, [resetConfirmDialog, showSuccess, showError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 800 }}>
|
<Box sx={{ maxWidth: 800 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>Datenverwaltung</Typography>
|
<Typography variant="h6" sx={{ mb: 1 }}>Datenverwaltung</Typography>
|
||||||
@@ -165,6 +213,81 @@ export default function DataManagementTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ---- Reset / Truncate sections ---- */}
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>Daten zuruecksetzen</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Alle Eintraege loeschen und Nummern (IDs) auf 1 zuruecksetzen. Abhaengige Daten werden ebenfalls geloescht.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{RESET_SECTIONS.map((section) => {
|
||||||
|
const rs = resetStates[section.key];
|
||||||
|
return (
|
||||||
|
<Paper key={section.key} sx={{ p: 3, mb: 2 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>{section.label}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{section.description}</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleResetPreview(section.key)}
|
||||||
|
disabled={rs?.loading}
|
||||||
|
startIcon={rs?.loading ? <CircularProgress size={16} /> : undefined}
|
||||||
|
>
|
||||||
|
Vorschau
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{rs?.previewCount !== null && rs?.previewCount !== undefined && (
|
||||||
|
<>
|
||||||
|
<Alert severity={rs.previewCount > 0 ? 'warning' : 'info'} sx={{ py: 0 }}>
|
||||||
|
{rs.previewCount} {rs.previewCount === 1 ? 'Eintrag' : 'Eintraege'} vorhanden
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{rs.previewCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
startIcon={<RestartAltIcon />}
|
||||||
|
onClick={() => setResetConfirmDialog({ key: section.key, label: section.label, count: rs.previewCount! })}
|
||||||
|
>
|
||||||
|
Zuruecksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Dialog open={!!resetConfirmDialog} onClose={() => !resetDeleting && setResetConfirmDialog(null)}>
|
||||||
|
<DialogTitle>Daten zuruecksetzen?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
{resetConfirmDialog && (
|
||||||
|
<>
|
||||||
|
<strong>{resetConfirmDialog.count}</strong> {resetConfirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{resetConfirmDialog.label}</strong> werden
|
||||||
|
unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setResetConfirmDialog(null)} disabled={resetDeleting}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleResetDelete}
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
disabled={resetDeleting}
|
||||||
|
startIcon={resetDeleting ? <CircularProgress size={16} /> : <RestartAltIcon />}
|
||||||
|
>
|
||||||
|
{resetDeleting ? 'Wird zurueckgesetzt...' : 'Endgueltig zuruecksetzen'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, Paper, Typography, Button, Autocomplete, TextField,
|
Accordion, AccordionDetails, AccordionSummary,
|
||||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
|
Box, Button, Card, CardContent, Checkbox, Chip, Paper, Typography,
|
||||||
CircularProgress,
|
Autocomplete, TextField, Dialog, DialogTitle, DialogContent,
|
||||||
|
DialogContentText, DialogActions, CircularProgress, FormControlLabel,
|
||||||
|
IconButton, Tooltip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import SyncIcon from '@mui/icons-material/Sync';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminApi } from '../../services/admin';
|
import { adminApi } from '../../services/admin';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
import type { UserOverview } from '../../types/admin.types';
|
import type { UserOverview } from '../../types/admin.types';
|
||||||
|
|
||||||
export default function DebugTab() {
|
export default function DebugTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
// ── Profile deletion ──
|
||||||
const { data: users = [], isLoading: usersLoading } = useQuery<UserOverview[]>({
|
const { data: users = [], isLoading: usersLoading } = useQuery<UserOverview[]>({
|
||||||
queryKey: ['admin', 'users'],
|
queryKey: ['admin', 'users'],
|
||||||
queryFn: adminApi.getUsers,
|
queryFn: adminApi.getUsers,
|
||||||
@@ -38,14 +45,55 @@ export default function DebugTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── FDISK Sync ──
|
||||||
|
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [force, setForce] = useState(false);
|
||||||
|
|
||||||
|
const { data: syncData, isLoading: syncLoading, isError: syncError } = useQuery({
|
||||||
|
queryKey: ['admin', 'fdisk-sync', 'logs'],
|
||||||
|
queryFn: adminApi.fdiskSyncLogs,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (logBoxRef.current) {
|
||||||
|
logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [syncData?.logs.length]);
|
||||||
|
|
||||||
|
const triggerMutation = useMutation({
|
||||||
|
mutationFn: (forceSync: boolean) => adminApi.fdiskSyncTrigger(forceSync),
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccess('Sync gestartet');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'fdisk-sync', 'logs'] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = (err as { response?: { status?: number } })?.response?.status === 409
|
||||||
|
? 'Sync läuft bereits'
|
||||||
|
: 'Sync konnte nicht gestartet werden';
|
||||||
|
showError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const running = syncData?.running ?? false;
|
||||||
|
|
||||||
|
const copyLogs = useCallback(() => {
|
||||||
|
const text = (syncData?.logs ?? []).map((e) => e.line).join('\n');
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => showSuccess('Logs kopiert'),
|
||||||
|
() => showError('Kopieren fehlgeschlagen'),
|
||||||
|
);
|
||||||
|
}, [syncData?.logs, showSuccess, showError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 600 }}>
|
<Box>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>Debug-Werkzeuge</Typography>
|
<Typography variant="h6" sx={{ mb: 1 }}>Debug-Werkzeuge</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
Werkzeuge fuer Fehlersuche und Datenbereinigung.
|
Werkzeuge fuer Fehlersuche und Datenbereinigung.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Paper sx={{ p: 3 }}>
|
{/* Profile deletion */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3, maxWidth: 600 }}>
|
||||||
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
|
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
|
||||||
Profildaten loeschen
|
Profildaten loeschen
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -80,6 +128,111 @@ export default function DebugTab() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{/* FDISK Sync */}
|
||||||
|
<Accordion defaultExpanded={false}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>FDISK Synchronisation</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Synchronisiert Mitgliederdaten und Ausbildungen aus FDISK in die Datenbank.
|
||||||
|
Läuft automatisch täglich um Mitternacht.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={running ? 'Läuft…' : 'Bereit'}
|
||||||
|
color={running ? 'warning' : 'success'}
|
||||||
|
size="small"
|
||||||
|
icon={running ? <CircularProgress size={12} color="inherit" /> : undefined}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SyncIcon />}
|
||||||
|
onClick={() => triggerMutation.mutate(force)}
|
||||||
|
disabled={running || triggerMutation.isPending}
|
||||||
|
>
|
||||||
|
Jetzt synchronisieren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={force} onChange={(e) => setForce(e.target.checked)} />}
|
||||||
|
label="Alle Mitglieder erzwungen synchronisieren"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="outlined" sx={{ mt: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2">Protokoll (letzte 500 Zeilen)</Typography>
|
||||||
|
<Tooltip title="Logs kopieren">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={copyLogs}
|
||||||
|
disabled={!syncData?.logs?.length}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{syncLoading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{syncError && (
|
||||||
|
<Typography color="error" variant="body2">
|
||||||
|
Sync-Dienst nicht erreichbar. Läuft der fdisk-sync Container?
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{!syncLoading && !syncError && (
|
||||||
|
<Box
|
||||||
|
ref={logBoxRef}
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: 'grey.900',
|
||||||
|
color: 'grey.100',
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1.5,
|
||||||
|
maxHeight: 500,
|
||||||
|
overflowY: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(syncData?.logs ?? []).length === 0 ? (
|
||||||
|
<Typography variant="caption" color="grey.500">Noch keine Logs vorhanden.</Typography>
|
||||||
|
) : (
|
||||||
|
(syncData?.logs ?? []).map((entry, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
color: entry.line.includes('ERROR') || entry.line.includes('WARN')
|
||||||
|
? (entry.line.includes('ERROR') ? 'error.light' : 'warning.light')
|
||||||
|
: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.line}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
<Dialog open={confirmOpen} onClose={() => !deleting && setConfirmOpen(false)}>
|
<Dialog open={confirmOpen} onClose={() => !deleting && setConfirmOpen(false)}>
|
||||||
<DialogTitle>Profildaten loeschen?</DialogTitle>
|
<DialogTitle>Profildaten loeschen?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
|
|||||||
'Erinnerungen': ['manage_reminders'],
|
'Erinnerungen': ['manage_reminders'],
|
||||||
'Widget': ['widget'],
|
'Widget': ['widget'],
|
||||||
},
|
},
|
||||||
shop: {
|
ausruestungsanfrage: {
|
||||||
'Katalog': ['view', 'manage_catalog'],
|
'Katalog': ['view', 'manage_catalog'],
|
||||||
'Anfragen': ['create_request', 'approve_requests', 'link_orders', 'view_overview', 'order_for_user'],
|
'Anfragen': ['create_request', 'approve_requests', 'link_orders', 'view_overview', 'order_for_user'],
|
||||||
'Widget': ['widget'],
|
'Widget': ['widget'],
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Card, CardContent, Typography, Box, Chip, List, ListItem, ListItemText, Divider, Skeleton } from '@mui/material';
|
import { Card, CardContent, Typography, Box, Chip, List, ListItem, ListItemText, Divider, Skeleton } from '@mui/material';
|
||||||
import { Store } from '@mui/icons-material';
|
import { Build } from '@mui/icons-material';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { shopApi } from '../../services/shop';
|
import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage';
|
||||||
import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../../types/shop.types';
|
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../../types/ausruestungsanfrage.types';
|
||||||
import type { ShopAnfrageStatus } from '../../types/shop.types';
|
import type { AusruestungAnfrageStatus } from '../../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
function ShopWidget() {
|
function AusruestungsanfrageWidget() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: requests, isLoading, isError } = useQuery({
|
const { data: requests, isLoading, isError } = useQuery({
|
||||||
queryKey: ['shop-widget-requests'],
|
queryKey: ['ausruestungsanfrage-widget-requests'],
|
||||||
queryFn: () => shopApi.getRequests({ status: 'offen' }),
|
queryFn: () => ausruestungsanfrageApi.getRequests({ status: 'offen' }),
|
||||||
refetchInterval: 5 * 60 * 1000,
|
refetchInterval: 5 * 60 * 1000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
@@ -20,7 +20,7 @@ function ShopWidget() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>Shop-Anfragen</Typography>
|
<Typography variant="h6" gutterBottom>Ausrüstungsanfragen</Typography>
|
||||||
<Skeleton variant="rectangular" height={60} />
|
<Skeleton variant="rectangular" height={60} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -31,7 +31,7 @@ function ShopWidget() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>Shop-Anfragen</Typography>
|
<Typography variant="h6" gutterBottom>Ausrüstungsanfragen</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Anfragen konnten nicht geladen werden.
|
Anfragen konnten nicht geladen werden.
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -46,9 +46,9 @@ function ShopWidget() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>Shop-Anfragen</Typography>
|
<Typography variant="h6" gutterBottom>Ausrüstungsanfragen</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
|
||||||
<Store fontSize="small" />
|
<Build fontSize="small" />
|
||||||
<Typography variant="body2">Keine offenen Anfragen</Typography>
|
<Typography variant="body2">Keine offenen Anfragen</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -60,7 +60,7 @@ function ShopWidget() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
<Typography variant="h6">Shop-Anfragen</Typography>
|
<Typography variant="h6">Ausrüstungsanfragen</Typography>
|
||||||
<Chip label={`${pendingCount} offen`} size="small" color="warning" />
|
<Chip label={`${pendingCount} offen`} size="small" color="warning" />
|
||||||
</Box>
|
</Box>
|
||||||
<List dense disablePadding>
|
<List dense disablePadding>
|
||||||
@@ -70,7 +70,7 @@ function ShopWidget() {
|
|||||||
<ListItem
|
<ListItem
|
||||||
disablePadding
|
disablePadding
|
||||||
sx={{ cursor: 'pointer', py: 0.5, '&:hover': { bgcolor: 'action.hover' } }}
|
sx={{ cursor: 'pointer', py: 0.5, '&:hover': { bgcolor: 'action.hover' } }}
|
||||||
onClick={() => navigate('/shop?tab=2')}
|
onClick={() => navigate('/ausruestungsanfrage?tab=2')}
|
||||||
>
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={`Anfrage #${req.id}`}
|
primary={`Anfrage #${req.id}`}
|
||||||
@@ -79,8 +79,8 @@ function ShopWidget() {
|
|||||||
secondaryTypographyProps={{ variant: 'caption' }}
|
secondaryTypographyProps={{ variant: 'caption' }}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label={SHOP_STATUS_LABELS[req.status as ShopAnfrageStatus]}
|
label={AUSRUESTUNG_STATUS_LABELS[req.status as AusruestungAnfrageStatus]}
|
||||||
color={SHOP_STATUS_COLORS[req.status as ShopAnfrageStatus]}
|
color={AUSRUESTUNG_STATUS_COLORS[req.status as AusruestungAnfrageStatus]}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ ml: 1 }}
|
sx={{ ml: 1 }}
|
||||||
/>
|
/>
|
||||||
@@ -93,7 +93,7 @@ function ShopWidget() {
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{ cursor: 'pointer', mt: 1, display: 'block' }}
|
sx={{ cursor: 'pointer', mt: 1, display: 'block' }}
|
||||||
onClick={() => navigate('/shop?tab=2')}
|
onClick={() => navigate('/ausruestungsanfrage?tab=2')}
|
||||||
>
|
>
|
||||||
Alle {pendingCount} Anfragen anzeigen
|
Alle {pendingCount} Anfragen anzeigen
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -103,4 +103,4 @@ function ShopWidget() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ShopWidget;
|
export default AusruestungsanfrageWidget;
|
||||||
@@ -19,4 +19,4 @@ export { default as BannerWidget } from './BannerWidget';
|
|||||||
export { default as LinksWidget } from './LinksWidget';
|
export { default as LinksWidget } from './LinksWidget';
|
||||||
export { default as WidgetGroup } from './WidgetGroup';
|
export { default as WidgetGroup } from './WidgetGroup';
|
||||||
export { default as BestellungenWidget } from './BestellungenWidget';
|
export { default as BestellungenWidget } from './BestellungenWidget';
|
||||||
export { default as ShopWidget } from './ShopWidget';
|
export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import {
|
|||||||
Toolbar,
|
Toolbar,
|
||||||
Typography,
|
Typography,
|
||||||
IconButton,
|
IconButton,
|
||||||
Button,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
Divider,
|
Divider,
|
||||||
Box,
|
Box,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
LocalFireDepartment,
|
LocalFireDepartment,
|
||||||
@@ -19,14 +18,11 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Logout,
|
Logout,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Launch,
|
|
||||||
Chat,
|
Chat,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
import { configApi } from '../../services/config';
|
|
||||||
import { useLayout } from '../../contexts/LayoutContext';
|
import { useLayout } from '../../contexts/LayoutContext';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -38,14 +34,6 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toggleChatPanel } = useLayout();
|
const { toggleChatPanel } = useLayout();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const [toolsAnchorEl, setToolsAnchorEl] = useState<null | HTMLElement>(null);
|
|
||||||
|
|
||||||
const { data: externalLinks } = useQuery({
|
|
||||||
queryKey: ['external-links'],
|
|
||||||
queryFn: () => configApi.getExternalLinks(),
|
|
||||||
staleTime: 10 * 60 * 1000,
|
|
||||||
enabled: !!user,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
@@ -55,14 +43,6 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToolsOpen = (event: React.MouseEvent<HTMLElement>) => {
|
|
||||||
setToolsAnchorEl(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToolsClose = () => {
|
|
||||||
setToolsAnchorEl(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProfile = () => {
|
const handleProfile = () => {
|
||||||
handleMenuClose();
|
handleMenuClose();
|
||||||
navigate('/profile');
|
navigate('/profile');
|
||||||
@@ -78,11 +58,6 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
logout();
|
logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenExternal = (url: string) => {
|
|
||||||
handleToolsClose();
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get initials for avatar
|
// Get initials for avatar
|
||||||
const getInitials = () => {
|
const getInitials = () => {
|
||||||
if (!user) return '?';
|
if (!user) return '?';
|
||||||
@@ -90,19 +65,6 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
return initials || user.name?.[0] || '?';
|
return initials || user.name?.[0] || '?';
|
||||||
};
|
};
|
||||||
|
|
||||||
const linkEntries = externalLinks
|
|
||||||
? Object.entries(externalLinks).filter(([key, url]) => key !== 'customLinks' && !!url)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const customLinks: Array<{ name: string; url: string }> =
|
|
||||||
externalLinks?.customLinks ?? [];
|
|
||||||
|
|
||||||
const linkLabels: Record<string, string> = {
|
|
||||||
nextcloud: 'Nextcloud Dateien',
|
|
||||||
bookstack: 'Wissensdatenbank',
|
|
||||||
vikunja: 'Aufgabenverwaltung',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar
|
<AppBar
|
||||||
position="fixed"
|
position="fixed"
|
||||||
@@ -128,72 +90,6 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
{(linkEntries.length > 0 || customLinks.length > 0) && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
color="inherit"
|
|
||||||
onClick={handleToolsOpen}
|
|
||||||
size="small"
|
|
||||||
startIcon={<Launch />}
|
|
||||||
aria-label="FF Rems Tools"
|
|
||||||
aria-controls="tools-menu"
|
|
||||||
aria-haspopup="true"
|
|
||||||
sx={{ display: { xs: 'none', sm: 'inline-flex' } }}
|
|
||||||
>
|
|
||||||
FF Rems Tools
|
|
||||||
</Button>
|
|
||||||
<Tooltip title="FF Rems Tools">
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
onClick={handleToolsOpen}
|
|
||||||
size="small"
|
|
||||||
aria-label="FF Rems Tools"
|
|
||||||
sx={{ display: { xs: 'inline-flex', sm: 'none' } }}
|
|
||||||
>
|
|
||||||
<Launch />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Menu
|
|
||||||
id="tools-menu"
|
|
||||||
anchorEl={toolsAnchorEl}
|
|
||||||
open={Boolean(toolsAnchorEl)}
|
|
||||||
onClose={handleToolsClose}
|
|
||||||
anchorOrigin={{
|
|
||||||
vertical: 'bottom',
|
|
||||||
horizontal: 'right',
|
|
||||||
}}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: 'top',
|
|
||||||
horizontal: 'right',
|
|
||||||
}}
|
|
||||||
PaperProps={{
|
|
||||||
elevation: 3,
|
|
||||||
sx: { minWidth: 180, mt: 1 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{linkEntries.map(([key, url]) => (
|
|
||||||
<MenuItem key={key} onClick={() => handleOpenExternal(url)}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Launch fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
{linkLabels[key] || key}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
{customLinks.length > 0 && linkEntries.length > 0 && <Divider />}
|
|
||||||
{customLinks.map((link, index) => (
|
|
||||||
<MenuItem key={`custom-${index}`} onClick={() => handleOpenExternal(link.url)}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Launch fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
{link.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip title="Chat">
|
<Tooltip title="Chat">
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
ExpandMore,
|
ExpandMore,
|
||||||
ExpandLess,
|
ExpandLess,
|
||||||
LocalShipping,
|
LocalShipping,
|
||||||
Store,
|
|
||||||
BugReport,
|
BugReport,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
@@ -62,11 +61,10 @@ const adminSubItems: SubItem[] = [
|
|||||||
{ text: 'Broadcast', path: '/admin?tab=3' },
|
{ text: 'Broadcast', path: '/admin?tab=3' },
|
||||||
{ text: 'Banner', path: '/admin?tab=4' },
|
{ text: 'Banner', path: '/admin?tab=4' },
|
||||||
{ text: 'Wartung', path: '/admin?tab=5' },
|
{ text: 'Wartung', path: '/admin?tab=5' },
|
||||||
{ text: 'FDISK Sync', path: '/admin?tab=6' },
|
{ text: 'Berechtigungen', path: '/admin?tab=6' },
|
||||||
{ text: 'Berechtigungen', path: '/admin?tab=7' },
|
{ text: 'Bestellungen', path: '/admin?tab=7' },
|
||||||
{ text: 'Bestellungen', path: '/admin?tab=8' },
|
{ text: 'Datenverwaltung', path: '/admin?tab=8' },
|
||||||
{ text: 'Datenverwaltung', path: '/admin?tab=9' },
|
{ text: 'Debug', path: '/admin?tab=9' },
|
||||||
{ text: 'Debug', path: '/admin?tab=10' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseNavigationItems: NavigationItem[] = [
|
const baseNavigationItems: NavigationItem[] = [
|
||||||
@@ -86,7 +84,7 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
text: 'Fahrzeuge',
|
text: 'Fahrzeuge',
|
||||||
icon: <DirectionsCar />,
|
icon: <DirectionsCar />,
|
||||||
path: '/fahrzeuge',
|
path: '/fahrzeuge',
|
||||||
permission: 'fahrzeuge:access',
|
permission: 'fahrzeuge:view',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Ausrüstung',
|
text: 'Ausrüstung',
|
||||||
@@ -123,18 +121,18 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
permission: 'bestellungen:view',
|
permission: 'bestellungen:view',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Shop',
|
text: 'Ausrüstungsanfragen',
|
||||||
icon: <Store />,
|
icon: <Build />,
|
||||||
path: '/shop',
|
path: '/ausruestungsanfrage',
|
||||||
// subItems computed dynamically in navigationItems useMemo
|
// subItems computed dynamically in navigationItems useMemo
|
||||||
permission: 'shop:view',
|
permission: 'ausruestungsanfrage:view',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Issues',
|
text: 'Issues',
|
||||||
icon: <BugReport />,
|
icon: <BugReport />,
|
||||||
path: '/issues',
|
path: '/issues',
|
||||||
// subItems computed dynamically in navigationItems useMemo
|
// subItems computed dynamically in navigationItems useMemo
|
||||||
permission: 'issues:create',
|
permission: 'issues:view_own',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -187,24 +185,27 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
permission: 'fahrzeuge:view',
|
permission: 'fahrzeuge:view',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build Shop sub-items dynamically based on permissions (tab order must match Shop.tsx)
|
// Build Ausrüstungsanfrage sub-items dynamically based on permissions (tab order must match Ausruestungsanfrage.tsx)
|
||||||
const shopSubItems: SubItem[] = [];
|
const ausruestungSubItems: SubItem[] = [];
|
||||||
let shopTabIdx = 0;
|
let ausruestungTabIdx = 0;
|
||||||
if (hasPermission('shop:create_request')) { shopSubItems.push({ text: 'Meine Anfragen', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; }
|
if (hasPermission('ausruestungsanfrage:create_request')) { ausruestungSubItems.push({ text: 'Meine Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
|
||||||
if (hasPermission('shop:approve_requests')) { shopSubItems.push({ text: 'Alle Anfragen', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; }
|
if (hasPermission('ausruestungsanfrage:approve_requests')) { ausruestungSubItems.push({ text: 'Alle Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
|
||||||
if (hasPermission('shop:view_overview')) { shopSubItems.push({ text: 'Übersicht', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; }
|
if (hasPermission('ausruestungsanfrage:view_overview')) { ausruestungSubItems.push({ text: 'Übersicht', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
|
||||||
shopSubItems.push({ text: 'Katalog', path: `/shop?tab=${shopTabIdx}` });
|
ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` });
|
||||||
|
|
||||||
// Build Issues sub-items dynamically (tab order must match Issues.tsx)
|
// Build Issues sub-items dynamically (tab order must match Issues.tsx)
|
||||||
const issuesSubItems: SubItem[] = [{ text: 'Meine Issues', path: '/issues?tab=0' }];
|
const issuesSubItems: SubItem[] = [{ text: 'Meine Issues', path: '/issues?tab=0' }];
|
||||||
if (hasPermission('issues:view_all')) {
|
if (hasPermission('issues:view_all')) {
|
||||||
issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=1' });
|
issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=1' });
|
||||||
}
|
}
|
||||||
|
if (hasPermission('issues:manage')) {
|
||||||
|
issuesSubItems.push({ text: 'Erledigte Issues', path: `/issues?tab=${issuesSubItems.length}` });
|
||||||
|
}
|
||||||
|
|
||||||
const items = baseNavigationItems
|
const items = baseNavigationItems
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (item.path === '/fahrzeuge') return fahrzeugeItem;
|
if (item.path === '/fahrzeuge') return fahrzeugeItem;
|
||||||
if (item.path === '/shop') return { ...item, subItems: shopSubItems };
|
if (item.path === '/ausruestungsanfrage') return { ...item, subItems: ausruestungSubItems };
|
||||||
if (item.path === '/issues') return { ...item, subItems: issuesSubItems };
|
if (item.path === '/issues') return { ...item, subItems: issuesSubItems };
|
||||||
return item;
|
return item;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const WIDGETS = [
|
|||||||
{ key: 'adminStatus', label: 'Admin Status', defaultVisible: true },
|
{ key: 'adminStatus', label: 'Admin Status', defaultVisible: true },
|
||||||
{ key: 'links', label: 'Links', defaultVisible: true },
|
{ key: 'links', label: 'Links', defaultVisible: true },
|
||||||
{ key: 'bestellungen', label: 'Bestellungen', defaultVisible: true },
|
{ key: 'bestellungen', label: 'Bestellungen', defaultVisible: true },
|
||||||
{ key: 'shopRequests', label: 'Shop-Anfragen', defaultVisible: true },
|
{ key: 'ausruestungsanfragen', label: 'Ausrüstungsanfragen', defaultVisible: true },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WidgetKey = typeof WIDGETS[number]['key'];
|
export type WidgetKey = typeof WIDGETS[number]['key'];
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import UserOverviewTab from '../components/admin/UserOverviewTab';
|
|||||||
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
|
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
|
||||||
import BannerManagementTab from '../components/admin/BannerManagementTab';
|
import BannerManagementTab from '../components/admin/BannerManagementTab';
|
||||||
import ServiceModeTab from '../components/admin/ServiceModeTab';
|
import ServiceModeTab from '../components/admin/ServiceModeTab';
|
||||||
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
|
|
||||||
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
|
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
|
||||||
import BestellungenTab from '../components/admin/BestellungenTab';
|
import BestellungenTab from '../components/admin/BestellungenTab';
|
||||||
import DataManagementTab from '../components/admin/DataManagementTab';
|
import DataManagementTab from '../components/admin/DataManagementTab';
|
||||||
@@ -26,7 +25,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
|||||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_TAB_COUNT = 11;
|
const ADMIN_TAB_COUNT = 10;
|
||||||
|
|
||||||
function AdminDashboard() {
|
function AdminDashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -58,7 +57,6 @@ function AdminDashboard() {
|
|||||||
<Tab label="Broadcast" />
|
<Tab label="Broadcast" />
|
||||||
<Tab label="Banner" />
|
<Tab label="Banner" />
|
||||||
<Tab label="Wartung" />
|
<Tab label="Wartung" />
|
||||||
<Tab label="FDISK Sync" />
|
|
||||||
<Tab label="Berechtigungen" />
|
<Tab label="Berechtigungen" />
|
||||||
<Tab label="Bestellungen" />
|
<Tab label="Bestellungen" />
|
||||||
<Tab label="Datenverwaltung" />
|
<Tab label="Datenverwaltung" />
|
||||||
@@ -85,18 +83,15 @@ function AdminDashboard() {
|
|||||||
<ServiceModeTab />
|
<ServiceModeTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={6}>
|
<TabPanel value={tab} index={6}>
|
||||||
<FdiskSyncTab />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel value={tab} index={7}>
|
|
||||||
<PermissionMatrixTab />
|
<PermissionMatrixTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={8}>
|
<TabPanel value={tab} index={7}>
|
||||||
<BestellungenTab />
|
<BestellungenTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={9}>
|
<TabPanel value={tab} index={8}>
|
||||||
<DataManagementTab />
|
<DataManagementTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={10}>
|
<TabPanel value={tab} index={9}>
|
||||||
<DebugTab />
|
<DebugTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { shopApi } from '../services/shop';
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
import { bestellungApi } from '../services/bestellung';
|
||||||
import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types';
|
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
|
||||||
import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus, ShopAnfrage, ShopOverview } from '../types/shop.types';
|
import type { AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem, AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage, AusruestungOverview } from '../types/ausruestungsanfrage.types';
|
||||||
import type { Bestellung } from '../types/bestellung.types';
|
import type { Bestellung } from '../types/bestellung.types';
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatOrderId(r: ShopAnfrage): string {
|
function formatOrderId(r: AusruestungAnfrage): string {
|
||||||
if (r.bestell_jahr && r.bestell_nummer) {
|
if (r.bestell_jahr && r.bestell_nummer) {
|
||||||
return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`;
|
return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
@@ -46,8 +46,8 @@ function KatalogTab() {
|
|||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const canManage = hasPermission('shop:manage_catalog');
|
const canManage = hasPermission('ausruestungsanfrage:manage_catalog');
|
||||||
const canCreate = hasPermission('shop:create_request');
|
const canCreate = hasPermission('ausruestungsanfrage:create_request');
|
||||||
|
|
||||||
const [filterKategorie, setFilterKategorie] = useState<string>('');
|
const [filterKategorie, setFilterKategorie] = useState<string>('');
|
||||||
const [draft, setDraft] = useState<DraftItem[]>([]);
|
const [draft, setDraft] = useState<DraftItem[]>([]);
|
||||||
@@ -55,38 +55,38 @@ function KatalogTab() {
|
|||||||
const [submitOpen, setSubmitOpen] = useState(false);
|
const [submitOpen, setSubmitOpen] = useState(false);
|
||||||
const [submitNotizen, setSubmitNotizen] = useState('');
|
const [submitNotizen, setSubmitNotizen] = useState('');
|
||||||
const [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
|
const [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
|
||||||
const [editArtikel, setEditArtikel] = useState<ShopArtikel | null>(null);
|
const [editArtikel, setEditArtikel] = useState<AusruestungArtikel | null>(null);
|
||||||
const [artikelForm, setArtikelForm] = useState<ShopArtikelFormData>({ bezeichnung: '' });
|
const [artikelForm, setArtikelForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' });
|
||||||
|
|
||||||
const { data: items = [], isLoading } = useQuery({
|
const { data: items = [], isLoading } = useQuery({
|
||||||
queryKey: ['shop', 'items', filterKategorie],
|
queryKey: ['ausruestungsanfrage', 'items', filterKategorie],
|
||||||
queryFn: () => shopApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined),
|
queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: categories = [] } = useQuery({
|
const { data: categories = [] } = useQuery({
|
||||||
queryKey: ['shop', 'categories'],
|
queryKey: ['ausruestungsanfrage', 'categories'],
|
||||||
queryFn: () => shopApi.getCategories(),
|
queryFn: () => ausruestungsanfrageApi.getCategories(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createItemMut = useMutation({
|
const createItemMut = useMutation({
|
||||||
mutationFn: (data: ShopArtikelFormData) => shopApi.createItem(data),
|
mutationFn: (data: AusruestungArtikelFormData) => ausruestungsanfrageApi.createItem(data),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel erstellt'); setArtikelDialogOpen(false); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel erstellt'); setArtikelDialogOpen(false); },
|
||||||
onError: () => showError('Fehler beim Erstellen'),
|
onError: () => showError('Fehler beim Erstellen'),
|
||||||
});
|
});
|
||||||
const updateItemMut = useMutation({
|
const updateItemMut = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: Partial<ShopArtikelFormData> }) => shopApi.updateItem(id, data),
|
mutationFn: ({ id, data }: { id: number; data: Partial<AusruestungArtikelFormData> }) => ausruestungsanfrageApi.updateItem(id, data),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel aktualisiert'); setArtikelDialogOpen(false); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel aktualisiert'); setArtikelDialogOpen(false); },
|
||||||
onError: () => showError('Fehler beim Aktualisieren'),
|
onError: () => showError('Fehler beim Aktualisieren'),
|
||||||
});
|
});
|
||||||
const deleteItemMut = useMutation({
|
const deleteItemMut = useMutation({
|
||||||
mutationFn: (id: number) => shopApi.deleteItem(id),
|
mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel gelöscht'); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel gelöscht'); },
|
||||||
onError: () => showError('Fehler beim Löschen'),
|
onError: () => showError('Fehler beim Löschen'),
|
||||||
});
|
});
|
||||||
const createRequestMut = useMutation({
|
const createRequestMut = useMutation({
|
||||||
mutationFn: ({ items, notizen }: { items: ShopAnfrageFormItem[]; notizen?: string }) => shopApi.createRequest(items, notizen),
|
mutationFn: ({ items, notizen }: { items: AusruestungAnfrageFormItem[]; notizen?: string }) => ausruestungsanfrageApi.createRequest(items, notizen),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['shop'] });
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
||||||
showSuccess('Anfrage gesendet');
|
showSuccess('Anfrage gesendet');
|
||||||
setDraft([]);
|
setDraft([]);
|
||||||
setSubmitOpen(false);
|
setSubmitOpen(false);
|
||||||
@@ -95,7 +95,7 @@ function KatalogTab() {
|
|||||||
onError: () => showError('Fehler beim Senden der Anfrage'),
|
onError: () => showError('Fehler beim Senden der Anfrage'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const addToDraft = (item: ShopArtikel) => {
|
const addToDraft = (item: AusruestungArtikel) => {
|
||||||
setDraft(prev => {
|
setDraft(prev => {
|
||||||
const existing = prev.find(d => d.artikel_id === item.id);
|
const existing = prev.find(d => d.artikel_id === item.id);
|
||||||
if (existing) return prev.map(d => d.artikel_id === item.id ? { ...d, menge: d.menge + 1 } : d);
|
if (existing) return prev.map(d => d.artikel_id === item.id ? { ...d, menge: d.menge + 1 } : d);
|
||||||
@@ -121,7 +121,7 @@ function KatalogTab() {
|
|||||||
setArtikelForm({ bezeichnung: '' });
|
setArtikelForm({ bezeichnung: '' });
|
||||||
setArtikelDialogOpen(true);
|
setArtikelDialogOpen(true);
|
||||||
};
|
};
|
||||||
const openEditArtikel = (a: ShopArtikel) => {
|
const openEditArtikel = (a: AusruestungArtikel) => {
|
||||||
setEditArtikel(a);
|
setEditArtikel(a);
|
||||||
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie });
|
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie });
|
||||||
setArtikelDialogOpen(true);
|
setArtikelDialogOpen(true);
|
||||||
@@ -279,22 +279,75 @@ function KatalogTab() {
|
|||||||
|
|
||||||
function MeineAnfragenTab() {
|
function MeineAnfragenTab() {
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const canCreate = hasPermission('ausruestungsanfrage:create_request');
|
||||||
|
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [newBezeichnung, setNewBezeichnung] = useState('');
|
||||||
|
const [newBemerkung, setNewBemerkung] = useState('');
|
||||||
|
const [selectedArtikel, setSelectedArtikel] = useState<AusruestungArtikel[]>([]);
|
||||||
|
|
||||||
const { data: requests = [], isLoading } = useQuery({
|
const { data: requests = [], isLoading } = useQuery({
|
||||||
queryKey: ['shop', 'myRequests'],
|
queryKey: ['ausruestungsanfrage', 'myRequests'],
|
||||||
queryFn: () => shopApi.getMyRequests(),
|
queryFn: () => ausruestungsanfrageApi.getMyRequests(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: detail } = useQuery<ShopAnfrageDetailResponse>({
|
const { data: detail } = useQuery<AusruestungAnfrageDetailResponse>({
|
||||||
queryKey: ['shop', 'request', expandedId],
|
queryKey: ['ausruestungsanfrage', 'request', expandedId],
|
||||||
queryFn: () => shopApi.getRequest(expandedId!),
|
queryFn: () => ausruestungsanfrageApi.getRequest(expandedId!),
|
||||||
enabled: expandedId != null,
|
enabled: expandedId != null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: catalogItems = [] } = useQuery({
|
||||||
|
queryKey: ['ausruestungsanfrage', 'items-for-create'],
|
||||||
|
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
|
||||||
|
enabled: createDialogOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: ({ items, notizen }: { items: AusruestungAnfrageFormItem[]; notizen?: string }) =>
|
||||||
|
ausruestungsanfrageApi.createRequest(items, notizen),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
||||||
|
showSuccess('Anfrage erstellt');
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
setNewBezeichnung('');
|
||||||
|
setNewBemerkung('');
|
||||||
|
setSelectedArtikel([]);
|
||||||
|
},
|
||||||
|
onError: () => showError('Fehler beim Erstellen der Anfrage'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateSubmit = () => {
|
||||||
|
const items: AusruestungAnfrageFormItem[] = [];
|
||||||
|
|
||||||
|
// Add selected catalog items
|
||||||
|
for (const a of selectedArtikel) {
|
||||||
|
items.push({ artikel_id: a.id, bezeichnung: a.bezeichnung, menge: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add free-text item if provided
|
||||||
|
const text = newBezeichnung.trim();
|
||||||
|
if (text) {
|
||||||
|
items.push({ bezeichnung: text, menge: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
createMut.mutate({ items, notizen: newBemerkung || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
|
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
|
||||||
if (requests.length === 0) return <Typography color="text.secondary">Keine Anfragen vorhanden.</Typography>;
|
if (requests.length === 0 && !canCreate) return <Typography color="text.secondary">Keine Anfragen vorhanden.</Typography>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box>
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ mb: 2 }}>Keine Anfragen vorhanden.</Typography>
|
||||||
|
) : (
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<TableContainer component={Paper} variant="outlined">
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
@@ -313,7 +366,7 @@ function MeineAnfragenTab() {
|
|||||||
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedId(prev => prev === r.id ? null : r.id)}>
|
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedId(prev => prev === r.id ? null : r.id)}>
|
||||||
<TableCell>{expandedId === r.id ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}</TableCell>
|
<TableCell>{expandedId === r.id ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}</TableCell>
|
||||||
<TableCell>{formatOrderId(r)}</TableCell>
|
<TableCell>{formatOrderId(r)}</TableCell>
|
||||||
<TableCell><Chip label={SHOP_STATUS_LABELS[r.status]} color={SHOP_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
||||||
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
|
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
|
||||||
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
|
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
|
||||||
<TableCell>{r.admin_notizen || '-'}</TableCell>
|
<TableCell>{r.admin_notizen || '-'}</TableCell>
|
||||||
@@ -351,6 +404,55 @@ function MeineAnfragenTab() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Request Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Neue Ausrüstungsanfrage</DialogTitle>
|
||||||
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Bezeichnung"
|
||||||
|
placeholder="Was wird benötigt?"
|
||||||
|
value={newBezeichnung}
|
||||||
|
onChange={e => setNewBezeichnung(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={catalogItems}
|
||||||
|
getOptionLabel={o => o.bezeichnung}
|
||||||
|
value={selectedArtikel}
|
||||||
|
onChange={(_, v) => setSelectedArtikel(v)}
|
||||||
|
renderInput={params => <TextField {...params} label="Aus Katalog auswählen (optional)" />}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Bemerkung (optional)"
|
||||||
|
value={newBemerkung}
|
||||||
|
onChange={e => setNewBemerkung(e.target.value)}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleCreateSubmit}
|
||||||
|
disabled={createMut.isPending || (!newBezeichnung.trim() && selectedArtikel.length === 0)}
|
||||||
|
>
|
||||||
|
Anfrage erstellen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* FAB for creating new request */}
|
||||||
|
{canCreate && (
|
||||||
|
<ChatAwareFab onClick={() => setCreateDialogOpen(true)} aria-label="Neue Anfrage erstellen">
|
||||||
|
<AddIcon />
|
||||||
|
</ChatAwareFab>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,13 +470,13 @@ function AlleAnfragenTab() {
|
|||||||
const [selectedBestellung, setSelectedBestellung] = useState<Bestellung | null>(null);
|
const [selectedBestellung, setSelectedBestellung] = useState<Bestellung | null>(null);
|
||||||
|
|
||||||
const { data: requests = [], isLoading } = useQuery({
|
const { data: requests = [], isLoading } = useQuery({
|
||||||
queryKey: ['shop', 'requests', statusFilter],
|
queryKey: ['ausruestungsanfrage', 'requests', statusFilter],
|
||||||
queryFn: () => shopApi.getRequests(statusFilter ? { status: statusFilter } : undefined),
|
queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: detail } = useQuery<ShopAnfrageDetailResponse>({
|
const { data: detail } = useQuery<AusruestungAnfrageDetailResponse>({
|
||||||
queryKey: ['shop', 'request', expandedId],
|
queryKey: ['ausruestungsanfrage', 'request', expandedId],
|
||||||
queryFn: () => shopApi.getRequest(expandedId!),
|
queryFn: () => ausruestungsanfrageApi.getRequest(expandedId!),
|
||||||
enabled: expandedId != null,
|
enabled: expandedId != null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -385,9 +487,9 @@ function AlleAnfragenTab() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const statusMut = useMutation({
|
const statusMut = useMutation({
|
||||||
mutationFn: ({ id, status, notes }: { id: number; status: string; notes?: string }) => shopApi.updateRequestStatus(id, status, notes),
|
mutationFn: ({ id, status, notes }: { id: number; status: string; notes?: string }) => ausruestungsanfrageApi.updateRequestStatus(id, status, notes),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['shop'] });
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
||||||
showSuccess('Status aktualisiert');
|
showSuccess('Status aktualisiert');
|
||||||
setActionDialog(null);
|
setActionDialog(null);
|
||||||
setAdminNotizen('');
|
setAdminNotizen('');
|
||||||
@@ -396,9 +498,9 @@ function AlleAnfragenTab() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const linkMut = useMutation({
|
const linkMut = useMutation({
|
||||||
mutationFn: ({ anfrageId, bestellungId }: { anfrageId: number; bestellungId: number }) => shopApi.linkToOrder(anfrageId, bestellungId),
|
mutationFn: ({ anfrageId, bestellungId }: { anfrageId: number; bestellungId: number }) => ausruestungsanfrageApi.linkToOrder(anfrageId, bestellungId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['shop'] });
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
||||||
showSuccess('Verknüpfung erstellt');
|
showSuccess('Verknüpfung erstellt');
|
||||||
setLinkDialog(null);
|
setLinkDialog(null);
|
||||||
setSelectedBestellung(null);
|
setSelectedBestellung(null);
|
||||||
@@ -419,8 +521,8 @@ function AlleAnfragenTab() {
|
|||||||
<InputLabel>Status Filter</InputLabel>
|
<InputLabel>Status Filter</InputLabel>
|
||||||
<Select value={statusFilter} label="Status Filter" onChange={e => setStatusFilter(e.target.value)}>
|
<Select value={statusFilter} label="Status Filter" onChange={e => setStatusFilter(e.target.value)}>
|
||||||
<MenuItem value="">Alle</MenuItem>
|
<MenuItem value="">Alle</MenuItem>
|
||||||
{(Object.keys(SHOP_STATUS_LABELS) as ShopAnfrageStatus[]).map(s => (
|
{(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => (
|
||||||
<MenuItem key={s} value={s}>{SHOP_STATUS_LABELS[s]}</MenuItem>
|
<MenuItem key={s} value={s}>{AUSRUESTUNG_STATUS_LABELS[s]}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -448,7 +550,7 @@ function AlleAnfragenTab() {
|
|||||||
<TableCell>{expandedId === r.id ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}</TableCell>
|
<TableCell>{expandedId === r.id ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}</TableCell>
|
||||||
<TableCell>{formatOrderId(r)}</TableCell>
|
<TableCell>{formatOrderId(r)}</TableCell>
|
||||||
<TableCell>{r.anfrager_name || r.anfrager_id}</TableCell>
|
<TableCell>{r.anfrager_name || r.anfrager_id}</TableCell>
|
||||||
<TableCell><Chip label={SHOP_STATUS_LABELS[r.status]} color={SHOP_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
||||||
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
|
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
|
||||||
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
|
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
|
||||||
<TableCell onClick={e => e.stopPropagation()}>
|
<TableCell onClick={e => e.stopPropagation()}>
|
||||||
@@ -570,9 +672,9 @@ function AlleAnfragenTab() {
|
|||||||
// ─── Overview Tab ────────────────────────────────────────────────────────────
|
// ─── Overview Tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function UebersichtTab() {
|
function UebersichtTab() {
|
||||||
const { data: overview, isLoading } = useQuery<ShopOverview>({
|
const { data: overview, isLoading } = useQuery<AusruestungOverview>({
|
||||||
queryKey: ['shop', 'overview'],
|
queryKey: ['ausruestungsanfrage', 'overview'],
|
||||||
queryFn: () => shopApi.getOverview(),
|
queryFn: () => ausruestungsanfrageApi.getOverview(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <Typography color="text.secondary">Lade Übersicht...</Typography>;
|
if (isLoading) return <Typography color="text.secondary">Lade Übersicht...</Typography>;
|
||||||
@@ -631,14 +733,14 @@ function UebersichtTab() {
|
|||||||
|
|
||||||
// ─── Main Page ──────────────────────────────────────────────────────────────
|
// ─── Main Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Shop() {
|
export default function Ausruestungsanfrage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
|
|
||||||
const canView = hasPermission('shop:view');
|
const canView = hasPermission('ausruestungsanfrage:view');
|
||||||
const canCreate = hasPermission('shop:create_request');
|
const canCreate = hasPermission('ausruestungsanfrage:create_request');
|
||||||
const canApprove = hasPermission('shop:approve_requests');
|
const canApprove = hasPermission('ausruestungsanfrage:approve_requests');
|
||||||
const canViewOverview = hasPermission('shop:view_overview');
|
const canViewOverview = hasPermission('ausruestungsanfrage:view_overview');
|
||||||
|
|
||||||
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewOverview ? 1 : 0);
|
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewOverview ? 1 : 0);
|
||||||
|
|
||||||
@@ -672,7 +774,7 @@ export default function Shop() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Typography variant="h5" fontWeight={700} sx={{ mb: 2 }}>Shop</Typography>
|
<Typography variant="h5" fontWeight={700} sx={{ mb: 2 }}>Ausrüstungsanfragen</Typography>
|
||||||
|
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||||
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
|
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
|
||||||
@@ -100,7 +100,7 @@ export default function BestellungDetail() {
|
|||||||
const [deleteReminderTarget, setDeleteReminderTarget] = useState<number | null>(null);
|
const [deleteReminderTarget, setDeleteReminderTarget] = useState<number | null>(null);
|
||||||
|
|
||||||
// ── Query ──
|
// ── Query ──
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ['bestellung', orderId],
|
queryKey: ['bestellung', orderId],
|
||||||
queryFn: () => bestellungApi.getOrder(orderId),
|
queryFn: () => bestellungApi.getOrder(orderId),
|
||||||
enabled: !!orderId,
|
enabled: !!orderId,
|
||||||
@@ -263,11 +263,17 @@ export default function BestellungDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !bestellung) {
|
if (isError || !bestellung) {
|
||||||
|
const is404 = (error as any)?.response?.status === 404 || !bestellung;
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||||
<Typography color="error">Bestellung nicht gefunden.</Typography>
|
<Typography color="error">
|
||||||
<Button sx={{ mt: 2 }} onClick={() => navigate('/bestellungen')}>Zurück</Button>
|
{is404 ? 'Bestellung nicht gefunden.' : 'Fehler beim Laden der Bestellung.'}
|
||||||
|
</Typography>
|
||||||
|
{!is404 && (
|
||||||
|
<Button sx={{ mt: 2 }} variant="outlined" onClick={() => refetch()}>Erneut versuchen</Button>
|
||||||
|
)}
|
||||||
|
<Button sx={{ mt: 2, ml: !is404 ? 1 : 0 }} onClick={() => navigate('/bestellungen')}>Zurück</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import LinksWidget from '../components/dashboard/LinksWidget';
|
|||||||
import BannerWidget from '../components/dashboard/BannerWidget';
|
import BannerWidget from '../components/dashboard/BannerWidget';
|
||||||
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
||||||
import BestellungenWidget from '../components/dashboard/BestellungenWidget';
|
import BestellungenWidget from '../components/dashboard/BestellungenWidget';
|
||||||
import ShopWidget from '../components/dashboard/ShopWidget';
|
import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
|
||||||
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';
|
||||||
@@ -141,10 +141,10 @@ function Dashboard() {
|
|||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasPermission('shop:widget') && widgetVisible('shopRequests') && (
|
{hasPermission('ausruestungsanfrage:widget') && widgetVisible('ausruestungsanfragen') && (
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '470ms' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '470ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<ShopWidget />
|
<AusruestungsanfrageWidget />
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
|
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
|
||||||
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
||||||
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
||||||
InputLabel, Collapse, Divider, CircularProgress,
|
InputLabel, Collapse, Divider, CircularProgress, FormControlLabel, Switch,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
|
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
|
||||||
@@ -216,7 +216,7 @@ function IssueRow({
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManage && (
|
{(canManage || isOwner) && (
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
<InputLabel>Status</InputLabel>
|
<InputLabel>Status</InputLabel>
|
||||||
@@ -232,6 +232,7 @@ function IssueRow({
|
|||||||
<MenuItem value="abgelehnt">Abgelehnt</MenuItem>
|
<MenuItem value="abgelehnt">Abgelehnt</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{canManage && (
|
||||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||||
<InputLabel>Priorität</InputLabel>
|
<InputLabel>Priorität</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -245,6 +246,7 @@ function IssueRow({
|
|||||||
<MenuItem value="hoch">Hoch</MenuItem>
|
<MenuItem value="hoch">Hoch</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -328,9 +330,6 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
|
|||||||
|
|
||||||
export default function Issues() {
|
export default function Issues() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
|
|
||||||
const tab = isNaN(tabParam) || tabParam < 0 || tabParam > 1 ? 0 : tabParam;
|
|
||||||
|
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -341,6 +340,11 @@ export default function Issues() {
|
|||||||
const canCreate = hasPermission('issues:create');
|
const canCreate = hasPermission('issues:create');
|
||||||
const userId = user?.id || '';
|
const userId = user?.id || '';
|
||||||
|
|
||||||
|
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
|
||||||
|
const maxTab = canManage ? 2 : (canViewAll ? 1 : 0);
|
||||||
|
const tab = isNaN(tabParam) || tabParam < 0 || tabParam > maxTab ? 0 : tabParam;
|
||||||
|
|
||||||
|
const [showDone, setShowDone] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', typ: 'bug', prioritaet: 'mittel' });
|
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', typ: 'bug', prioritaet: 'mittel' });
|
||||||
|
|
||||||
@@ -365,6 +369,8 @@ export default function Issues() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
|
const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
|
||||||
|
const myIssuesFiltered = myIssues.filter((i: Issue) => showDone || (i.status !== 'erledigt' && i.status !== 'abgelehnt'));
|
||||||
|
const doneIssues = issues.filter((i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -374,15 +380,21 @@ export default function Issues() {
|
|||||||
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
|
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
|
||||||
<Tab label="Meine Issues" />
|
<Tab label="Meine Issues" />
|
||||||
{canViewAll && <Tab label="Alle Issues" />}
|
{canViewAll && <Tab label="Alle Issues" />}
|
||||||
|
{canManage && <Tab label="Erledigte Issues" />}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<TabPanel value={tab} index={0}>
|
<TabPanel value={tab} index={0}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={showDone} onChange={(e) => setShowDone(e.target.checked)} size="small" />}
|
||||||
|
label="Erledigte anzeigen"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<IssueTable issues={myIssues} canManage={canManage} userId={userId} />
|
<IssueTable issues={myIssuesFiltered} canManage={canManage} userId={userId} />
|
||||||
)}
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -397,6 +409,18 @@ export default function Issues() {
|
|||||||
)}
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<TabPanel value={tab} index={canViewAll ? 2 : 1}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<IssueTable issues={doneIssues} canManage={canManage} userId={userId} />
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Create Issue Dialog */}
|
{/* Create Issue Dialog */}
|
||||||
|
|||||||
80
frontend/src/services/ausruestungsanfrage.ts
Normal file
80
frontend/src/services/ausruestungsanfrage.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type {
|
||||||
|
AusruestungArtikel,
|
||||||
|
AusruestungArtikelFormData,
|
||||||
|
AusruestungAnfrage,
|
||||||
|
AusruestungAnfrageDetailResponse,
|
||||||
|
AusruestungAnfrageFormItem,
|
||||||
|
AusruestungOverview,
|
||||||
|
} from '../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
|
export const ausruestungsanfrageApi = {
|
||||||
|
// ── Catalog Items ──
|
||||||
|
getItems: async (filters?: { kategorie?: string; aktiv?: boolean }): Promise<AusruestungArtikel[]> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.kategorie) params.set('kategorie', filters.kategorie);
|
||||||
|
if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv));
|
||||||
|
const r = await api.get(`/api/ausruestungsanfragen/items?${params.toString()}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
getItem: async (id: number): Promise<AusruestungArtikel> => {
|
||||||
|
const r = await api.get(`/api/ausruestungsanfragen/items/${id}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createItem: async (data: AusruestungArtikelFormData): Promise<AusruestungArtikel> => {
|
||||||
|
const r = await api.post('/api/ausruestungsanfragen/items', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateItem: async (id: number, data: Partial<AusruestungArtikelFormData>): Promise<AusruestungArtikel> => {
|
||||||
|
const r = await api.patch(`/api/ausruestungsanfragen/items/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteItem: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/ausruestungsanfragen/items/${id}`);
|
||||||
|
},
|
||||||
|
getCategories: async (): Promise<string[]> => {
|
||||||
|
const r = await api.get('/api/ausruestungsanfragen/categories');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Requests ──
|
||||||
|
getRequests: async (filters?: { status?: string }): Promise<AusruestungAnfrage[]> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.status) params.set('status', filters.status);
|
||||||
|
const r = await api.get(`/api/ausruestungsanfragen/requests?${params.toString()}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
getMyRequests: async (): Promise<AusruestungAnfrage[]> => {
|
||||||
|
const r = await api.get('/api/ausruestungsanfragen/requests/my');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
getRequest: async (id: number): Promise<AusruestungAnfrageDetailResponse> => {
|
||||||
|
const r = await api.get(`/api/ausruestungsanfragen/requests/${id}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createRequest: async (items: AusruestungAnfrageFormItem[], notizen?: string): Promise<AusruestungAnfrage> => {
|
||||||
|
const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen });
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateRequestStatus: async (id: number, status: string, admin_notizen?: string): Promise<AusruestungAnfrage> => {
|
||||||
|
const r = await api.patch(`/api/ausruestungsanfragen/requests/${id}/status`, { status, admin_notizen });
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteRequest: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/ausruestungsanfragen/requests/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Linking ──
|
||||||
|
linkToOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
|
||||||
|
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/link`, { bestellung_id: bestellungId });
|
||||||
|
},
|
||||||
|
unlinkFromOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/ausruestungsanfragen/requests/${anfrageId}/link/${bestellungId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Overview ──
|
||||||
|
getOverview: async (): Promise<AusruestungOverview> => {
|
||||||
|
const r = await api.get('/api/ausruestungsanfragen/overview');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { api } from './api';
|
|
||||||
import type {
|
|
||||||
ShopArtikel,
|
|
||||||
ShopArtikelFormData,
|
|
||||||
ShopAnfrage,
|
|
||||||
ShopAnfrageDetailResponse,
|
|
||||||
ShopAnfrageFormItem,
|
|
||||||
ShopOverview,
|
|
||||||
} from '../types/shop.types';
|
|
||||||
|
|
||||||
export const shopApi = {
|
|
||||||
// ── Catalog Items ──
|
|
||||||
getItems: async (filters?: { kategorie?: string; aktiv?: boolean }): Promise<ShopArtikel[]> => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters?.kategorie) params.set('kategorie', filters.kategorie);
|
|
||||||
if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv));
|
|
||||||
const r = await api.get(`/api/shop/items?${params.toString()}`);
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
getItem: async (id: number): Promise<ShopArtikel> => {
|
|
||||||
const r = await api.get(`/api/shop/items/${id}`);
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
createItem: async (data: ShopArtikelFormData): Promise<ShopArtikel> => {
|
|
||||||
const r = await api.post('/api/shop/items', data);
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
updateItem: async (id: number, data: Partial<ShopArtikelFormData>): Promise<ShopArtikel> => {
|
|
||||||
const r = await api.patch(`/api/shop/items/${id}`, data);
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
deleteItem: async (id: number): Promise<void> => {
|
|
||||||
await api.delete(`/api/shop/items/${id}`);
|
|
||||||
},
|
|
||||||
getCategories: async (): Promise<string[]> => {
|
|
||||||
const r = await api.get('/api/shop/categories');
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Requests ──
|
|
||||||
getRequests: async (filters?: { status?: string }): Promise<ShopAnfrage[]> => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters?.status) params.set('status', filters.status);
|
|
||||||
const r = await api.get(`/api/shop/requests?${params.toString()}`);
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
getMyRequests: async (): Promise<ShopAnfrage[]> => {
|
|
||||||
const r = await api.get('/api/shop/requests/my');
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
getRequest: async (id: number): Promise<ShopAnfrageDetailResponse> => {
|
|
||||||
const r = await api.get(`/api/shop/requests/${id}`);
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
createRequest: async (items: ShopAnfrageFormItem[], notizen?: string): Promise<ShopAnfrage> => {
|
|
||||||
const r = await api.post('/api/shop/requests', { items, notizen });
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
updateRequestStatus: async (id: number, status: string, admin_notizen?: string): Promise<ShopAnfrage> => {
|
|
||||||
const r = await api.patch(`/api/shop/requests/${id}/status`, { status, admin_notizen });
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
deleteRequest: async (id: number): Promise<void> => {
|
|
||||||
await api.delete(`/api/shop/requests/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Linking ──
|
|
||||||
linkToOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
|
|
||||||
await api.post(`/api/shop/requests/${anfrageId}/link`, { bestellung_id: bestellungId });
|
|
||||||
},
|
|
||||||
unlinkFromOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
|
|
||||||
await api.delete(`/api/shop/requests/${anfrageId}/link/${bestellungId}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Overview ──
|
|
||||||
getOverview: async (): Promise<ShopOverview> => {
|
|
||||||
const r = await api.get('/api/shop/overview');
|
|
||||||
return r.data.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Shop (Internal Ordering) types
|
// Ausrüstungsanfrage (Equipment Request) types
|
||||||
|
|
||||||
// ── Catalog Items ──
|
// ── Catalog Items ──
|
||||||
|
|
||||||
export interface ShopArtikel {
|
export interface AusruestungArtikel {
|
||||||
id: number;
|
id: number;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
@@ -15,7 +15,7 @@ export interface ShopArtikel {
|
|||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopArtikelFormData {
|
export interface AusruestungArtikelFormData {
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
kategorie?: string;
|
kategorie?: string;
|
||||||
@@ -25,9 +25,9 @@ export interface ShopArtikelFormData {
|
|||||||
|
|
||||||
// ── Requests ──
|
// ── Requests ──
|
||||||
|
|
||||||
export type ShopAnfrageStatus = 'offen' | 'genehmigt' | 'abgelehnt' | 'bestellt' | 'erledigt';
|
export type AusruestungAnfrageStatus = 'offen' | 'genehmigt' | 'abgelehnt' | 'bestellt' | 'erledigt';
|
||||||
|
|
||||||
export const SHOP_STATUS_LABELS: Record<ShopAnfrageStatus, string> = {
|
export const AUSRUESTUNG_STATUS_LABELS: Record<AusruestungAnfrageStatus, string> = {
|
||||||
offen: 'Offen',
|
offen: 'Offen',
|
||||||
genehmigt: 'Genehmigt',
|
genehmigt: 'Genehmigt',
|
||||||
abgelehnt: 'Abgelehnt',
|
abgelehnt: 'Abgelehnt',
|
||||||
@@ -35,7 +35,7 @@ export const SHOP_STATUS_LABELS: Record<ShopAnfrageStatus, string> = {
|
|||||||
erledigt: 'Erledigt',
|
erledigt: 'Erledigt',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SHOP_STATUS_COLORS: Record<ShopAnfrageStatus, 'default' | 'info' | 'error' | 'primary' | 'success'> = {
|
export const AUSRUESTUNG_STATUS_COLORS: Record<AusruestungAnfrageStatus, 'default' | 'info' | 'error' | 'primary' | 'success'> = {
|
||||||
offen: 'default',
|
offen: 'default',
|
||||||
genehmigt: 'info',
|
genehmigt: 'info',
|
||||||
abgelehnt: 'error',
|
abgelehnt: 'error',
|
||||||
@@ -43,11 +43,11 @@ export const SHOP_STATUS_COLORS: Record<ShopAnfrageStatus, 'default' | 'info' |
|
|||||||
erledigt: 'success',
|
erledigt: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ShopAnfrage {
|
export interface AusruestungAnfrage {
|
||||||
id: number;
|
id: number;
|
||||||
anfrager_id: string;
|
anfrager_id: string;
|
||||||
anfrager_name?: string;
|
anfrager_name?: string;
|
||||||
status: ShopAnfrageStatus;
|
status: AusruestungAnfrageStatus;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
admin_notizen?: string;
|
admin_notizen?: string;
|
||||||
bearbeitet_von?: string;
|
bearbeitet_von?: string;
|
||||||
@@ -60,7 +60,7 @@ export interface ShopAnfrage {
|
|||||||
items_count?: number;
|
items_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopAnfragePosition {
|
export interface AusruestungAnfragePosition {
|
||||||
id: number;
|
id: number;
|
||||||
anfrage_id: number;
|
anfrage_id: number;
|
||||||
artikel_id?: number;
|
artikel_id?: number;
|
||||||
@@ -70,7 +70,7 @@ export interface ShopAnfragePosition {
|
|||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopAnfrageFormItem {
|
export interface AusruestungAnfrageFormItem {
|
||||||
artikel_id?: number;
|
artikel_id?: number;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
menge: number;
|
menge: number;
|
||||||
@@ -79,22 +79,22 @@ export interface ShopAnfrageFormItem {
|
|||||||
|
|
||||||
// ── API Response Types ──
|
// ── API Response Types ──
|
||||||
|
|
||||||
export interface ShopAnfrageDetailResponse {
|
export interface AusruestungAnfrageDetailResponse {
|
||||||
anfrage: ShopAnfrage;
|
anfrage: AusruestungAnfrage;
|
||||||
positionen: ShopAnfragePosition[];
|
positionen: AusruestungAnfragePosition[];
|
||||||
linked_bestellungen?: { id: number; bezeichnung: string; status: string }[];
|
linked_bestellungen?: { id: number; bezeichnung: string; status: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Overview ──
|
// ── Overview ──
|
||||||
|
|
||||||
export interface ShopOverviewItem {
|
export interface AusruestungOverviewItem {
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
total_menge: number;
|
total_menge: number;
|
||||||
anfrage_count: number;
|
anfrage_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopOverview {
|
export interface AusruestungOverview {
|
||||||
items: ShopOverviewItem[];
|
items: AusruestungOverviewItem[];
|
||||||
pending_count: number;
|
pending_count: number;
|
||||||
approved_count: number;
|
approved_count: number;
|
||||||
total_items: number;
|
total_items: number;
|
||||||
Reference in New Issue
Block a user