new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 16:09:42 +01:00
parent e9a9478aac
commit 8c66492b27
40 changed files with 2016 additions and 117 deletions

View File

@@ -102,6 +102,7 @@ import settingsRoutes from './routes/settings.routes';
import bannerRoutes from './routes/banner.routes';
import permissionRoutes from './routes/permission.routes';
import shopRoutes from './routes/shop.routes';
import issueRoutes from './routes/issue.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -126,6 +127,7 @@ app.use('/api/settings', settingsRoutes);
app.use('/api/banners', bannerRoutes);
app.use('/api/permissions', permissionRoutes);
app.use('/api/shop', shopRoutes);
app.use('/api/issues', issueRoutes);
// Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');

View File

@@ -87,6 +87,7 @@ class AuthController {
// Step 2: Get user info from Authentik
const userInfo = await authentikService.getUserInfo(tokens.access_token);
const groups = userInfo.groups ?? [];
const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_'));
// Step 3: Verify ID token if present
if (tokens.id_token) {
@@ -119,8 +120,8 @@ class AuthController {
profile_picture_url: userInfo.picture,
});
await userService.updateGroups(user.id, groups);
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
await userService.updateGroups(user.id, dashboardGroups);
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
await memberService.ensureProfileExists(user.id);
// Audit: first-ever login (user record creation)
@@ -168,8 +169,8 @@ class AuthController {
});
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
await userService.updateGroups(user.id, dashboardGroups);
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
await memberService.ensureProfileExists(user.id);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);

View File

@@ -113,17 +113,29 @@ class BestellungController {
}
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) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
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 {
const order = await bestellungService.createOrder(req.body, req.user!.id);
res.status(201).json({ success: true, data: order });
} catch (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' });
}
}

View File

