new features
This commit is contained in:
@@ -102,6 +102,7 @@ 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 shopRoutes from './routes/shop.routes';
|
||||||
|
import issueRoutes from './routes/issue.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -126,6 +127,7 @@ 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/shop', shopRoutes);
|
||||||
|
app.use('/api/issues', issueRoutes);
|
||||||
|
|
||||||
// Static file serving for uploads (authenticated)
|
// Static file serving for uploads (authenticated)
|
||||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class AuthController {
|
|||||||
// Step 2: Get user info from Authentik
|
// Step 2: Get user info from Authentik
|
||||||
const userInfo = await authentikService.getUserInfo(tokens.access_token);
|
const userInfo = await authentikService.getUserInfo(tokens.access_token);
|
||||||
const groups = userInfo.groups ?? [];
|
const groups = userInfo.groups ?? [];
|
||||||
|
const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_'));
|
||||||
|
|
||||||
// Step 3: Verify ID token if present
|
// Step 3: Verify ID token if present
|
||||||
if (tokens.id_token) {
|
if (tokens.id_token) {
|
||||||
@@ -119,8 +120,8 @@ class AuthController {
|
|||||||
profile_picture_url: userInfo.picture,
|
profile_picture_url: userInfo.picture,
|
||||||
});
|
});
|
||||||
|
|
||||||
await userService.updateGroups(user.id, groups);
|
await userService.updateGroups(user.id, dashboardGroups);
|
||||||
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
|
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
|
||||||
await memberService.ensureProfileExists(user.id);
|
await memberService.ensureProfileExists(user.id);
|
||||||
|
|
||||||
// Audit: first-ever login (user record creation)
|
// Audit: first-ever login (user record creation)
|
||||||
@@ -168,8 +169,8 @@ class AuthController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await userService.updateLastLogin(user.id);
|
await userService.updateLastLogin(user.id);
|
||||||
await userService.updateGroups(user.id, groups);
|
await userService.updateGroups(user.id, dashboardGroups);
|
||||||
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
|
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
|
||||||
await memberService.ensureProfileExists(user.id);
|
await memberService.ensureProfileExists(user.id);
|
||||||
|
|
||||||
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
||||||
|
|||||||
@@ -113,17 +113,29 @@ class BestellungController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createOrder(req: Request, res: Response): Promise<void> {
|
async createOrder(req: Request, res: Response): Promise<void> {
|
||||||
const { bezeichnung } = req.body;
|
const { bezeichnung, lieferant_id, budget, besteller_id } = req.body;
|
||||||
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
|
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
|
||||||
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (lieferant_id != null && (!Number.isInteger(lieferant_id) || lieferant_id <= 0)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Lieferanten-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (budget != null && (typeof budget !== 'number' || budget < 0)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Budget muss eine positive Zahl sein' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (besteller_id != null && besteller_id !== '' && (typeof besteller_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(besteller_id))) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Besteller-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const order = await bestellungService.createOrder(req.body, req.user!.id);
|
const order = await bestellungService.createOrder(req.body, req.user!.id);
|
||||||
res.status(201).json({ success: true, data: order });
|
res.status(201).json({ success: true, data: order });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('BestellungController.createOrder error', { error });
|
logger.error('BestellungController.createOrder error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Bestellung konnte nicht erstellt werden' });
|
res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Bestellung konnte nicht erstellt werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ const CreateWartungslogSchema = z.object({
|
|||||||
(url) => /^https?:\/\//i.test(url),
|
(url) => /^https?:\/\//i.test(url),
|
||||||
'Nur http/https URLs erlaubt'
|
'Nur http/https URLs erlaubt'
|
||||||
).optional(),
|
).optional(),
|
||||||
|
naechste_pruefung_am: isoDate.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateWartungslogSchema = z.object({
|
||||||
|
datum: isoDate.optional(),
|
||||||
|
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']).optional(),
|
||||||
|
beschreibung: z.string().min(1).max(2000).optional(),
|
||||||
|
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).nullable().optional(),
|
||||||
|
kosten: z.number().min(0).nullable().optional(),
|
||||||
|
pruefende_stelle: z.string().max(150).nullable().optional(),
|
||||||
|
naechste_pruefung_am: isoDate.nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
@@ -403,6 +414,43 @@ class EquipmentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateWartung(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id, wartungId } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wId = parseInt(wartungId, 10);
|
||||||
|
if (isNaN(wId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateWartungslogSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = await equipmentService.updateWartungslog(id, wId, parsed.data, getUserId(req));
|
||||||
|
res.status(200).json({ success: true, data: entry });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Wartungseintrag nicht gefunden') {
|
||||||
|
res.status(404).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
|
||||||
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
||||||
const { wartungId } = req.params as Record<string, string>;
|
const { wartungId } = req.params as Record<string, string>;
|
||||||
const id = parseInt(wartungId, 10);
|
const id = parseInt(wartungId, 10);
|
||||||
|
|||||||
176
backend/src/controllers/issue.controller.ts
Normal file
176
backend/src/controllers/issue.controller.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import issueService from '../services/issue.service';
|
||||||
|
import { permissionService } from '../services/permission.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const param = (req: Request, key: string): string => req.params[key] as string;
|
||||||
|
|
||||||
|
class IssueController {
|
||||||
|
async getIssues(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
|
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
||||||
|
const issues = await issueService.getIssues(userId, canViewAll);
|
||||||
|
res.status(200).json({ success: true, data: issues });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.getIssues error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issues konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssue(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const issue = await issueService.getIssueById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
|
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
||||||
|
if (!canViewAll && issue.erstellt_von !== userId) {
|
||||||
|
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: issue });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.getIssue error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssue(req: Request, res: Response): Promise<void> {
|
||||||
|
const { titel } = req.body;
|
||||||
|
if (!titel || typeof titel !== 'string' || titel.trim().length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Titel ist erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const issue = await issueService.createIssue(req.body, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data: issue });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.createIssue error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIssue(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
|
const canManage = permissionService.hasPermission(groups, 'issues:manage');
|
||||||
|
if (!canManage) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const issue = await issueService.updateIssue(id, req.body);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: issue });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.updateIssue error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIssue(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const issue = await issueService.getIssueById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
|
const canManage = permissionService.hasPermission(groups, 'issues:manage');
|
||||||
|
if (!canManage && issue.erstellt_von !== userId) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await issueService.deleteIssue(id);
|
||||||
|
res.status(200).json({ success: true, message: 'Issue gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.deleteIssue error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getComments(req: Request, res: Response): Promise<void> {
|
||||||
|
const issueId = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(issueId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const issue = await issueService.getIssueById(issueId);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
|
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
||||||
|
if (!canViewAll && issue.erstellt_von !== userId) {
|
||||||
|
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const comments = await issueService.getComments(issueId);
|
||||||
|
res.status(200).json({ success: true, data: comments });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.getComments error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kommentare konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addComment(req: Request, res: Response): Promise<void> {
|
||||||
|
const issueId = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(issueId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { inhalt } = req.body;
|
||||||
|
if (!inhalt || typeof inhalt !== 'string' || inhalt.trim().length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kommentar darf nicht leer sein' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const issue = await issueService.getIssueById(issueId);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
|
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
||||||
|
const canManage = permissionService.hasPermission(groups, 'issues:manage');
|
||||||
|
if (!canViewAll && !canManage && issue.erstellt_von !== userId) {
|
||||||
|
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const comment = await issueService.addComment(issueId, userId, inhalt.trim());
|
||||||
|
res.status(201).json({ success: true, data: comment });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.addComment error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new IssueController();
|
||||||
@@ -188,11 +188,14 @@ class ShopController {
|
|||||||
|
|
||||||
// Notify requester on status changes
|
// Notify requester on status changes
|
||||||
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
|
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
|
||||||
|
const orderLabel = existing.bestell_jahr && existing.bestell_nummer
|
||||||
|
? `${existing.bestell_jahr}/${String(existing.bestell_nummer).padStart(3, '0')}`
|
||||||
|
: `#${id}`;
|
||||||
await notificationService.createNotification({
|
await notificationService.createNotification({
|
||||||
user_id: existing.anfrager_id,
|
user_id: existing.anfrager_id,
|
||||||
typ: 'shop_anfrage',
|
typ: 'shop_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 #${id} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
|
nachricht: `Deine Shop-Anfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
|
||||||
schwere: status === 'abgelehnt' ? 'warnung' : 'info',
|
schwere: status === 'abgelehnt' ? 'warnung' : 'info',
|
||||||
link: '/shop',
|
link: '/shop',
|
||||||
quell_id: String(id),
|
quell_id: String(id),
|
||||||
@@ -218,6 +221,20 @@ class ShopController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Overview
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getOverview(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const overview = await shopService.getOverview();
|
||||||
|
res.status(200).json({ success: true, data: overview });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ShopController.getOverview error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Linking
|
// Linking
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ const UpdateStatusSchema = z.object({
|
|||||||
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
|
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']);
|
||||||
|
|
||||||
const CreateWartungslogSchema = z.object({
|
const CreateWartungslogSchema = z.object({
|
||||||
datum: isoDate,
|
datum: isoDate,
|
||||||
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
|
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
|
||||||
@@ -94,6 +96,18 @@ const CreateWartungslogSchema = z.object({
|
|||||||
kraftstoff_liter: z.number().min(0).optional(),
|
kraftstoff_liter: z.number().min(0).optional(),
|
||||||
kosten: z.number().min(0).optional(),
|
kosten: z.number().min(0).optional(),
|
||||||
externe_werkstatt: z.string().max(150).optional(),
|
externe_werkstatt: z.string().max(150).optional(),
|
||||||
|
ergebnis: ErgebnisEnum.optional(),
|
||||||
|
naechste_faelligkeit: isoDate.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateWartungslogSchema = z.object({
|
||||||
|
datum: isoDate,
|
||||||
|
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
|
||||||
|
beschreibung: z.string().min(1).max(2000),
|
||||||
|
km_stand: z.number().int().min(0).optional(),
|
||||||
|
externe_werkstatt: z.string().max(150).optional(),
|
||||||
|
ergebnis: ErgebnisEnum.optional(),
|
||||||
|
naechste_faelligkeit: isoDate.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
@@ -384,6 +398,34 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateWartung(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id, wartungId } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateWartungslogSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = await vehicleService.updateWartungslog(wartungId, id, parsed.data, getUserId(req));
|
||||||
|
res.status(200).json({ success: true, data: entry });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Wartungseintrag nicht gefunden') {
|
||||||
|
res.status(404).json({ success: false, message: 'Wartungseintrag nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
|
||||||
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
||||||
const { wartungId } = req.params as Record<string, string>;
|
const { wartungId } = req.params as Record<string, string>;
|
||||||
const id = parseInt(wartungId, 10);
|
const id = parseInt(wartungId, 10);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration 042: Ensure ganztaegig column exists on fahrzeug_buchungen
|
||||||
|
-- Fixes case where migration 041 was tracked before this column was added.
|
||||||
|
|
||||||
|
ALTER TABLE fahrzeug_buchungen ADD COLUMN IF NOT EXISTS ganztaegig BOOLEAN DEFAULT FALSE;
|
||||||
130
backend/src/database/migrations/043_feature_batch.sql
Normal file
130
backend/src/database/migrations/043_feature_batch.sql
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
-- Migration 043: Feature batch
|
||||||
|
-- 1. Vehicle wartungslog: ergebnis + naechste_faelligkeit
|
||||||
|
-- 2. Shop anfragen: bestell_nummer + bestell_jahr
|
||||||
|
-- 3. Issues + issue_kommentare tables
|
||||||
|
-- 4. New feature group 'issues' + permissions
|
||||||
|
-- 5. New shop permissions (view_overview, order_for_user)
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Vehicle Wartungslog additions
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE fahrzeug_wartungslog
|
||||||
|
ADD COLUMN IF NOT EXISTS ergebnis VARCHAR(30)
|
||||||
|
CHECK (ergebnis IN ('bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden'));
|
||||||
|
|
||||||
|
ALTER TABLE fahrzeug_wartungslog
|
||||||
|
ADD COLUMN IF NOT EXISTS naechste_faelligkeit DATE;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Shop Anfragen: unique order ID per year
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE shop_anfragen
|
||||||
|
ADD COLUMN IF NOT EXISTS bestell_nummer INT;
|
||||||
|
|
||||||
|
ALTER TABLE shop_anfragen
|
||||||
|
ADD COLUMN IF NOT EXISTS bestell_jahr INT DEFAULT EXTRACT(YEAR FROM CURRENT_DATE);
|
||||||
|
|
||||||
|
-- Make bezeichnung optional on shop_anfragen (if it exists)
|
||||||
|
-- shop_anfragen doesn't have bezeichnung — it's on positionen. No change needed.
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Issues tables
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS issues (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
titel VARCHAR(500) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
typ VARCHAR(50) NOT NULL DEFAULT 'bug'
|
||||||
|
CHECK (typ IN ('bug', 'feature', 'sonstiges')),
|
||||||
|
prioritaet VARCHAR(20) NOT NULL DEFAULT 'mittel'
|
||||||
|
CHECK (prioritaet IN ('niedrig', 'mittel', 'hoch')),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'offen'
|
||||||
|
CHECK (status IN ('offen', 'in_bearbeitung', 'erledigt', 'abgelehnt')),
|
||||||
|
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
zugewiesen_an UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_erstellt_von ON issues(erstellt_von);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_typ ON issues(typ);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS issue_kommentare (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
issue_id INT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
||||||
|
autor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
inhalt TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issue_kommentare_issue ON issue_kommentare(issue_id);
|
||||||
|
|
||||||
|
-- Auto-update trigger for issues
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_issues_updated') THEN
|
||||||
|
CREATE TRIGGER trg_issues_updated BEFORE UPDATE ON issues
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Issues feature group + permissions
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
INSERT INTO feature_groups (id, label, sort_order) VALUES
|
||||||
|
('issues', 'Issues', 13)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||||
|
('issues:create', 'issues', 'Erstellen', 'Issues erstellen', 1),
|
||||||
|
('issues:view_own', 'issues', 'Eigene ansehen', 'Eigene Issues einsehen', 2),
|
||||||
|
('issues:view_all', 'issues', 'Alle ansehen', 'Alle Issues einsehen', 3),
|
||||||
|
('issues:manage', 'issues', 'Verwalten', 'Issues bearbeiten, Status ändern, zuweisen', 4)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Seed: all groups get create + view_own; kommando gets all
|
||||||
|
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||||
|
-- Kommando: full access
|
||||||
|
('dashboard_kommando', 'issues:create'),
|
||||||
|
('dashboard_kommando', 'issues:view_own'),
|
||||||
|
('dashboard_kommando', 'issues:view_all'),
|
||||||
|
('dashboard_kommando', 'issues:manage'),
|
||||||
|
-- Fahrmeister
|
||||||
|
('dashboard_fahrmeister', 'issues:create'),
|
||||||
|
('dashboard_fahrmeister', 'issues:view_own'),
|
||||||
|
-- Zeugmeister
|
||||||
|
('dashboard_zeugmeister', 'issues:create'),
|
||||||
|
('dashboard_zeugmeister', 'issues:view_own'),
|
||||||
|
-- Chargen
|
||||||
|
('dashboard_chargen', 'issues:create'),
|
||||||
|
('dashboard_chargen', 'issues:view_own'),
|
||||||
|
-- Moderator
|
||||||
|
('dashboard_moderator', 'issues:create'),
|
||||||
|
('dashboard_moderator', 'issues:view_own'),
|
||||||
|
-- Atemschutz
|
||||||
|
('dashboard_atemschutz', 'issues:create'),
|
||||||
|
('dashboard_atemschutz', 'issues:view_own'),
|
||||||
|
-- Mitglied
|
||||||
|
('dashboard_mitglied', 'issues:create'),
|
||||||
|
('dashboard_mitglied', 'issues:view_own')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. New shop permissions
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||||
|
('shop:view_overview', 'shop', 'Übersicht', 'Aggregierte Übersicht aller Anfragen', 7),
|
||||||
|
('shop:order_for_user', 'shop', 'Für Benutzer bestellen', 'Anfragen im Namen anderer erstellen', 8)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Kommando gets the new permissions
|
||||||
|
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||||
|
('dashboard_kommando', 'shop:view_overview'),
|
||||||
|
('dashboard_kommando', 'shop:order_for_user')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -174,4 +174,15 @@ export interface CreateAusruestungWartungslogData {
|
|||||||
kosten?: number;
|
kosten?: number;
|
||||||
pruefende_stelle?: string;
|
pruefende_stelle?: string;
|
||||||
dokument_url?: string;
|
dokument_url?: string;
|
||||||
|
naechste_pruefung_am?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAusruestungWartungslogData {
|
||||||
|
datum?: string;
|
||||||
|
art?: AusruestungWartungslogArt;
|
||||||
|
beschreibung?: string;
|
||||||
|
ergebnis?: string | null;
|
||||||
|
kosten?: number | null;
|
||||||
|
pruefende_stelle?: string | null;
|
||||||
|
naechste_pruefung_am?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export interface Fahrzeug {
|
|||||||
aktiver_lehrgang?: AktiverLehrgang | null;
|
aktiver_lehrgang?: AktiverLehrgang | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WartungslogErgebnis =
|
||||||
|
| 'bestanden'
|
||||||
|
| 'bestanden_mit_maengeln'
|
||||||
|
| 'nicht_bestanden';
|
||||||
|
|
||||||
export interface FahrzeugWartungslog {
|
export interface FahrzeugWartungslog {
|
||||||
id: string;
|
id: string;
|
||||||
fahrzeug_id: string;
|
fahrzeug_id: string;
|
||||||
@@ -62,6 +67,8 @@ export interface FahrzeugWartungslog {
|
|||||||
kraftstoff_liter: number | null;
|
kraftstoff_liter: number | null;
|
||||||
kosten: number | null;
|
kosten: number | null;
|
||||||
externe_werkstatt: string | null;
|
externe_werkstatt: string | null;
|
||||||
|
ergebnis: WartungslogErgebnis | null;
|
||||||
|
naechste_faelligkeit: Date | null;
|
||||||
erfasst_von: string | null;
|
erfasst_von: string | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
@@ -174,4 +181,16 @@ export interface CreateWartungslogData {
|
|||||||
kraftstoff_liter?: number;
|
kraftstoff_liter?: number;
|
||||||
kosten?: number;
|
kosten?: number;
|
||||||
externe_werkstatt?: string;
|
externe_werkstatt?: string;
|
||||||
|
ergebnis?: WartungslogErgebnis;
|
||||||
|
naechste_faelligkeit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWartungslogData {
|
||||||
|
datum: string;
|
||||||
|
art?: WartungslogArt;
|
||||||
|
beschreibung: string;
|
||||||
|
km_stand?: number;
|
||||||
|
externe_werkstatt?: string;
|
||||||
|
ergebnis?: WartungslogErgebnis;
|
||||||
|
naechste_faelligkeit?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,4 +325,34 @@ router.delete(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /api/admin/debug/user/:userId/profile — delete mitglieder_profile row
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/debug/user/:userId/profile',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('admin:write'),
|
||||||
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.userId;
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM mitglieder_profile WHERE user_id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((result.rowCount ?? 0) === 0) {
|
||||||
|
res.status(404).json({ success: false, message: 'Kein Profil fuer diesen Benutzer gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Admin deleted user profile data', { userId, admin: req.user?.id });
|
||||||
|
res.json({ success: true, message: 'Profildaten geloescht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete user profile', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Loeschen der Profildaten' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ router.post('/', authenticate, requirePermission('ausruestung:create')
|
|||||||
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
|
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
|
||||||
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
|
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
|
||||||
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.addWartung.bind(equipmentController));
|
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.addWartung.bind(equipmentController));
|
||||||
|
router.patch('/:id/wartung/:wartungId', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.updateWartung.bind(equipmentController));
|
||||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:manage_maintenance'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController));
|
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:manage_maintenance'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController));
|
||||||
|
|
||||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
// ── Delete — admin only ──────────────────────────────────────────────────────
|
||||||
|
|||||||
45
backend/src/routes/issue.routes.ts
Normal file
45
backend/src/routes/issue.routes.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import issueController from '../controllers/issue.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
issueController.getIssues.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('issues:create'),
|
||||||
|
issueController.createIssue.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/comments',
|
||||||
|
authenticate,
|
||||||
|
issueController.getComments.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/comments',
|
||||||
|
authenticate,
|
||||||
|
issueController.addComment.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
issueController.updateIssue.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
issueController.deleteIssue.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -17,6 +17,12 @@ router.delete('/items/:id', authenticate, requirePermission('shop:manage_catalog
|
|||||||
|
|
||||||
router.get('/categories', authenticate, requirePermission('shop:view'), shopController.getCategories.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
|
// Requests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehic
|
|||||||
|
|
||||||
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||||
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.addWartung.bind(vehicleController));
|
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.addWartung.bind(vehicleController));
|
||||||
|
router.patch('/:id/wartung/:wartungId', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.updateWartung.bind(vehicleController));
|
||||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:manage_maintenance'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController));
|
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:manage_maintenance'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CreateAusruestungData,
|
CreateAusruestungData,
|
||||||
UpdateAusruestungData,
|
UpdateAusruestungData,
|
||||||
CreateAusruestungWartungslogData,
|
CreateAusruestungWartungslogData,
|
||||||
|
UpdateAusruestungWartungslogData,
|
||||||
AusruestungStatus,
|
AusruestungStatus,
|
||||||
EquipmentStats,
|
EquipmentStats,
|
||||||
VehicleEquipmentWarning,
|
VehicleEquipmentWarning,
|
||||||
@@ -330,6 +331,15 @@ class EquipmentService {
|
|||||||
|
|
||||||
const entry = result.rows[0] as AusruestungWartungslog;
|
const entry = result.rows[0] as AusruestungWartungslog;
|
||||||
logger.info('Equipment wartungslog entry added', { entryId: entry.id, equipmentId, by: createdBy });
|
logger.info('Equipment wartungslog entry added', { entryId: entry.id, equipmentId, by: createdBy });
|
||||||
|
|
||||||
|
// Auto-update next inspection date on the equipment when result is 'bestanden'
|
||||||
|
if (data.ergebnis === 'bestanden' && data.naechste_pruefung_am) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE ausruestung SET naechste_pruefung_am = $1, letzte_pruefung_am = $2 WHERE id = $3`,
|
||||||
|
[data.naechste_pruefung_am, data.datum, equipmentId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('EquipmentService.addWartungslog failed', { error, equipmentId });
|
logger.error('EquipmentService.addWartungslog failed', { error, equipmentId });
|
||||||
@@ -461,6 +471,70 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// WARTUNGSLOG UPDATE
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async updateWartungslog(
|
||||||
|
equipmentId: string,
|
||||||
|
wartungId: number,
|
||||||
|
data: UpdateAusruestungWartungslogData,
|
||||||
|
updatedBy: string
|
||||||
|
): Promise<AusruestungWartungslog> {
|
||||||
|
try {
|
||||||
|
// Verify the wartung entry belongs to this equipment
|
||||||
|
const check = await pool.query(
|
||||||
|
`SELECT id FROM ausruestung_wartungslog WHERE id = $1 AND ausruestung_id = $2`,
|
||||||
|
[wartungId, equipmentId]
|
||||||
|
);
|
||||||
|
if (check.rows.length === 0) {
|
||||||
|
throw new Error('Wartungseintrag nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let p = 1;
|
||||||
|
|
||||||
|
const addField = (col: string, value: unknown) => {
|
||||||
|
fields.push(`${col} = $${p++}`);
|
||||||
|
values.push(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.datum !== undefined) addField('datum', data.datum);
|
||||||
|
if (data.art !== undefined) addField('art', data.art);
|
||||||
|
if (data.beschreibung !== undefined) addField('beschreibung', data.beschreibung);
|
||||||
|
if (data.ergebnis !== undefined) addField('ergebnis', data.ergebnis);
|
||||||
|
if (data.kosten !== undefined) addField('kosten', data.kosten);
|
||||||
|
if (data.pruefende_stelle !== undefined) addField('pruefende_stelle', data.pruefende_stelle);
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
throw new Error('No fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(wartungId);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE ausruestung_wartungslog SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry = result.rows[0] as AusruestungWartungslog;
|
||||||
|
|
||||||
|
// Auto-update next inspection date on the equipment when result is 'bestanden'
|
||||||
|
if (data.ergebnis === 'bestanden' && data.naechste_pruefung_am) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE ausruestung SET naechste_pruefung_am = $1, letzte_pruefung_am = $2 WHERE id = $3`,
|
||||||
|
[data.naechste_pruefung_am, data.datum ?? entry.datum, equipmentId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Equipment wartungslog entry updated', { wartungId, equipmentId, by: updatedBy });
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('EquipmentService.updateWartungslog failed', { error, wartungId, equipmentId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// WARTUNGSLOG FILE UPLOAD
|
// WARTUNGSLOG FILE UPLOAD
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ class EventsService {
|
|||||||
data.alle_gruppen,
|
data.alle_gruppen,
|
||||||
data.max_teilnehmer ?? null,
|
data.max_teilnehmer ?? null,
|
||||||
data.anmeldung_erforderlich,
|
data.anmeldung_erforderlich,
|
||||||
|
data.anmeldung_bis ?? null,
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -376,8 +377,8 @@ class EventsService {
|
|||||||
`INSERT INTO veranstaltungen (
|
`INSERT INTO veranstaltungen (
|
||||||
wiederholung_parent_id, titel, beschreibung, ort, ort_url, kategorie_id,
|
wiederholung_parent_id, titel, beschreibung, ort, ort_url, kategorie_id,
|
||||||
datum_von, datum_bis, ganztaegig, zielgruppen, alle_gruppen,
|
datum_von, datum_bis, ganztaegig, zielgruppen, alle_gruppen,
|
||||||
max_teilnehmer, anmeldung_erforderlich, erstellt_von
|
max_teilnehmer, anmeldung_erforderlich, anmeldung_bis, erstellt_von
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
162
backend/src/services/issue.service.ts
Normal file
162
backend/src/services/issue.service.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import pool from '../config/database';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
async function getIssues(userId: string, canViewAll: boolean) {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT i.*,
|
||||||
|
u1.name AS erstellt_von_name,
|
||||||
|
u2.name AS zugewiesen_an_name
|
||||||
|
FROM issues i
|
||||||
|
LEFT JOIN users u1 ON u1.id = i.erstellt_von
|
||||||
|
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
|
||||||
|
${canViewAll ? '' : 'WHERE i.erstellt_von = $1'}
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
`;
|
||||||
|
const result = canViewAll
|
||||||
|
? await pool.query(query)
|
||||||
|
: await pool.query(query, [userId]);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.getIssues failed', { error });
|
||||||
|
throw new Error('Issues konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIssueById(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT i.*,
|
||||||
|
u1.name AS erstellt_von_name,
|
||||||
|
u2.name AS zugewiesen_an_name
|
||||||
|
FROM issues i
|
||||||
|
LEFT JOIN users u1 ON u1.id = i.erstellt_von
|
||||||
|
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
|
||||||
|
WHERE i.id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.getIssueById failed', { error, id });
|
||||||
|
throw new Error('Issue konnte nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createIssue(
|
||||||
|
data: { titel: string; beschreibung?: string; typ?: string; prioritaet?: string },
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO issues (titel, beschreibung, typ, prioritaet, erstellt_von)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.titel,
|
||||||
|
data.beschreibung || null,
|
||||||
|
data.typ || 'sonstiges',
|
||||||
|
data.prioritaet || 'mittel',
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.createIssue failed', { error });
|
||||||
|
throw new Error('Issue konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateIssue(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
titel?: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
typ?: string;
|
||||||
|
prioritaet?: string;
|
||||||
|
status?: string;
|
||||||
|
zugewiesen_an?: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE issues
|
||||||
|
SET titel = COALESCE($1, titel),
|
||||||
|
beschreibung = COALESCE($2, beschreibung),
|
||||||
|
typ = COALESCE($3, typ),
|
||||||
|
prioritaet = COALESCE($4, prioritaet),
|
||||||
|
status = COALESCE($5, status),
|
||||||
|
zugewiesen_an = COALESCE($6, zugewiesen_an),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $7
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.titel,
|
||||||
|
data.beschreibung,
|
||||||
|
data.typ,
|
||||||
|
data.prioritaet,
|
||||||
|
data.status,
|
||||||
|
data.zugewiesen_an,
|
||||||
|
id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.updateIssue failed', { error, id });
|
||||||
|
throw new Error('Issue konnte nicht aktualisiert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteIssue(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM issues WHERE id = $1 RETURNING id`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.deleteIssue failed', { error, id });
|
||||||
|
throw new Error('Issue konnte nicht gelöscht werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getComments(issueId: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT c.*, u.name AS autor_name
|
||||||
|
FROM issue_kommentare c
|
||||||
|
LEFT JOIN users u ON u.id = c.autor_id
|
||||||
|
WHERE c.issue_id = $1
|
||||||
|
ORDER BY c.created_at ASC`,
|
||||||
|
[issueId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.getComments failed', { error, issueId });
|
||||||
|
throw new Error('Kommentare konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComment(issueId: number, autorId: string, inhalt: string) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO issue_kommentare (issue_id, autor_id, inhalt)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[issueId, autorId, inhalt]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.addComment failed', { error, issueId });
|
||||||
|
throw new Error('Kommentar konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getIssues,
|
||||||
|
getIssueById,
|
||||||
|
createIssue,
|
||||||
|
updateIssue,
|
||||||
|
deleteIssue,
|
||||||
|
getComments,
|
||||||
|
addComment,
|
||||||
|
};
|
||||||
@@ -202,11 +202,21 @@ async function createRequest(
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Get next bestell_nummer for the current year
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const maxResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr
|
||||||
|
FROM shop_anfragen
|
||||||
|
WHERE bestell_jahr = $1`,
|
||||||
|
[currentYear],
|
||||||
|
);
|
||||||
|
const nextNr = maxResult.rows[0].next_nr;
|
||||||
|
|
||||||
const anfrageResult = await client.query(
|
const anfrageResult = await client.query(
|
||||||
`INSERT INTO shop_anfragen (anfrager_id, notizen)
|
`INSERT INTO shop_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[userId, notizen || null],
|
[userId, notizen || null, nextNr, currentYear],
|
||||||
);
|
);
|
||||||
const anfrage = anfrageResult.rows[0];
|
const anfrage = anfrageResult.rows[0];
|
||||||
|
|
||||||
@@ -296,6 +306,42 @@ async function getLinkedOrders(anfrageId: number) {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overview (aggregated)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getOverview() {
|
||||||
|
const aggregated = await pool.query(
|
||||||
|
`SELECT p.bezeichnung,
|
||||||
|
SUM(p.menge)::int AS total_menge,
|
||||||
|
COUNT(DISTINCT p.anfrage_id)::int AS anfrage_count
|
||||||
|
FROM shop_anfrage_positionen p
|
||||||
|
JOIN shop_anfragen a ON a.id = p.anfrage_id
|
||||||
|
WHERE a.status IN ('offen', 'genehmigt')
|
||||||
|
GROUP BY p.bezeichnung
|
||||||
|
ORDER BY total_menge DESC, p.bezeichnung`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const counts = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count,
|
||||||
|
COALESCE(SUM(sub.total), 0)::int AS total_items
|
||||||
|
FROM shop_anfragen a
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT SUM(p.menge) AS total
|
||||||
|
FROM shop_anfrage_positionen p
|
||||||
|
WHERE p.anfrage_id = a.id
|
||||||
|
) sub ON true
|
||||||
|
WHERE a.status IN ('offen', 'genehmigt')`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: aggregated.rows,
|
||||||
|
...counts.rows[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getItems,
|
getItems,
|
||||||
getItemById,
|
getItemById,
|
||||||
@@ -312,4 +358,5 @@ export default {
|
|||||||
linkToOrder,
|
linkToOrder,
|
||||||
unlinkFromOrder,
|
unlinkFromOrder,
|
||||||
getLinkedOrders,
|
getLinkedOrders,
|
||||||
|
getOverview,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CreateFahrzeugData,
|
CreateFahrzeugData,
|
||||||
UpdateFahrzeugData,
|
UpdateFahrzeugData,
|
||||||
CreateWartungslogData,
|
CreateWartungslogData,
|
||||||
|
UpdateWartungslogData,
|
||||||
FahrzeugStatus,
|
FahrzeugStatus,
|
||||||
VehicleStats,
|
VehicleStats,
|
||||||
InspectionAlert,
|
InspectionAlert,
|
||||||
@@ -372,8 +373,9 @@ class VehicleService {
|
|||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO fahrzeug_wartungslog (
|
`INSERT INTO fahrzeug_wartungslog (
|
||||||
fahrzeug_id, datum, art, beschreibung,
|
fahrzeug_id, datum, art, beschreibung,
|
||||||
km_stand, kraftstoff_liter, kosten, externe_werkstatt, erfasst_von
|
km_stand, kraftstoff_liter, kosten, externe_werkstatt,
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
ergebnis, naechste_faelligkeit, erfasst_von
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
fahrzeugId,
|
fahrzeugId,
|
||||||
@@ -384,11 +386,21 @@ class VehicleService {
|
|||||||
data.kraftstoff_liter ?? null,
|
data.kraftstoff_liter ?? null,
|
||||||
data.kosten ?? null,
|
data.kosten ?? null,
|
||||||
data.externe_werkstatt ?? null,
|
data.externe_werkstatt ?? null,
|
||||||
|
data.ergebnis ?? null,
|
||||||
|
data.naechste_faelligkeit ?? null,
|
||||||
createdBy,
|
createdBy,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const entry = result.rows[0] as FahrzeugWartungslog;
|
const entry = result.rows[0] as FahrzeugWartungslog;
|
||||||
|
|
||||||
|
// Auto-update next service date on the vehicle when result is 'bestanden'
|
||||||
|
if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE fahrzeuge SET naechste_wartung_am = $1 WHERE id = $2`,
|
||||||
|
[data.naechste_faelligkeit, fahrzeugId]
|
||||||
|
);
|
||||||
|
}
|
||||||
logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy });
|
logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy });
|
||||||
return entry;
|
return entry;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -397,6 +409,54 @@ class VehicleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateWartungslog(
|
||||||
|
wartungId: string,
|
||||||
|
fahrzeugId: string,
|
||||||
|
data: UpdateWartungslogData,
|
||||||
|
updatedBy: string
|
||||||
|
): Promise<FahrzeugWartungslog> {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE fahrzeug_wartungslog
|
||||||
|
SET datum = $1, art = $2, beschreibung = $3, km_stand = $4,
|
||||||
|
externe_werkstatt = $5, ergebnis = $6, naechste_faelligkeit = $7
|
||||||
|
WHERE id = $8 AND fahrzeug_id = $9
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.datum,
|
||||||
|
data.art ?? null,
|
||||||
|
data.beschreibung,
|
||||||
|
data.km_stand ?? null,
|
||||||
|
data.externe_werkstatt ?? null,
|
||||||
|
data.ergebnis ?? null,
|
||||||
|
data.naechste_faelligkeit ?? null,
|
||||||
|
wartungId,
|
||||||
|
fahrzeugId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Wartungseintrag nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = result.rows[0] as FahrzeugWartungslog;
|
||||||
|
|
||||||
|
// Auto-update next service date on the vehicle when result is 'bestanden'
|
||||||
|
if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE fahrzeuge SET naechste_wartung_am = $1 WHERE id = $2`,
|
||||||
|
[data.naechste_faelligkeit, fahrzeugId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Wartungslog entry updated', { wartungId, fahrzeugId, by: updatedBy });
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VehicleService.updateWartungslog failed', { error, wartungId, fahrzeugId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getWartungslogForVehicle(fahrzeugId: string): Promise<FahrzeugWartungslog[]> {
|
async getWartungslogForVehicle(fahrzeugId: string): Promise<FahrzeugWartungslog[]> {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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 Shop from './pages/Shop';
|
||||||
|
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';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
@@ -243,6 +244,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/issues"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Issues />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
|
|||||||
106
frontend/src/components/admin/DebugTab.tsx
Normal file
106
frontend/src/components/admin/DebugTab.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Paper, Typography, Button, Autocomplete, TextField,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../../services/admin';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import type { UserOverview } from '../../types/admin.types';
|
||||||
|
|
||||||
|
export default function DebugTab() {
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const { data: users = [], isLoading: usersLoading } = useQuery<UserOverview[]>({
|
||||||
|
queryKey: ['admin', 'users'],
|
||||||
|
queryFn: adminApi.getUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserOverview | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUserProfile(selectedUser.id);
|
||||||
|
showSuccess(`Profildaten fuer ${selectedUser.name || selectedUser.email} geloescht`);
|
||||||
|
setSelectedUser(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Fehler beim Loeschen';
|
||||||
|
showError(msg);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
setConfirmOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 600 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>Debug-Werkzeuge</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Werkzeuge fuer Fehlersuche und Datenbereinigung.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
|
||||||
|
Profildaten loeschen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Loescht die synchronisierten Profildaten (mitglieder_profile) eines Benutzers.
|
||||||
|
Beim naechsten Login werden die Daten erneut von Authentik und FDISK synchronisiert.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Autocomplete
|
||||||
|
options={users}
|
||||||
|
loading={usersLoading}
|
||||||
|
value={selectedUser}
|
||||||
|
onChange={(_e, v) => setSelectedUser(v)}
|
||||||
|
getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`}
|
||||||
|
isOptionEqualToValue={(a, b) => a.id === b.id}
|
||||||
|
sx={{ minWidth: 320, flex: 1 }}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Benutzer waehlen" size="small" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={!selectedUser || deleting}
|
||||||
|
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteIcon />}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Profildaten loeschen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Dialog open={confirmOpen} onClose={() => !deleting && setConfirmOpen(false)}>
|
||||||
|
<DialogTitle>Profildaten loeschen?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Profildaten fuer <strong>{selectedUser?.name || selectedUser?.email}</strong> werden geloescht.
|
||||||
|
Beim naechsten Login werden die Daten erneut synchronisiert.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setConfirmOpen(false)} disabled={deleting}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
disabled={deleting}
|
||||||
|
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteIcon />}
|
||||||
|
>
|
||||||
|
{deleting ? 'Wird geloescht...' : 'Loeschen'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ExpandLess,
|
ExpandLess,
|
||||||
LocalShipping,
|
LocalShipping,
|
||||||
Store,
|
Store,
|
||||||
|
BugReport,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -65,6 +66,7 @@ const adminSubItems: SubItem[] = [
|
|||||||
{ text: 'Berechtigungen', path: '/admin?tab=7' },
|
{ text: 'Berechtigungen', path: '/admin?tab=7' },
|
||||||
{ text: 'Bestellungen', path: '/admin?tab=8' },
|
{ text: 'Bestellungen', path: '/admin?tab=8' },
|
||||||
{ text: 'Datenverwaltung', path: '/admin?tab=9' },
|
{ text: 'Datenverwaltung', path: '/admin?tab=9' },
|
||||||
|
{ text: 'Debug', path: '/admin?tab=10' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseNavigationItems: NavigationItem[] = [
|
const baseNavigationItems: NavigationItem[] = [
|
||||||
@@ -124,8 +126,24 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
text: 'Shop',
|
text: 'Shop',
|
||||||
icon: <Store />,
|
icon: <Store />,
|
||||||
path: '/shop',
|
path: '/shop',
|
||||||
|
subItems: [
|
||||||
|
{ text: 'Katalog', path: '/shop?tab=0' },
|
||||||
|
{ text: 'Meine Anfragen', path: '/shop?tab=1' },
|
||||||
|
{ text: 'Alle Anfragen', path: '/shop?tab=2' },
|
||||||
|
{ text: 'Übersicht', path: '/shop?tab=3' },
|
||||||
|
],
|
||||||
permission: 'shop:view',
|
permission: 'shop:view',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Issues',
|
||||||
|
icon: <BugReport />,
|
||||||
|
path: '/issues',
|
||||||
|
subItems: [
|
||||||
|
{ text: 'Meine Issues', path: '/issues?tab=0' },
|
||||||
|
{ text: 'Alle Issues', path: '/issues?tab=1' },
|
||||||
|
],
|
||||||
|
permission: 'issues:create',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminItem: NavigationItem = {
|
const adminItem: NavigationItem = {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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';
|
||||||
|
import DebugTab from '../components/admin/DebugTab';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
@@ -25,7 +26,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 = 10;
|
const ADMIN_TAB_COUNT = 11;
|
||||||
|
|
||||||
function AdminDashboard() {
|
function AdminDashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -61,6 +62,7 @@ function AdminDashboard() {
|
|||||||
<Tab label="Berechtigungen" />
|
<Tab label="Berechtigungen" />
|
||||||
<Tab label="Bestellungen" />
|
<Tab label="Bestellungen" />
|
||||||
<Tab label="Datenverwaltung" />
|
<Tab label="Datenverwaltung" />
|
||||||
|
<Tab label="Debug" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -94,6 +96,9 @@ function AdminDashboard() {
|
|||||||
<TabPanel value={tab} index={9}>
|
<TabPanel value={tab} index={9}>
|
||||||
<DataManagementTab />
|
<DataManagementTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={tab} index={10}>
|
||||||
|
<DebugTab />
|
||||||
|
</TabPanel>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
AusruestungStatusLabel,
|
AusruestungStatusLabel,
|
||||||
UpdateAusruestungStatusPayload,
|
UpdateAusruestungStatusPayload,
|
||||||
CreateAusruestungWartungslogPayload,
|
CreateAusruestungWartungslogPayload,
|
||||||
|
UpdateAusruestungWartungslogPayload,
|
||||||
} from '../types/equipment.types';
|
} from '../types/equipment.types';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
@@ -422,6 +423,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [editingEntry, setEditingEntry] = useState<AusruestungWartungslog | null>(null);
|
||||||
|
|
||||||
const emptyForm: CreateAusruestungWartungslogPayload = {
|
const emptyForm: CreateAusruestungWartungslogPayload = {
|
||||||
datum: '',
|
datum: '',
|
||||||
@@ -430,10 +432,33 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
ergebnis: undefined,
|
ergebnis: undefined,
|
||||||
kosten: undefined,
|
kosten: undefined,
|
||||||
pruefende_stelle: undefined,
|
pruefende_stelle: undefined,
|
||||||
|
naechste_pruefung_am: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateAusruestungWartungslogPayload>(emptyForm);
|
const [form, setForm] = useState<CreateAusruestungWartungslogPayload>(emptyForm);
|
||||||
|
|
||||||
|
const openAddDialog = () => {
|
||||||
|
setEditingEntry(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setSaveError(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (entry: AusruestungWartungslog) => {
|
||||||
|
setEditingEntry(entry);
|
||||||
|
setForm({
|
||||||
|
datum: entry.datum ? new Date(entry.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '',
|
||||||
|
art: entry.art,
|
||||||
|
beschreibung: entry.beschreibung,
|
||||||
|
ergebnis: entry.ergebnis ?? undefined,
|
||||||
|
kosten: entry.kosten ?? undefined,
|
||||||
|
pruefende_stelle: entry.pruefende_stelle ?? undefined,
|
||||||
|
naechste_pruefung_am: undefined,
|
||||||
|
});
|
||||||
|
setSaveError(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!form.datum || !form.art || !form.beschreibung.trim()) {
|
if (!form.datum || !form.art || !form.beschreibung.trim()) {
|
||||||
setSaveError('Datum, Art und Beschreibung sind erforderlich.');
|
setSaveError('Datum, Art und Beschreibung sind erforderlich.');
|
||||||
@@ -442,14 +467,33 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
const datumIso = fromGermanDate(form.datum) || form.datum;
|
||||||
|
const naechstePruefungIso = form.naechste_pruefung_am
|
||||||
|
? (fromGermanDate(form.naechste_pruefung_am) || form.naechste_pruefung_am)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (editingEntry) {
|
||||||
|
const payload: UpdateAusruestungWartungslogPayload = {
|
||||||
|
datum: datumIso,
|
||||||
|
art: form.art,
|
||||||
|
beschreibung: form.beschreibung,
|
||||||
|
ergebnis: form.ergebnis || null,
|
||||||
|
pruefende_stelle: form.pruefende_stelle || null,
|
||||||
|
naechste_pruefung_am: naechstePruefungIso || null,
|
||||||
|
};
|
||||||
|
await equipmentApi.updateWartungslog(equipmentId, editingEntry.id, payload);
|
||||||
|
} else {
|
||||||
await equipmentApi.addWartungslog(equipmentId, {
|
await equipmentApi.addWartungslog(equipmentId, {
|
||||||
...form,
|
...form,
|
||||||
datum: fromGermanDate(form.datum) || form.datum,
|
datum: datumIso,
|
||||||
pruefende_stelle: form.pruefende_stelle || undefined,
|
pruefende_stelle: form.pruefende_stelle || undefined,
|
||||||
ergebnis: form.ergebnis || undefined,
|
ergebnis: form.ergebnis || undefined,
|
||||||
|
naechste_pruefung_am: naechstePruefungIso,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
|
setEditingEntry(null);
|
||||||
onAdded();
|
onAdded();
|
||||||
} catch {
|
} catch {
|
||||||
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
|
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
|
||||||
@@ -463,6 +507,8 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
(a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime()
|
(a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showNaechstePruefung = form.ergebnis === 'bestanden';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
@@ -496,13 +542,19 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2">{entry.beschreibung}</Typography>
|
<Typography variant="body2">{entry.beschreibung}</Typography>
|
||||||
|
{entry.pruefende_stelle && (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||||
{[
|
{entry.pruefende_stelle}
|
||||||
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} EUR`,
|
|
||||||
entry.pruefende_stelle && entry.pruefende_stelle,
|
|
||||||
].filter(Boolean).join(' · ')}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{canWrite && (
|
||||||
|
<Tooltip title="Bearbeiten">
|
||||||
|
<IconButton size="small" onClick={() => openEditDialog(entry)}>
|
||||||
|
<Edit fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -513,14 +565,14 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
<ChatAwareFab
|
<ChatAwareFab
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Wartung eintragen"
|
aria-label="Wartung eintragen"
|
||||||
onClick={() => { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }}
|
onClick={openAddDialog}
|
||||||
>
|
>
|
||||||
<Add />
|
<Add />
|
||||||
</ChatAwareFab>
|
</ChatAwareFab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Wartung / Prüfung eintragen</DialogTitle>
|
<DialogTitle>{editingEntry ? 'Wartungseintrag bearbeiten' : 'Wartung / Prüfung eintragen'}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||||
@@ -585,21 +637,6 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextField
|
|
||||||
label="Kosten (EUR)"
|
|
||||||
type="number"
|
|
||||||
fullWidth
|
|
||||||
value={form.kosten ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
kosten: e.target.value ? Number(e.target.value) : undefined,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
inputProps={{ min: 0, step: 0.01 }}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Prüfende Stelle"
|
label="Prüfende Stelle"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -608,6 +645,19 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
placeholder="Name der prüfenden Stelle oder Person"
|
placeholder="Name der prüfenden Stelle oder Person"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
{showNaechstePruefung && (
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
label="Nächste Prüfung fällig am"
|
||||||
|
fullWidth
|
||||||
|
placeholder="TT.MM.JJJJ"
|
||||||
|
value={form.naechste_pruefung_am ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, naechste_pruefung_am: e.target.value }))}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
helperText="Wird als nächster Prüftermin übernommen"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function Bestellungen() {
|
|||||||
setOrderForm({ ...emptyOrderForm });
|
setOrderForm({ ...emptyOrderForm });
|
||||||
navigate(`/bestellungen/${created.id}`);
|
navigate(`/bestellungen/${created.id}`);
|
||||||
},
|
},
|
||||||
onError: () => showError('Fehler beim Erstellen der Bestellung'),
|
onError: (error: any) => showError(error?.response?.data?.message || 'Fehler beim Erstellen der Bestellung'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createVendor = useMutation({
|
const createVendor = useMutation({
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ import {
|
|||||||
FahrzeugStatus,
|
FahrzeugStatus,
|
||||||
FahrzeugStatusLabel,
|
FahrzeugStatusLabel,
|
||||||
CreateWartungslogPayload,
|
CreateWartungslogPayload,
|
||||||
|
UpdateWartungslogPayload,
|
||||||
UpdateStatusPayload,
|
UpdateStatusPayload,
|
||||||
WartungslogArt,
|
WartungslogArt,
|
||||||
|
WartungslogErgebnis,
|
||||||
|
WartungslogErgebnisLabel,
|
||||||
|
WartungslogErgebnisColor,
|
||||||
OverlappingBooking,
|
OverlappingBooking,
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
import type { AusruestungListItem } from '../types/equipment.types';
|
import type { AusruestungListItem } from '../types/equipment.types';
|
||||||
@@ -470,6 +474,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [editingWartungId, setEditingWartungId] = useState<string | null>(null);
|
||||||
|
|
||||||
const emptyForm: CreateWartungslogPayload = {
|
const emptyForm: CreateWartungslogPayload = {
|
||||||
datum: '',
|
datum: '',
|
||||||
@@ -479,10 +484,36 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
kraftstoff_liter: undefined,
|
kraftstoff_liter: undefined,
|
||||||
kosten: undefined,
|
kosten: undefined,
|
||||||
externe_werkstatt: '',
|
externe_werkstatt: '',
|
||||||
|
ergebnis: undefined,
|
||||||
|
naechste_faelligkeit: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateWartungslogPayload>(emptyForm);
|
const [form, setForm] = useState<CreateWartungslogPayload>(emptyForm);
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setEditingWartungId(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setSaveError(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (entry: FahrzeugWartungslog) => {
|
||||||
|
setEditingWartungId(entry.id);
|
||||||
|
setForm({
|
||||||
|
datum: entry.datum ? new Date(entry.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '',
|
||||||
|
art: entry.art ?? undefined,
|
||||||
|
beschreibung: entry.beschreibung,
|
||||||
|
km_stand: entry.km_stand ?? undefined,
|
||||||
|
kraftstoff_liter: entry.kraftstoff_liter ?? undefined,
|
||||||
|
kosten: entry.kosten ?? undefined,
|
||||||
|
externe_werkstatt: entry.externe_werkstatt ?? '',
|
||||||
|
ergebnis: entry.ergebnis ?? undefined,
|
||||||
|
naechste_faelligkeit: entry.naechste_faelligkeit ? entry.naechste_faelligkeit.slice(0, 10) : '',
|
||||||
|
});
|
||||||
|
setSaveError(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!form.datum || !form.beschreibung.trim()) {
|
if (!form.datum || !form.beschreibung.trim()) {
|
||||||
setSaveError('Datum und Beschreibung sind erforderlich.');
|
setSaveError('Datum und Beschreibung sind erforderlich.');
|
||||||
@@ -491,13 +522,29 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
const isoDate = fromGermanDate(form.datum) || form.datum;
|
||||||
|
if (editingWartungId) {
|
||||||
|
const payload: UpdateWartungslogPayload = {
|
||||||
|
datum: isoDate,
|
||||||
|
art: form.art,
|
||||||
|
beschreibung: form.beschreibung,
|
||||||
|
km_stand: form.km_stand,
|
||||||
|
externe_werkstatt: form.externe_werkstatt || undefined,
|
||||||
|
ergebnis: form.ergebnis,
|
||||||
|
naechste_faelligkeit: form.naechste_faelligkeit || undefined,
|
||||||
|
};
|
||||||
|
await vehiclesApi.updateWartungslog(fahrzeugId, editingWartungId, payload);
|
||||||
|
} else {
|
||||||
await vehiclesApi.addWartungslog(fahrzeugId, {
|
await vehiclesApi.addWartungslog(fahrzeugId, {
|
||||||
...form,
|
...form,
|
||||||
datum: fromGermanDate(form.datum) || form.datum,
|
datum: isoDate,
|
||||||
externe_werkstatt: form.externe_werkstatt || undefined,
|
externe_werkstatt: form.externe_werkstatt || undefined,
|
||||||
|
naechste_faelligkeit: form.naechste_faelligkeit || undefined,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
|
setEditingWartungId(null);
|
||||||
onAdded();
|
onAdded();
|
||||||
} catch {
|
} catch {
|
||||||
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
|
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
|
||||||
@@ -521,14 +568,20 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||||
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
|
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
|
||||||
{entry.art && <Chip label={entry.art} size="small" variant="outlined" />}
|
{entry.art && <Chip label={entry.art} size="small" variant="outlined" />}
|
||||||
|
{entry.ergebnis && (
|
||||||
|
<Chip
|
||||||
|
label={WartungslogErgebnisLabel[entry.ergebnis]}
|
||||||
|
size="small"
|
||||||
|
color={WartungslogErgebnisColor[entry.ergebnis]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2">{entry.beschreibung}</Typography>
|
<Typography variant="body2">{entry.beschreibung}</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||||
{[
|
{[
|
||||||
entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`,
|
entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`,
|
||||||
entry.kraftstoff_liter != null && `${Number(entry.kraftstoff_liter).toFixed(1)} L`,
|
|
||||||
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} €`,
|
|
||||||
entry.externe_werkstatt && entry.externe_werkstatt,
|
entry.externe_werkstatt && entry.externe_werkstatt,
|
||||||
|
entry.naechste_faelligkeit && `Nächste Fälligkeit: ${fmtDate(entry.naechste_faelligkeit)}`,
|
||||||
].filter(Boolean).join(' · ')}
|
].filter(Boolean).join(' · ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
{entry.dokument_url ? (
|
{entry.dokument_url ? (
|
||||||
@@ -568,6 +621,11 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
{canWrite && (
|
||||||
|
<IconButton size="small" onClick={() => openEditDialog(entry)} aria-label="Bearbeiten">
|
||||||
|
<Edit fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -578,14 +636,14 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
<ChatAwareFab
|
<ChatAwareFab
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Wartung eintragen"
|
aria-label="Wartung eintragen"
|
||||||
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
|
onClick={openCreateDialog}
|
||||||
>
|
>
|
||||||
<Add />
|
<Add />
|
||||||
</ChatAwareFab>
|
</ChatAwareFab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Wartung / Service eintragen</DialogTitle>
|
<DialogTitle>{editingWartungId ? 'Wartungseintrag bearbeiten' : 'Wartung / Service eintragen'}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||||
@@ -624,7 +682,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextField
|
<TextField
|
||||||
label="km-Stand"
|
label="km-Stand"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -634,27 +692,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
inputProps={{ min: 0 }}
|
inputProps={{ min: 0 }}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextField
|
|
||||||
label="Kraftstoff (L)"
|
|
||||||
type="number"
|
|
||||||
fullWidth
|
|
||||||
value={form.kraftstoff_liter ?? ''}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, kraftstoff_liter: e.target.value ? Number(e.target.value) : undefined }))}
|
|
||||||
inputProps={{ min: 0, step: 0.1 }}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={4}>
|
|
||||||
<TextField
|
|
||||||
label="Kosten (€)"
|
|
||||||
type="number"
|
|
||||||
fullWidth
|
|
||||||
value={form.kosten ?? ''}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))}
|
|
||||||
inputProps={{ min: 0, step: 0.01 }}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Externe Werkstatt"
|
label="Externe Werkstatt"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -663,6 +701,31 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
placeholder="Name der Werkstatt (wenn extern)"
|
placeholder="Name der Werkstatt (wenn extern)"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Ergebnis</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Ergebnis"
|
||||||
|
value={form.ergebnis ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, ergebnis: (e.target.value || undefined) as WartungslogErgebnis | undefined }))}
|
||||||
|
>
|
||||||
|
<MenuItem value="">— Kein Ergebnis —</MenuItem>
|
||||||
|
<MenuItem value="bestanden">Bestanden</MenuItem>
|
||||||
|
<MenuItem value="bestanden_mit_maengeln">Bestanden mit Mängeln</MenuItem>
|
||||||
|
<MenuItem value="nicht_bestanden">Nicht bestanden</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
label="Nächste Fälligkeit"
|
||||||
|
type="date"
|
||||||
|
fullWidth
|
||||||
|
value={form.naechste_faelligkeit ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, naechste_faelligkeit: e.target.value }))}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
471
frontend/src/pages/Issues.tsx
Normal file
471
frontend/src/pages/Issues.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
|
||||||
|
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
||||||
|
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
||||||
|
InputLabel, Collapse, Divider, CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
|
||||||
|
BugReport, FiberNew, HelpOutline, Send as SendIcon,
|
||||||
|
Circle as CircleIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { issuesApi } from '../services/issues';
|
||||||
|
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types';
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
const formatDate = (iso?: string) =>
|
||||||
|
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<Issue['status'], 'info' | 'warning' | 'success' | 'error'> = {
|
||||||
|
offen: 'info',
|
||||||
|
in_bearbeitung: 'warning',
|
||||||
|
erledigt: 'success',
|
||||||
|
abgelehnt: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<Issue['status'], string> = {
|
||||||
|
offen: 'Offen',
|
||||||
|
in_bearbeitung: 'In Bearbeitung',
|
||||||
|
erledigt: 'Erledigt',
|
||||||
|
abgelehnt: 'Abgelehnt',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYP_ICONS: Record<Issue['typ'], JSX.Element> = {
|
||||||
|
bug: <BugReport fontSize="small" color="error" />,
|
||||||
|
feature: <FiberNew fontSize="small" color="info" />,
|
||||||
|
sonstiges: <HelpOutline fontSize="small" color="action" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYP_LABELS: Record<Issue['typ'], string> = {
|
||||||
|
bug: 'Bug',
|
||||||
|
feature: 'Feature',
|
||||||
|
sonstiges: 'Sonstiges',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIO_COLORS: Record<Issue['prioritaet'], string> = {
|
||||||
|
hoch: '#d32f2f',
|
||||||
|
mittel: '#ed6c02',
|
||||||
|
niedrig: '#9e9e9e',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIO_LABELS: Record<Issue['prioritaet'], string> = {
|
||||||
|
hoch: 'Hoch',
|
||||||
|
mittel: 'Mittel',
|
||||||
|
niedrig: 'Niedrig',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tab Panel ──
|
||||||
|
|
||||||
|
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
|
||||||
|
function TabPanel({ children, value, index }: TabPanelProps) {
|
||||||
|
if (value !== index) return null;
|
||||||
|
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comment Section ──
|
||||||
|
|
||||||
|
function CommentSection({ issueId }: { issueId: number }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showError } = useNotification();
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
|
const { data: comments = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['issues', issueId, 'comments'],
|
||||||
|
queryFn: () => issuesApi.getComments(issueId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMut = useMutation({
|
||||||
|
mutationFn: (inhalt: string) => issuesApi.addComment(issueId, inhalt),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'comments'] });
|
||||||
|
setText('');
|
||||||
|
},
|
||||||
|
onError: () => showError('Kommentar konnte nicht erstellt werden'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Kommentare</Typography>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">Noch keine Kommentare</Typography>
|
||||||
|
) : (
|
||||||
|
comments.map((c: IssueComment) => (
|
||||||
|
<Box key={c.id} sx={{ mb: 1.5, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: 'pre-wrap' }}>{c.inhalt}</Typography>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Kommentar schreiben..."
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && text.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
addMut.mutate(text.trim());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
multiline
|
||||||
|
maxRows={4}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
disabled={!text.trim() || addMut.isPending}
|
||||||
|
onClick={() => addMut.mutate(text.trim())}
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issue Row ──
|
||||||
|
|
||||||
|
function IssueRow({
|
||||||
|
issue,
|
||||||
|
canManage,
|
||||||
|
isOwner,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
canManage: boolean;
|
||||||
|
isOwner: boolean;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||||
|
showSuccess('Issue aktualisiert');
|
||||||
|
},
|
||||||
|
onError: () => showError('Fehler beim Aktualisieren'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
hover
|
||||||
|
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'unset' : undefined } }}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ width: 50 }}>#{issue.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{TYP_ICONS[issue.typ]}
|
||||||
|
<Typography variant="body2">{issue.titel}</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={TYP_LABELS[issue.typ]} size="small" variant="outlined" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<CircleIcon sx={{ fontSize: 10, color: PRIO_COLORS[issue.prioritaet] }} />
|
||||||
|
<Typography variant="body2">{PRIO_LABELS[issue.prioritaet]}</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={STATUS_LABELS[issue.status]}
|
||||||
|
size="small"
|
||||||
|
color={STATUS_COLORS[issue.status]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
|
||||||
|
<TableCell>{formatDate(issue.created_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}>
|
||||||
|
{expanded ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} sx={{ py: 0 }}>
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
{issue.beschreibung && (
|
||||||
|
<Typography variant="body2" sx={{ mb: 2, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{issue.beschreibung}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{issue.zugewiesen_an_name && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Zugewiesen an: {issue.zugewiesen_an_name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={issue.status}
|
||||||
|
label="Status"
|
||||||
|
onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MenuItem value="offen">Offen</MenuItem>
|
||||||
|
<MenuItem value="in_bearbeitung">In Bearbeitung</MenuItem>
|
||||||
|
<MenuItem value="erledigt">Erledigt</MenuItem>
|
||||||
|
<MenuItem value="abgelehnt">Abgelehnt</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||||
|
<InputLabel>Priorität</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={issue.prioritaet}
|
||||||
|
label="Priorität"
|
||||||
|
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value as Issue['prioritaet'] })}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MenuItem value="niedrig">Niedrig</MenuItem>
|
||||||
|
<MenuItem value="mittel">Mittel</MenuItem>
|
||||||
|
<MenuItem value="hoch">Hoch</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(canManage || isOwner) && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(issue.id); }}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<CommentSection issueId={issue.id} />
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issue Table ──
|
||||||
|
|
||||||
|
function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: boolean; userId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => issuesApi.deleteIssue(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||||
|
showSuccess('Issue gelöscht');
|
||||||
|
},
|
||||||
|
onError: () => showError('Fehler beim Löschen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
Keine Issues vorhanden
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Titel</TableCell>
|
||||||
|
<TableCell>Typ</TableCell>
|
||||||
|
<TableCell>Priorität</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Erstellt von</TableCell>
|
||||||
|
<TableCell>Erstellt am</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<IssueRow
|
||||||
|
key={issue.id}
|
||||||
|
issue={issue}
|
||||||
|
canManage={canManage}
|
||||||
|
isOwner={issue.erstellt_von === userId}
|
||||||
|
onDelete={(id) => deleteMut.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Page ──
|
||||||
|
|
||||||
|
export default function Issues() {
|
||||||
|
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 { hasPermission } = usePermissionContext();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canViewAll = hasPermission('issues:view_all');
|
||||||
|
const canManage = hasPermission('issues:manage');
|
||||||
|
const canCreate = hasPermission('issues:create');
|
||||||
|
const userId = user?.id || '';
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', typ: 'bug', prioritaet: 'mittel' });
|
||||||
|
|
||||||
|
const { data: issues = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['issues'],
|
||||||
|
queryFn: () => issuesApi.getIssues(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||||
|
showSuccess('Issue erstellt');
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({ titel: '', typ: 'bug', prioritaet: 'mittel' });
|
||||||
|
},
|
||||||
|
onError: () => showError('Fehler beim Erstellen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTabChange = (_: unknown, newValue: number) => {
|
||||||
|
setSearchParams({ tab: String(newValue) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>Issues</Typography>
|
||||||
|
|
||||||
|
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
|
||||||
|
<Tab label="Meine Issues" />
|
||||||
|
{canViewAll && <Tab label="Alle Issues" />}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<TabPanel value={tab} index={0}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<IssueTable issues={myIssues} canManage={canManage} userId={userId} />
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{canViewAll && (
|
||||||
|
<TabPanel value={tab} index={1}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<IssueTable issues={issues} canManage={canManage} userId={userId} />
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Create Issue Dialog */}
|
||||||
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Neues Issue erstellen</DialogTitle>
|
||||||
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Titel"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={form.titel}
|
||||||
|
onChange={(e) => setForm({ ...form, titel: e.target.value })}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
fullWidth
|
||||||
|
value={form.beschreibung || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Typ</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.typ || 'bug'}
|
||||||
|
label="Typ"
|
||||||
|
onChange={(e) => setForm({ ...form, typ: e.target.value as Issue['typ'] })}
|
||||||
|
>
|
||||||
|
<MenuItem value="bug">Bug</MenuItem>
|
||||||
|
<MenuItem value="feature">Feature</MenuItem>
|
||||||
|
<MenuItem value="sonstiges">Sonstiges</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Priorität</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.prioritaet || 'mittel'}
|
||||||
|
label="Priorität"
|
||||||
|
onChange={(e) => setForm({ ...form, prioritaet: e.target.value as Issue['prioritaet'] })}
|
||||||
|
>
|
||||||
|
<MenuItem value="niedrig">Niedrig</MenuItem>
|
||||||
|
<MenuItem value="mittel">Mittel</MenuItem>
|
||||||
|
<MenuItem value="hoch">Hoch</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!form.titel.trim() || createMut.isPending}
|
||||||
|
onClick={() => createMut.mutate(form)}
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
{canCreate && (
|
||||||
|
<ChatAwareFab
|
||||||
|
color="primary"
|
||||||
|
aria-label="Neues Issue"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</ChatAwareFab>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1339,11 +1339,20 @@ function VeranstaltungFormDialog({
|
|||||||
anmeldung_erforderlich: editingEvent.anmeldung_erforderlich,
|
anmeldung_erforderlich: editingEvent.anmeldung_erforderlich,
|
||||||
anmeldung_bis: null,
|
anmeldung_bis: null,
|
||||||
});
|
});
|
||||||
|
// Populate recurrence fields if parent event has config (read-only display)
|
||||||
|
if (editingEvent.wiederholung) {
|
||||||
|
setWiederholungAktiv(true);
|
||||||
|
setWiederholungTyp(editingEvent.wiederholung.typ);
|
||||||
|
setWiederholungIntervall(editingEvent.wiederholung.intervall ?? 1);
|
||||||
|
setWiederholungBis(editingEvent.wiederholung.bis ?? '');
|
||||||
|
setWiederholungWochentag(editingEvent.wiederholung.wochentag ?? 0);
|
||||||
|
} else {
|
||||||
setWiederholungAktiv(false);
|
setWiederholungAktiv(false);
|
||||||
setWiederholungTyp('wöchentlich');
|
setWiederholungTyp('wöchentlich');
|
||||||
setWiederholungIntervall(1);
|
setWiederholungIntervall(1);
|
||||||
setWiederholungBis('');
|
setWiederholungBis('');
|
||||||
setWiederholungWochentag(0);
|
setWiederholungWochentag(0);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setMinutes(0, 0, 0);
|
now.setMinutes(0, 0, 0);
|
||||||
@@ -1358,6 +1367,29 @@ function VeranstaltungFormDialog({
|
|||||||
}
|
}
|
||||||
}, [open, editingEvent]);
|
}, [open, editingEvent]);
|
||||||
|
|
||||||
|
// Auto-correct: end date should never be before start date
|
||||||
|
useEffect(() => {
|
||||||
|
const von = new Date(form.datum_von);
|
||||||
|
const bis = new Date(form.datum_bis);
|
||||||
|
if (!isNaN(von.getTime()) && !isNaN(bis.getTime()) && bis < von) {
|
||||||
|
// Set datum_bis to datum_von (preserve time offset for non-ganztaegig)
|
||||||
|
if (form.ganztaegig) {
|
||||||
|
handleChange('datum_bis', von.toISOString());
|
||||||
|
} else {
|
||||||
|
const adjusted = new Date(von);
|
||||||
|
adjusted.setHours(adjusted.getHours() + 2);
|
||||||
|
handleChange('datum_bis', adjusted.toISOString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also auto-correct wiederholungBis
|
||||||
|
if (wiederholungBis) {
|
||||||
|
const vonDateOnly = form.datum_von.slice(0, 10);
|
||||||
|
if (wiederholungBis < vonDateOnly) {
|
||||||
|
setWiederholungBis(vonDateOnly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [form.datum_von]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
|
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
|
||||||
if (field === 'kategorie_id' && !editingEvent) {
|
if (field === 'kategorie_id' && !editingEvent) {
|
||||||
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
|
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
|
||||||
@@ -1600,10 +1632,21 @@ function VeranstaltungFormDialog({
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Wiederholung (only for new events) */}
|
{/* Wiederholung */}
|
||||||
{!editingEvent && (
|
{(!editingEvent || (editingEvent && editingEvent.wiederholung)) && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
{editingEvent && editingEvent.wiederholung ? (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||||
|
Wiederholung kann nicht bearbeitet werden
|
||||||
|
</Typography>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked disabled />}
|
||||||
|
label="Wiederkehrende Veranstaltung"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -1613,9 +1656,10 @@ function VeranstaltungFormDialog({
|
|||||||
}
|
}
|
||||||
label="Wiederkehrende Veranstaltung"
|
label="Wiederkehrende Veranstaltung"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{wiederholungAktiv && (
|
{wiederholungAktiv && (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small" disabled={!!editingEvent}>
|
||||||
<InputLabel id="wiederholung-typ-label">Wiederholung</InputLabel>
|
<InputLabel id="wiederholung-typ-label">Wiederholung</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
labelId="wiederholung-typ-label"
|
labelId="wiederholung-typ-label"
|
||||||
@@ -1640,11 +1684,12 @@ function VeranstaltungFormDialog({
|
|||||||
onChange={(e) => setWiederholungIntervall(Math.max(1, Math.min(52, parseInt(e.target.value) || 1)))}
|
onChange={(e) => setWiederholungIntervall(Math.max(1, Math.min(52, parseInt(e.target.value) || 1)))}
|
||||||
inputProps={{ min: 1, max: 52 }}
|
inputProps={{ min: 1, max: 52 }}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
disabled={!!editingEvent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && (
|
{(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && (
|
||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small" disabled={!!editingEvent}>
|
||||||
<InputLabel id="wiederholung-wochentag-label">Wochentag</InputLabel>
|
<InputLabel id="wiederholung-wochentag-label">Wochentag</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
labelId="wiederholung-wochentag-label"
|
labelId="wiederholung-wochentag-label"
|
||||||
@@ -1667,6 +1712,7 @@ function VeranstaltungFormDialog({
|
|||||||
onChange={(e) => setWiederholungBis(e.target.value)}
|
onChange={(e) => setWiederholungBis(e.target.value)}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
disabled={!!editingEvent}
|
||||||
helperText="Letztes Datum für Wiederholungen"
|
helperText="Letztes Datum für Wiederholungen"
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -20,9 +20,18 @@ import { usePermissionContext } from '../contexts/PermissionContext';
|
|||||||
import { shopApi } from '../services/shop';
|
import { shopApi } from '../services/shop';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
import { bestellungApi } from '../services/bestellung';
|
||||||
import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types';
|
import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types';
|
||||||
import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus } from '../types/shop.types';
|
import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus, ShopAnfrage, ShopOverview } from '../types/shop.types';
|
||||||
import type { Bestellung } from '../types/bestellung.types';
|
import type { Bestellung } from '../types/bestellung.types';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatOrderId(r: ShopAnfrage): string {
|
||||||
|
if (r.bestell_jahr && r.bestell_nummer) {
|
||||||
|
return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
return `#${r.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Catalog Tab ────────────────────────────────────────────────────────────
|
// ─── Catalog Tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface DraftItem {
|
interface DraftItem {
|
||||||
@@ -291,7 +300,7 @@ function MeineAnfragenTab() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell width={40} />
|
<TableCell width={40} />
|
||||||
<TableCell>#</TableCell>
|
<TableCell>Anfrage</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>Status</TableCell>
|
||||||
<TableCell>Positionen</TableCell>
|
<TableCell>Positionen</TableCell>
|
||||||
<TableCell>Erstellt am</TableCell>
|
<TableCell>Erstellt am</TableCell>
|
||||||
@@ -303,9 +312,9 @@ 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>{r.id}</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={SHOP_STATUS_LABELS[r.status]} color={SHOP_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
||||||
<TableCell>{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>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -424,7 +433,7 @@ function AlleAnfragenTab() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell width={40} />
|
<TableCell width={40} />
|
||||||
<TableCell>#</TableCell>
|
<TableCell>Anfrage</TableCell>
|
||||||
<TableCell>Anfrager</TableCell>
|
<TableCell>Anfrager</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>Status</TableCell>
|
||||||
<TableCell>Positionen</TableCell>
|
<TableCell>Positionen</TableCell>
|
||||||
@@ -437,10 +446,10 @@ function AlleAnfragenTab() {
|
|||||||
<>
|
<>
|
||||||
<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>{r.id}</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={SHOP_STATUS_LABELS[r.status]} color={SHOP_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
||||||
<TableCell>{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()}>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
@@ -558,6 +567,68 @@ function AlleAnfragenTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Overview Tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UebersichtTab() {
|
||||||
|
const { data: overview, isLoading } = useQuery<ShopOverview>({
|
||||||
|
queryKey: ['shop', 'overview'],
|
||||||
|
queryFn: () => shopApi.getOverview(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Typography color="text.secondary">Lade Übersicht...</Typography>;
|
||||||
|
if (!overview) return <Typography color="text.secondary">Keine Daten verfügbar.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4" fontWeight={700}>{overview.pending_count}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4" fontWeight={700}>{overview.approved_count}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4" fontWeight={700}>{overview.total_items}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Artikel insgesamt</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{overview.items.length === 0 ? (
|
||||||
|
<Typography color="text.secondary">Keine offenen/genehmigten Anfragen vorhanden.</Typography>
|
||||||
|
) : (
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Artikel</TableCell>
|
||||||
|
<TableCell align="right">Gesamtmenge</TableCell>
|
||||||
|
<TableCell align="right">Anfragen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{overview.items.map(item => (
|
||||||
|
<TableRow key={item.bezeichnung}>
|
||||||
|
<TableCell>{item.bezeichnung}</TableCell>
|
||||||
|
<TableCell align="right">{item.total_menge}</TableCell>
|
||||||
|
<TableCell align="right">{item.anfrage_count}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main Page ──────────────────────────────────────────────────────────────
|
// ─── Main Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Shop() {
|
export default function Shop() {
|
||||||
@@ -567,8 +638,9 @@ export default function Shop() {
|
|||||||
const canView = hasPermission('shop:view');
|
const canView = hasPermission('shop:view');
|
||||||
const canCreate = hasPermission('shop:create_request');
|
const canCreate = hasPermission('shop:create_request');
|
||||||
const canApprove = hasPermission('shop:approve_requests');
|
const canApprove = hasPermission('shop:approve_requests');
|
||||||
|
const canViewOverview = hasPermission('shop:view_overview');
|
||||||
|
|
||||||
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0);
|
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewOverview ? 1 : 0);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(() => {
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
const t = Number(searchParams.get('tab'));
|
const t = Number(searchParams.get('tab'));
|
||||||
@@ -584,9 +656,10 @@ export default function Shop() {
|
|||||||
const map: Record<string, number> = { katalog: 0 };
|
const map: Record<string, number> = { katalog: 0 };
|
||||||
let next = 1;
|
let next = 1;
|
||||||
if (canCreate) { map.meine = next; next++; }
|
if (canCreate) { map.meine = next; next++; }
|
||||||
if (canApprove) { map.alle = next; }
|
if (canApprove) { map.alle = next; next++; }
|
||||||
|
if (canViewOverview) { map.uebersicht = next; }
|
||||||
return map;
|
return map;
|
||||||
}, [canCreate, canApprove]);
|
}, [canCreate, canApprove, canViewOverview]);
|
||||||
|
|
||||||
if (!canView) {
|
if (!canView) {
|
||||||
return (
|
return (
|
||||||
@@ -605,12 +678,14 @@ export default function Shop() {
|
|||||||
<Tab label="Katalog" />
|
<Tab label="Katalog" />
|
||||||
{canCreate && <Tab label="Meine Anfragen" />}
|
{canCreate && <Tab label="Meine Anfragen" />}
|
||||||
{canApprove && <Tab label="Alle Anfragen" />}
|
{canApprove && <Tab label="Alle Anfragen" />}
|
||||||
|
{canViewOverview && <Tab label="Übersicht" />}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{activeTab === tabIndex.katalog && <KatalogTab />}
|
{activeTab === tabIndex.katalog && <KatalogTab />}
|
||||||
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
|
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
|
||||||
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
|
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
|
||||||
|
{canViewOverview && activeTab === tabIndex.uebersicht && <UebersichtTab />}
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ export const adminApi = {
|
|||||||
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
|
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
|
||||||
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
|
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
|
||||||
fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
|
fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
|
||||||
|
deleteUserProfile: (userId: string) => api.delete<ApiResponse<{ message: string }>>(`/api/admin/debug/user/${userId}/profile`).then(r => r.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
UpdateAusruestungPayload,
|
UpdateAusruestungPayload,
|
||||||
UpdateAusruestungStatusPayload,
|
UpdateAusruestungStatusPayload,
|
||||||
CreateAusruestungWartungslogPayload,
|
CreateAusruestungWartungslogPayload,
|
||||||
|
UpdateAusruestungWartungslogPayload,
|
||||||
} from '../types/equipment.types';
|
} from '../types/equipment.types';
|
||||||
|
|
||||||
async function unwrap<T>(
|
async function unwrap<T>(
|
||||||
@@ -121,4 +122,19 @@ export const equipmentApi = {
|
|||||||
);
|
);
|
||||||
return response.data.data ?? [];
|
return response.data.data ?? [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateWartungslog(
|
||||||
|
equipmentId: string,
|
||||||
|
wartungId: string,
|
||||||
|
payload: UpdateAusruestungWartungslogPayload
|
||||||
|
): Promise<AusruestungWartungslog> {
|
||||||
|
const response = await api.patch<{ success: boolean; data: AusruestungWartungslog }>(
|
||||||
|
`/api/equipment/${equipmentId}/wartung/${wartungId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
if (response.data?.data === undefined || response.data?.data === null) {
|
||||||
|
throw new Error('Invalid API response');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
32
frontend/src/services/issues.ts
Normal file
32
frontend/src/services/issues.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types';
|
||||||
|
|
||||||
|
export const issuesApi = {
|
||||||
|
getIssues: async (): Promise<Issue[]> => {
|
||||||
|
const r = await api.get('/api/issues');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
getIssue: async (id: number): Promise<Issue> => {
|
||||||
|
const r = await api.get(`/api/issues/${id}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createIssue: async (data: CreateIssuePayload): Promise<Issue> => {
|
||||||
|
const r = await api.post('/api/issues', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateIssue: async (id: number, data: UpdateIssuePayload): Promise<Issue> => {
|
||||||
|
const r = await api.patch(`/api/issues/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteIssue: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/issues/${id}`);
|
||||||
|
},
|
||||||
|
getComments: async (issueId: number): Promise<IssueComment[]> => {
|
||||||
|
const r = await api.get(`/api/issues/${issueId}/comments`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
addComment: async (issueId: number, inhalt: string): Promise<IssueComment> => {
|
||||||
|
const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt });
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ShopAnfrage,
|
ShopAnfrage,
|
||||||
ShopAnfrageDetailResponse,
|
ShopAnfrageDetailResponse,
|
||||||
ShopAnfrageFormItem,
|
ShopAnfrageFormItem,
|
||||||
|
ShopOverview,
|
||||||
} from '../types/shop.types';
|
} from '../types/shop.types';
|
||||||
|
|
||||||
export const shopApi = {
|
export const shopApi = {
|
||||||
@@ -70,4 +71,10 @@ export const shopApi = {
|
|||||||
unlinkFromOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
|
unlinkFromOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
|
||||||
await api.delete(`/api/shop/requests/${anfrageId}/link/${bestellungId}`);
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
UpdateFahrzeugPayload,
|
UpdateFahrzeugPayload,
|
||||||
UpdateStatusPayload,
|
UpdateStatusPayload,
|
||||||
CreateWartungslogPayload,
|
CreateWartungslogPayload,
|
||||||
|
UpdateWartungslogPayload,
|
||||||
StatusUpdateResponse,
|
StatusUpdateResponse,
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
|
|
||||||
@@ -94,6 +95,17 @@ export const vehiclesApi = {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateWartungslog(vehicleId: string, wartungId: string, payload: UpdateWartungslogPayload): Promise<FahrzeugWartungslog> {
|
||||||
|
const response = await api.patch<{ success: boolean; data: FahrzeugWartungslog }>(
|
||||||
|
`/api/vehicles/${vehicleId}/wartung/${wartungId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
if (response.data?.data === undefined || response.data?.data === null) {
|
||||||
|
throw new Error('Invalid API response');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
async exportAlerts(): Promise<Blob> {
|
async exportAlerts(): Promise<Blob> {
|
||||||
const response = await api.get('/api/vehicles/alerts/export', {
|
const response = await api.get('/api/vehicles/alerts/export', {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -135,4 +135,15 @@ export interface CreateAusruestungWartungslogPayload {
|
|||||||
kosten?: number;
|
kosten?: number;
|
||||||
pruefende_stelle?: string;
|
pruefende_stelle?: string;
|
||||||
dokument_url?: string;
|
dokument_url?: string;
|
||||||
|
naechste_pruefung_am?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAusruestungWartungslogPayload {
|
||||||
|
datum?: string;
|
||||||
|
art?: AusruestungWartungslogArt;
|
||||||
|
beschreibung?: string;
|
||||||
|
ergebnis?: string | null;
|
||||||
|
kosten?: number | null;
|
||||||
|
pruefende_stelle?: string | null;
|
||||||
|
naechste_pruefung_am?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
39
frontend/src/types/issue.types.ts
Normal file
39
frontend/src/types/issue.types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export interface Issue {
|
||||||
|
id: number;
|
||||||
|
titel: string;
|
||||||
|
beschreibung: string | null;
|
||||||
|
typ: 'bug' | 'feature' | 'sonstiges';
|
||||||
|
prioritaet: 'niedrig' | 'mittel' | 'hoch';
|
||||||
|
status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
|
||||||
|
erstellt_von: string;
|
||||||
|
erstellt_von_name?: string;
|
||||||
|
zugewiesen_an: string | null;
|
||||||
|
zugewiesen_an_name?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueComment {
|
||||||
|
id: number;
|
||||||
|
issue_id: number;
|
||||||
|
autor_id: string;
|
||||||
|
autor_name?: string;
|
||||||
|
inhalt: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIssuePayload {
|
||||||
|
titel: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
typ?: 'bug' | 'feature' | 'sonstiges';
|
||||||
|
prioritaet?: 'niedrig' | 'mittel' | 'hoch';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateIssuePayload {
|
||||||
|
titel?: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
typ?: 'bug' | 'feature' | 'sonstiges';
|
||||||
|
prioritaet?: 'niedrig' | 'mittel' | 'hoch';
|
||||||
|
status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
|
||||||
|
zugewiesen_an?: string | null;
|
||||||
|
}
|
||||||
@@ -52,8 +52,11 @@ export interface ShopAnfrage {
|
|||||||
admin_notizen?: string;
|
admin_notizen?: string;
|
||||||
bearbeitet_von?: string;
|
bearbeitet_von?: string;
|
||||||
bearbeitet_von_name?: string;
|
bearbeitet_von_name?: string;
|
||||||
|
bestell_nummer?: number;
|
||||||
|
bestell_jahr?: number;
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
|
positionen_count?: number;
|
||||||
items_count?: number;
|
items_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,3 +84,18 @@ export interface ShopAnfrageDetailResponse {
|
|||||||
positionen: ShopAnfragePosition[];
|
positionen: ShopAnfragePosition[];
|
||||||
linked_bestellungen?: { id: number; bezeichnung: string; status: string }[];
|
linked_bestellungen?: { id: number; bezeichnung: string; status: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Overview ──
|
||||||
|
|
||||||
|
export interface ShopOverviewItem {
|
||||||
|
bezeichnung: string;
|
||||||
|
total_menge: number;
|
||||||
|
anfrage_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopOverview {
|
||||||
|
items: ShopOverviewItem[];
|
||||||
|
pending_count: number;
|
||||||
|
approved_count: number;
|
||||||
|
total_items: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,23 @@ export interface FahrzeugListItem {
|
|||||||
aktiver_lehrgang: AktiverLehrgang | null;
|
aktiver_lehrgang: AktiverLehrgang | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WartungslogErgebnis =
|
||||||
|
| 'bestanden'
|
||||||
|
| 'bestanden_mit_maengeln'
|
||||||
|
| 'nicht_bestanden';
|
||||||
|
|
||||||
|
export const WartungslogErgebnisLabel: Record<WartungslogErgebnis, string> = {
|
||||||
|
bestanden: 'Bestanden',
|
||||||
|
bestanden_mit_maengeln: 'Bestanden mit Mängeln',
|
||||||
|
nicht_bestanden: 'Nicht bestanden',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WartungslogErgebnisColor: Record<WartungslogErgebnis, 'success' | 'warning' | 'error'> = {
|
||||||
|
bestanden: 'success',
|
||||||
|
bestanden_mit_maengeln: 'warning',
|
||||||
|
nicht_bestanden: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
export interface FahrzeugWartungslog {
|
export interface FahrzeugWartungslog {
|
||||||
id: string;
|
id: string;
|
||||||
fahrzeug_id: string;
|
fahrzeug_id: string;
|
||||||
@@ -58,6 +75,8 @@ export interface FahrzeugWartungslog {
|
|||||||
kraftstoff_liter: number | null;
|
kraftstoff_liter: number | null;
|
||||||
kosten: number | null;
|
kosten: number | null;
|
||||||
externe_werkstatt: string | null;
|
externe_werkstatt: string | null;
|
||||||
|
ergebnis: WartungslogErgebnis | null;
|
||||||
|
naechste_faelligkeit: string | null;
|
||||||
dokument_url: string | null;
|
dokument_url: string | null;
|
||||||
erfasst_von: string | null;
|
erfasst_von: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -160,4 +179,16 @@ export interface CreateWartungslogPayload {
|
|||||||
kraftstoff_liter?: number;
|
kraftstoff_liter?: number;
|
||||||
kosten?: number;
|
kosten?: number;
|
||||||
externe_werkstatt?: string;
|
externe_werkstatt?: string;
|
||||||
|
ergebnis?: WartungslogErgebnis;
|
||||||
|
naechste_faelligkeit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWartungslogPayload {
|
||||||
|
datum: string;
|
||||||
|
art?: WartungslogArt;
|
||||||
|
beschreibung: string;
|
||||||
|
km_stand?: number;
|
||||||
|
externe_werkstatt?: string;
|
||||||
|
ergebnis?: WartungslogErgebnis;
|
||||||
|
naechste_faelligkeit?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user