@@ -81,6 +81,17 @@ const CreateWartungslogSchema = z.object({
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).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 ────────────────────────────────────────────────────────────────────
@@ -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> {
const { wartungId } = req.params as Record<string, string>;
const id = parseInt(wartungId, 10);

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

View File

@@ -188,11 +188,14 @@ class ShopController {
// Notify requester on status changes
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({
user_id: existing.anfrager_id,
typ: 'shop_anfrage',
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',
link: '/shop',
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
// -------------------------------------------------------------------------

View File

@@ -86,6 +86,8 @@ const UpdateStatusSchema = z.object({
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
);
const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']);
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
@@ -94,6 +96,18 @@ const CreateWartungslogSchema = z.object({
kraftstoff_liter: z.number().min(0).optional(),
kosten: z.number().min(0).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 ────────────────────────────────────────────────────────────────────
@@ -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> {
const { wartungId } = req.params as Record<string, string>;
const id = parseInt(wartungId, 10);

View File

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

View 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;

View File

@@ -174,4 +174,15 @@ export interface CreateAusruestungWartungslogData {
kosten?: number;
pruefende_stelle?: 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;
}

View File

@@ -52,6 +52,11 @@ export interface Fahrzeug {
aktiver_lehrgang?: AktiverLehrgang | null;
}
export type WartungslogErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden';
export interface FahrzeugWartungslog {
id: string;
fahrzeug_id: string;
@@ -62,6 +67,8 @@ export interface FahrzeugWartungslog {
kraftstoff_liter: number | null;
kosten: number | null;
externe_werkstatt: string | null;
ergebnis: WartungslogErgebnis | null;
naechste_faelligkeit: Date | null;
erfasst_von: string | null;
created_at: Date;
}
@@ -174,4 +181,16 @@ export interface CreateWartungslogData {
kraftstoff_liter?: number;
kosten?: number;
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;
}

View File

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

View File

@@ -23,6 +23,7 @@ router.post('/', authenticate, requirePermission('ausruestung:create')
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.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.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));
// ── Delete — admin only ──────────────────────────────────────────────────────

View 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;

View File

@@ -17,6 +17,12 @@ router.delete('/items/:id', authenticate, requirePermission('shop:manage_catalog
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
// ---------------------------------------------------------------------------

View File

@@ -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.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));
export default router;

View File

@@ -9,6 +9,7 @@ import {
CreateAusruestungData,
UpdateAusruestungData,
CreateAusruestungWartungslogData,
UpdateAusruestungWartungslogData,
AusruestungStatus,
EquipmentStats,
VehicleEquipmentWarning,
@@ -330,6 +331,15 @@ class EquipmentService {
const entry = result.rows[0] as AusruestungWartungslog;
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;
} catch (error) {
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
// =========================================================================

View File

@@ -367,6 +367,7 @@ class EventsService {
data.alle_gruppen,
data.max_teilnehmer ?? null,
data.anmeldung_erforderlich,
data.anmeldung_bis ?? null,
userId,
]);
}
@@ -376,8 +377,8 @@ class EventsService {
`INSERT INTO veranstaltungen (
wiederholung_parent_id, titel, beschreibung, ort, ort_url, kategorie_id,
datum_von, datum_bis, ganztaegig, zielgruppen, alle_gruppen,
max_teilnehmer, anmeldung_erforderlich, erstellt_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
max_teilnehmer, anmeldung_erforderlich, anmeldung_bis, erstellt_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
params
);
}

View 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,
};

View File

@@ -202,11 +202,21 @@ async function createRequest(
try {
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(
`INSERT INTO shop_anfragen (anfrager_id, notizen)
VALUES ($1, $2)
`INSERT INTO shop_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[userId, notizen || null],
[userId, notizen || null, nextNr, currentYear],
);
const anfrage = anfrageResult.rows[0];
@@ -296,6 +306,42 @@ async function getLinkedOrders(anfrageId: number) {
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 {
getItems,
getItemById,
@@ -312,4 +358,5 @@ export default {
linkToOrder,
unlinkFromOrder,
getLinkedOrders,
getOverview,
};

View File

@@ -8,6 +8,7 @@ import {
CreateFahrzeugData,
UpdateFahrzeugData,
CreateWartungslogData,
UpdateWartungslogData,
FahrzeugStatus,
VehicleStats,
InspectionAlert,
@@ -372,8 +373,9 @@ class VehicleService {
const result = await pool.query(
`INSERT INTO fahrzeug_wartungslog (
fahrzeug_id, datum, art, beschreibung,
km_stand, kraftstoff_liter, kosten, externe_werkstatt, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
km_stand, kraftstoff_liter, kosten, externe_werkstatt,
ergebnis, naechste_faelligkeit, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
fahrzeugId,
@@ -384,11 +386,21 @@ class VehicleService {
data.kraftstoff_liter ?? null,
data.kosten ?? null,
data.externe_werkstatt ?? null,
data.ergebnis ?? null,
data.naechste_faelligkeit ?? null,
createdBy,
]
);
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 });
return entry;
} 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[]> {
try {
const result = await pool.query(

View File

@@ -29,6 +29,7 @@ import Wissen from './pages/Wissen';
import Bestellungen from './pages/Bestellungen';
import BestellungDetail from './pages/BestellungDetail';
import Shop from './pages/Shop';
import Issues from './pages/Issues';
import AdminDashboard from './pages/AdminDashboard';
import AdminSettings from './pages/AdminSettings';
import NotFound from './pages/NotFound';
@@ -243,6 +244,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/issues"
element={
<ProtectedRoute>
<Issues />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={

View 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>
);
}

View File

@@ -27,6 +27,7 @@ import {
ExpandLess,
LocalShipping,
Store,
BugReport,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
@@ -65,6 +66,7 @@ const adminSubItems: SubItem[] = [
{ text: 'Berechtigungen', path: '/admin?tab=7' },
{ text: 'Bestellungen', path: '/admin?tab=8' },
{ text: 'Datenverwaltung', path: '/admin?tab=9' },
{ text: 'Debug', path: '/admin?tab=10' },
];
const baseNavigationItems: NavigationItem[] = [
@@ -124,8 +126,24 @@ const baseNavigationItems: NavigationItem[] = [
text: 'Shop',
icon: <Store />,
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',
},
{
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 = {

View File

@@ -12,6 +12,7 @@ import FdiskSyncTab from '../components/admin/FdiskSyncTab';
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
import BestellungenTab from '../components/admin/BestellungenTab';
import DataManagementTab from '../components/admin/DataManagementTab';
import DebugTab from '../components/admin/DebugTab';
import { usePermissionContext } from '../contexts/PermissionContext';
interface TabPanelProps {
@@ -25,7 +26,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
const ADMIN_TAB_COUNT = 10;
const ADMIN_TAB_COUNT = 11;
function AdminDashboard() {
const navigate = useNavigate();
@@ -61,6 +62,7 @@ function AdminDashboard() {
<Tab label="Berechtigungen" />
<Tab label="Bestellungen" />
<Tab label="Datenverwaltung" />
<Tab label="Debug" />
</Tabs>
</Box>
@@ -94,6 +96,9 @@ function AdminDashboard() {
<TabPanel value={tab} index={9}>
<DataManagementTab />
</TabPanel>
<TabPanel value={tab} index={10}>
<DebugTab />
</TabPanel>
</DashboardLayout>
);
}

View File

@@ -61,6 +61,7 @@ import {
AusruestungStatusLabel,
UpdateAusruestungStatusPayload,
CreateAusruestungWartungslogPayload,
UpdateAusruestungWartungslogPayload,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
@@ -422,6 +423,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editingEntry, setEditingEntry] = useState<AusruestungWartungslog | null>(null);
const emptyForm: CreateAusruestungWartungslogPayload = {
datum: '',
@@ -430,10 +432,33 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
ergebnis: undefined,
kosten: undefined,
pruefende_stelle: undefined,
naechste_pruefung_am: undefined,
};
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 () => {
if (!form.datum || !form.art || !form.beschreibung.trim()) {
setSaveError('Datum, Art und Beschreibung sind erforderlich.');
@@ -442,14 +467,33 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
try {
setSaving(true);
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, {
...form,
datum: fromGermanDate(form.datum) || form.datum,
datum: datumIso,
pruefende_stelle: form.pruefende_stelle || undefined,
ergebnis: form.ergebnis || undefined,
naechste_pruefung_am: naechstePruefungIso,
});
}
setDialogOpen(false);
setForm(emptyForm);
setEditingEntry(null);
onAdded();
} catch {
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()
);
const showNaechstePruefung = form.ergebnis === 'bestanden';
return (
<Box>
{sorted.length === 0 ? (
@@ -496,13 +542,19 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
)}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
{entry.pruefende_stelle && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{[
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} EUR`,
entry.pruefende_stelle && entry.pruefende_stelle,
].filter(Boolean).join(' · ')}
{entry.pruefende_stelle}
</Typography>
)}
</Box>
{canWrite && (
<Tooltip title="Bearbeiten">
<IconButton size="small" onClick={() => openEditDialog(entry)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
);
})}
@@ -513,14 +565,14 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
<ChatAwareFab
size="small"
aria-label="Wartung eintragen"
onClick={() => { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }}
onClick={openAddDialog}
>
<Add />
</ChatAwareFab>
)}
<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>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Grid container spacing={2} sx={{ mt: 0.5 }}>
@@ -585,21 +637,6 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
</FormControl>
</Grid>
<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
label="Prüfende Stelle"
fullWidth
@@ -608,6 +645,19 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
placeholder="Name der prüfenden Stelle oder Person"
/>
</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>
</DialogContent>
<DialogActions>

View File

@@ -124,7 +124,7 @@ export default function Bestellungen() {
setOrderForm({ ...emptyOrderForm });
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({

View File

@@ -65,8 +65,12 @@ import {
FahrzeugStatus,
FahrzeugStatusLabel,
CreateWartungslogPayload,
UpdateWartungslogPayload,
UpdateStatusPayload,
WartungslogArt,
WartungslogErgebnis,
WartungslogErgebnisLabel,
WartungslogErgebnisColor,
OverlappingBooking,
} from '../types/vehicle.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 [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editingWartungId, setEditingWartungId] = useState<string | null>(null);
const emptyForm: CreateWartungslogPayload = {
datum: '',
@@ -479,10 +484,36 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
kraftstoff_liter: undefined,
kosten: undefined,
externe_werkstatt: '',
ergebnis: undefined,
naechste_faelligkeit: '',
};
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 () => {
if (!form.datum || !form.beschreibung.trim()) {
setSaveError('Datum und Beschreibung sind erforderlich.');
@@ -491,13 +522,29 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
try {
setSaving(true);
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, {
...form,
datum: fromGermanDate(form.datum) || form.datum,
datum: isoDate,
externe_werkstatt: form.externe_werkstatt || undefined,
naechste_faelligkeit: form.naechste_faelligkeit || undefined,
});
}
setDialogOpen(false);
setForm(emptyForm);
setEditingWartungId(null);
onAdded();
} catch {
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 }}>
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
{entry.art && <Chip label={entry.art} size="small" variant="outlined" />}
{entry.ergebnis && (
<Chip
label={WartungslogErgebnisLabel[entry.ergebnis]}
size="small"
color={WartungslogErgebnisColor[entry.ergebnis]}
/>
)}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{[
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.naechste_faelligkeit && `Nächste Fälligkeit: ${fmtDate(entry.naechste_faelligkeit)}`,
].filter(Boolean).join(' · ')}
</Typography>
{entry.dokument_url ? (
@@ -568,6 +621,11 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
</Button>
) : null}
</Box>
{canWrite && (
<IconButton size="small" onClick={() => openEditDialog(entry)} aria-label="Bearbeiten">
<Edit fontSize="small" />
</IconButton>
)}
</Box>
);
})}
@@ -578,14 +636,14 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
<ChatAwareFab
size="small"
aria-label="Wartung eintragen"
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
onClick={openCreateDialog}
>
<Add />
</ChatAwareFab>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Wartung / Service eintragen</DialogTitle>
<DialogTitle>{editingWartungId ? 'Wartungseintrag bearbeiten' : 'Wartung / Service eintragen'}</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<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 }))}
/>
</Grid>
<Grid item xs={12} sm={4}>
<Grid item xs={12} sm={6}>
<TextField
label="km-Stand"
type="number"
@@ -634,27 +692,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
inputProps={{ min: 0 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<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}>
<Grid item xs={12} sm={6}>
<TextField
label="Externe Werkstatt"
fullWidth
@@ -663,6 +701,31 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
placeholder="Name der Werkstatt (wenn extern)"
/>
</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>
</DialogContent>
<DialogActions>

View 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>
);
}

View File

@@ -1339,11 +1339,20 @@ function VeranstaltungFormDialog({
anmeldung_erforderlich: editingEvent.anmeldung_erforderlich,
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);
setWiederholungTyp('wöchentlich');
setWiederholungIntervall(1);
setWiederholungBis('');
setWiederholungWochentag(0);
}
} else {
const now = new Date();
now.setMinutes(0, 0, 0);
@@ -1358,6 +1367,29 @@ function VeranstaltungFormDialog({
}
}, [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) => {
if (field === 'kategorie_id' && !editingEvent) {
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
@@ -1600,10 +1632,21 @@ function VeranstaltungFormDialog({
fullWidth
/>
)}
{/* Wiederholung (only for new events) */}
{!editingEvent && (
{/* Wiederholung */}
{(!editingEvent || (editingEvent && editingEvent.wiederholung)) && (
<>
<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
control={
<Switch
@@ -1613,9 +1656,10 @@ function VeranstaltungFormDialog({
}
label="Wiederkehrende Veranstaltung"
/>
)}
{wiederholungAktiv && (
<Stack spacing={2}>
<FormControl fullWidth size="small">
<FormControl fullWidth size="small" disabled={!!editingEvent}>
<InputLabel id="wiederholung-typ-label">Wiederholung</InputLabel>
<Select
labelId="wiederholung-typ-label"
@@ -1640,11 +1684,12 @@ function VeranstaltungFormDialog({
onChange={(e) => setWiederholungIntervall(Math.max(1, Math.min(52, parseInt(e.target.value) || 1)))}
inputProps={{ min: 1, max: 52 }}
fullWidth
disabled={!!editingEvent}
/>
)}
{(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>
<Select
labelId="wiederholung-wochentag-label"
@@ -1667,6 +1712,7 @@ function VeranstaltungFormDialog({
onChange={(e) => setWiederholungBis(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
disabled={!!editingEvent}
helperText="Letztes Datum für Wiederholungen"
/>
</Stack>

View File

@@ -20,9 +20,18 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { shopApi } from '../services/shop';
import { bestellungApi } from '../services/bestellung';
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';
// ─── 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 ────────────────────────────────────────────────────────────
interface DraftItem {
@@ -291,7 +300,7 @@ function MeineAnfragenTab() {
<TableHead>
<TableRow>
<TableCell width={40} />
<TableCell>#</TableCell>
<TableCell>Anfrage</TableCell>
<TableCell>Status</TableCell>
<TableCell>Positionen</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)}>
<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>{r.items_count ?? '-'}</TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
<TableCell>{r.admin_notizen || '-'}</TableCell>
</TableRow>
@@ -424,7 +433,7 @@ function AlleAnfragenTab() {
<TableHead>
<TableRow>
<TableCell width={40} />
<TableCell>#</TableCell>
<TableCell>Anfrage</TableCell>
<TableCell>Anfrager</TableCell>
<TableCell>Status</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)}>
<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><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 onClick={e => e.stopPropagation()}>
<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 ──────────────────────────────────────────────────────────────
export default function Shop() {
@@ -567,8 +638,9 @@ export default function Shop() {
const canView = hasPermission('shop:view');
const canCreate = hasPermission('shop:create_request');
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 t = Number(searchParams.get('tab'));
@@ -584,9 +656,10 @@ export default function Shop() {
const map: Record<string, number> = { katalog: 0 };
let next = 1;
if (canCreate) { map.meine = next; next++; }
if (canApprove) { map.alle = next; }
if (canApprove) { map.alle = next; next++; }
if (canViewOverview) { map.uebersicht = next; }
return map;
}, [canCreate, canApprove]);
}, [canCreate, canApprove, canViewOverview]);
if (!canView) {
return (
@@ -605,12 +678,14 @@ export default function Shop() {
<Tab label="Katalog" />
{canCreate && <Tab label="Meine Anfragen" />}
{canApprove && <Tab label="Alle Anfragen" />}
{canViewOverview && <Tab label="Übersicht" />}
</Tabs>
</Box>
{activeTab === tabIndex.katalog && <KatalogTab />}
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
{canViewOverview && activeTab === tabIndex.uebersicht && <UebersichtTab />}
</DashboardLayout>
);
}

View File

@@ -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),
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),
deleteUserProfile: (userId: string) => api.delete<ApiResponse<{ message: string }>>(`/api/admin/debug/user/${userId}/profile`).then(r => r.data),
};

View File

@@ -10,6 +10,7 @@ import type {
UpdateAusruestungPayload,
UpdateAusruestungStatusPayload,
CreateAusruestungWartungslogPayload,
UpdateAusruestungWartungslogPayload,
} from '../types/equipment.types';
async function unwrap<T>(
@@ -121,4 +122,19 @@ export const equipmentApi = {
);
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;
},
};

View 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;
},
};

View File

@@ -5,6 +5,7 @@ import type {
ShopAnfrage,
ShopAnfrageDetailResponse,
ShopAnfrageFormItem,
ShopOverview,
} from '../types/shop.types';
export const shopApi = {
@@ -70,4 +71,10 @@ export const shopApi = {
unlinkFromOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
await api.delete(`/api/shop/requests/${anfrageId}/link/${bestellungId}`);
},
// ── Overview ──
getOverview: async (): Promise<ShopOverview> => {
const r = await api.get('/api/shop/overview');
return r.data.data;
},
};

View File

@@ -9,6 +9,7 @@ import type {
UpdateFahrzeugPayload,
UpdateStatusPayload,
CreateWartungslogPayload,
UpdateWartungslogPayload,
StatusUpdateResponse,
} from '../types/vehicle.types';
@@ -94,6 +95,17 @@ export const vehiclesApi = {
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> {
const response = await api.get('/api/vehicles/alerts/export', {
responseType: 'blob',

View File

@@ -135,4 +135,15 @@ export interface CreateAusruestungWartungslogPayload {
kosten?: number;
pruefende_stelle?: 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;
}

View 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;
}

View File

@@ -52,8 +52,11 @@ export interface ShopAnfrage {
admin_notizen?: string;
bearbeitet_von?: string;
bearbeitet_von_name?: string;
bestell_nummer?: number;
bestell_jahr?: number;
erstellt_am: string;
aktualisiert_am: string;
positionen_count?: number;
items_count?: number;
}
@@ -81,3 +84,18 @@ export interface ShopAnfrageDetailResponse {
positionen: ShopAnfragePosition[];
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;
}

View File

@@ -48,6 +48,23 @@ export interface FahrzeugListItem {
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 {
id: string;
fahrzeug_id: string;
@@ -58,6 +75,8 @@ export interface FahrzeugWartungslog {
kraftstoff_liter: number | null;
kosten: number | null;
externe_werkstatt: string | null;
ergebnis: WartungslogErgebnis | null;
naechste_faelligkeit: string | null;
dokument_url: string | null;
erfasst_von: string | null;
created_at: string;
@@ -160,4 +179,16 @@ export interface CreateWartungslogPayload {
kraftstoff_liter?: number;
kosten?: number;
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;
}