add features
This commit is contained in:
@@ -53,11 +53,21 @@ app.get('/health', (_req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
import authRoutes from './routes/auth.routes';
|
import authRoutes from './routes/auth.routes';
|
||||||
import userRoutes from './routes/user.routes';
|
import userRoutes from './routes/user.routes';
|
||||||
|
import memberRoutes from './routes/member.routes';
|
||||||
|
import adminRoutes from './routes/admin.routes';
|
||||||
|
import trainingRoutes from './routes/training.routes';
|
||||||
|
import vehicleRoutes from './routes/vehicle.routes';
|
||||||
|
import incidentRoutes from './routes/incident.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
|
app.use('/api/members', memberRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/training', trainingRoutes);
|
||||||
|
app.use('/api/vehicles', vehicleRoutes);
|
||||||
|
app.use('/api/incidents', incidentRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import tokenService from '../services/token.service';
|
|||||||
import userService from '../services/user.service';
|
import userService from '../services/user.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { AuthRequest } from '../types/auth.types';
|
import { AuthRequest } from '../types/auth.types';
|
||||||
|
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
||||||
|
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
||||||
|
|
||||||
class AuthController {
|
class AuthController {
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +13,9 @@ class AuthController {
|
|||||||
* POST /api/auth/callback
|
* POST /api/auth/callback
|
||||||
*/
|
*/
|
||||||
async handleCallback(req: Request, res: Response): Promise<void> {
|
async handleCallback(req: Request, res: Response): Promise<void> {
|
||||||
|
const ip = extractIp(req);
|
||||||
|
const userAgent = extractUserAgent(req);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { code } = req.body as AuthRequest;
|
const { code } = req.body as AuthRequest;
|
||||||
|
|
||||||
@@ -46,32 +51,75 @@ class AuthController {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
// User doesn't exist, create new user
|
// User doesn't exist, create new user
|
||||||
logger.info('Creating new user from Authentik', {
|
logger.info('Creating new user from Authentik', {
|
||||||
sub: userInfo.sub,
|
sub: userInfo.sub,
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
user = await userService.createUser({
|
user = await userService.createUser({
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
authentik_sub: userInfo.sub,
|
authentik_sub: userInfo.sub,
|
||||||
preferred_username: userInfo.preferred_username,
|
preferred_username: userInfo.preferred_username,
|
||||||
given_name: userInfo.given_name,
|
given_name: userInfo.given_name,
|
||||||
family_name: userInfo.family_name,
|
family_name: userInfo.family_name,
|
||||||
name: userInfo.name,
|
name: userInfo.name,
|
||||||
profile_picture_url: userInfo.picture,
|
profile_picture_url: userInfo.picture,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit: first-ever login (user record creation)
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: user.id,
|
||||||
|
user_email: user.email,
|
||||||
|
action: AuditAction.LOGIN,
|
||||||
|
resource_type: AuditResourceType.USER,
|
||||||
|
resource_id: user.id,
|
||||||
|
old_value: null,
|
||||||
|
new_value: { event: 'first_login', email: user.email },
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: { new_account: true },
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// User exists, update last login
|
// User exists, update last login
|
||||||
logger.info('Existing user logging in', {
|
logger.info('Existing user logging in', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await userService.updateLastLogin(user.id);
|
await userService.updateLastLogin(user.id);
|
||||||
|
|
||||||
|
// Audit: returning user login
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: user.id,
|
||||||
|
user_email: user.email,
|
||||||
|
action: AuditAction.LOGIN,
|
||||||
|
resource_type: AuditResourceType.USER,
|
||||||
|
resource_id: user.id,
|
||||||
|
old_value: null,
|
||||||
|
new_value: null,
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is active
|
// Check if user is active
|
||||||
if (!user.is_active) {
|
if (!user.is_active) {
|
||||||
logger.warn('Inactive user attempted login', { userId: user.id });
|
logger.warn('Inactive user attempted login', { userId: user.id });
|
||||||
|
|
||||||
|
// Audit the denied login attempt
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: user.id,
|
||||||
|
user_email: user.email,
|
||||||
|
action: AuditAction.PERMISSION_DENIED,
|
||||||
|
resource_type: AuditResourceType.USER,
|
||||||
|
resource_id: user.id,
|
||||||
|
old_value: null,
|
||||||
|
new_value: null,
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: { reason: 'account_inactive' },
|
||||||
|
});
|
||||||
|
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'User account is inactive',
|
message: 'User account is inactive',
|
||||||
@@ -81,20 +129,20 @@ class AuthController {
|
|||||||
|
|
||||||
// Step 5: Generate internal JWT token
|
// Step 5: Generate internal JWT token
|
||||||
const accessToken = tokenService.generateToken({
|
const accessToken = tokenService.generateToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
authentikSub: user.authentik_sub,
|
authentikSub: user.authentik_sub,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
const refreshToken = tokenService.generateRefreshToken({
|
const refreshToken = tokenService.generateRefreshToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('User authenticated successfully', {
|
logger.info('User authenticated successfully', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 6: Return tokens and user info
|
// Step 6: Return tokens and user info
|
||||||
@@ -105,20 +153,37 @@ class AuthController {
|
|||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
preferredUsername: user.preferred_username,
|
preferredUsername: user.preferred_username,
|
||||||
givenName: user.given_name,
|
givenName: user.given_name,
|
||||||
familyName: user.family_name,
|
familyName: user.family_name,
|
||||||
profilePictureUrl: user.profile_picture_url,
|
profilePictureUrl: user.profile_picture_url,
|
||||||
isActive: user.is_active,
|
isActive: user.is_active,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('OAuth callback error', { error });
|
logger.error('OAuth callback error', { error });
|
||||||
|
|
||||||
|
// Audit the failed login attempt (user_id unknown at this point)
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: null,
|
||||||
|
user_email: null,
|
||||||
|
action: AuditAction.PERMISSION_DENIED,
|
||||||
|
resource_type: AuditResourceType.SYSTEM,
|
||||||
|
resource_id: null,
|
||||||
|
old_value: null,
|
||||||
|
new_value: null,
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: {
|
||||||
|
reason: 'oauth_callback_error',
|
||||||
|
error: error instanceof Error ? error.message : 'unknown',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Authentication failed';
|
error instanceof Error ? error.message : 'Authentication failed';
|
||||||
|
|
||||||
@@ -134,14 +199,29 @@ class AuthController {
|
|||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
*/
|
*/
|
||||||
async handleLogout(req: Request, res: Response): Promise<void> {
|
async handleLogout(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
const ip = extractIp(req);
|
||||||
// In a stateless JWT setup, logout is handled client-side by removing the token
|
const userAgent = extractUserAgent(req);
|
||||||
// However, we can log the event for audit purposes
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In a stateless JWT setup, logout is handled client-side by removing
|
||||||
|
// the token. We log the event for GDPR accountability.
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
logger.info('User logged out', {
|
logger.info('User logged out', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
email: req.user.email,
|
email: req.user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: req.user.id,
|
||||||
|
user_email: req.user.email,
|
||||||
|
action: AuditAction.LOGOUT,
|
||||||
|
resource_type: AuditResourceType.USER,
|
||||||
|
resource_id: req.user.id,
|
||||||
|
old_value: null,
|
||||||
|
new_value: null,
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,14 +295,14 @@ class AuthController {
|
|||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
const accessToken = tokenService.generateToken({
|
const accessToken = tokenService.generateToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
authentikSub: user.authentik_sub,
|
authentikSub: user.authentik_sub,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Token refreshed successfully', {
|
logger.info('Token refreshed successfully', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|||||||
@@ -3,15 +3,46 @@ import tokenService from '../services/token.service';
|
|||||||
import userService from '../services/user.service';
|
import userService from '../services/user.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { JwtPayload } from '../types/auth.types';
|
import { JwtPayload } from '../types/auth.types';
|
||||||
|
import { auditPermissionDenied } from './audit.middleware';
|
||||||
|
import { AuditResourceType } from '../services/audit.service';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Application roles — extend as needed when Authentik group mapping is added
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type AppRole = 'admin' | 'member' | 'viewer';
|
||||||
|
|
||||||
|
export const Permission = {
|
||||||
|
ADMIN_ACCESS: 'admin:access',
|
||||||
|
MEMBER_WRITE: 'member:write',
|
||||||
|
MEMBER_READ: 'member:read',
|
||||||
|
INCIDENT_WRITE:'incident:write',
|
||||||
|
INCIDENT_READ: 'incident:read',
|
||||||
|
EXPORT: 'export',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Permission = typeof Permission[keyof typeof Permission];
|
||||||
|
|
||||||
|
// Simple permission → required role mapping.
|
||||||
|
// Adjust once Authentik group sync is implemented.
|
||||||
|
const PERMISSION_ROLES: Record<Permission, AppRole[]> = {
|
||||||
|
'admin:access': ['admin'],
|
||||||
|
'member:write': ['admin', 'member'],
|
||||||
|
'member:read': ['admin', 'member', 'viewer'],
|
||||||
|
'incident:write': ['admin', 'member'],
|
||||||
|
'incident:read': ['admin', 'member', 'viewer'],
|
||||||
|
'export': ['admin'],
|
||||||
|
};
|
||||||
|
|
||||||
// Extend Express Request type to include user
|
// Extend Express Request type to include user
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
user?: {
|
user?: {
|
||||||
id: string; // UUID
|
id: string; // UUID
|
||||||
email: string;
|
email: string;
|
||||||
authentikSub: string;
|
authentikSub: string;
|
||||||
|
role?: AppRole; // populated when role is stored in DB / JWT
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +137,60 @@ export const authenticate = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role-based access control middleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* requirePermission — factory that returns Express middleware enforcing a
|
||||||
|
* specific permission. Must be placed after `authenticate` in the chain.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* router.get('/admin/audit-log', authenticate, requirePermission('admin:access'), handler);
|
||||||
|
*
|
||||||
|
* When access is denied, a PERMISSION_DENIED audit entry is written before
|
||||||
|
* the 403 response is sent.
|
||||||
|
*
|
||||||
|
* NOTE: Until Authentik group → role mapping is persisted to the users table
|
||||||
|
* or JWT, this middleware checks req.user.role. Temporary workaround:
|
||||||
|
* hard-code specific admin user IDs via the ADMIN_USER_IDS env variable, OR
|
||||||
|
* add a `role` column to the users table (recommended).
|
||||||
|
*/
|
||||||
|
export const requirePermission = (permission: Permission) => {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Not authenticated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole: AppRole = req.user.role ?? 'viewer';
|
||||||
|
const allowedRoles = PERMISSION_ROLES[permission];
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(userRole)) {
|
||||||
|
logger.warn('Permission denied', {
|
||||||
|
userId: req.user.id,
|
||||||
|
permission,
|
||||||
|
userRole,
|
||||||
|
path: req.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit the denied access — fire-and-forget
|
||||||
|
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
||||||
|
required_permission: permission,
|
||||||
|
user_role: userRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Insufficient permissions',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional authentication middleware
|
* Optional authentication middleware
|
||||||
* Attaches user if token is valid, but doesn't require it
|
* Attaches user if token is valid, but doesn't require it
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import app from './app';
|
|||||||
import environment from './config/environment';
|
import environment from './config/environment';
|
||||||
import logger from './utils/logger';
|
import logger from './utils/logger';
|
||||||
import { testConnection, closePool } from './config/database';
|
import { testConnection, closePool } from './config/database';
|
||||||
|
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
|
||||||
|
|
||||||
const startServer = async (): Promise<void> => {
|
const startServer = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -13,12 +14,15 @@ const startServer = async (): Promise<void> => {
|
|||||||
logger.warn('Database connection failed - server will start but database operations may fail');
|
logger.warn('Database connection failed - server will start but database operations may fail');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the GDPR IP anonymisation job
|
||||||
|
startAuditCleanupJob();
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
const server = app.listen(environment.port, () => {
|
const server = app.listen(environment.port, () => {
|
||||||
logger.info('Server started successfully', {
|
logger.info('Server started successfully', {
|
||||||
port: environment.port,
|
port: environment.port,
|
||||||
environment: environment.nodeEnv,
|
environment: environment.nodeEnv,
|
||||||
database: dbConnected ? 'connected' : 'disconnected',
|
database: dbConnected ? 'connected' : 'disconnected',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,6 +30,9 @@ const startServer = async (): Promise<void> => {
|
|||||||
const gracefulShutdown = async (signal: string) => {
|
const gracefulShutdown = async (signal: string) => {
|
||||||
logger.info(`${signal} received. Starting graceful shutdown...`);
|
logger.info(`${signal} received. Starting graceful shutdown...`);
|
||||||
|
|
||||||
|
// Stop scheduled jobs first
|
||||||
|
stopAuditCleanupJob();
|
||||||
|
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
logger.info('HTTP server closed');
|
logger.info('HTTP server closed');
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"jwt-decode": "^4.0.0"
|
"jwt-decode": "^4.0.0",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"recharts": "^2.12.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ import Dashboard from './pages/Dashboard';
|
|||||||
import Profile from './pages/Profile';
|
import Profile from './pages/Profile';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import Einsaetze from './pages/Einsaetze';
|
import Einsaetze from './pages/Einsaetze';
|
||||||
|
import EinsatzDetail from './pages/EinsatzDetail';
|
||||||
import Fahrzeuge from './pages/Fahrzeuge';
|
import Fahrzeuge from './pages/Fahrzeuge';
|
||||||
|
import FahrzeugDetail from './pages/FahrzeugDetail';
|
||||||
import Ausruestung from './pages/Ausruestung';
|
import Ausruestung from './pages/Ausruestung';
|
||||||
import Mitglieder from './pages/Mitglieder';
|
import Mitglieder from './pages/Mitglieder';
|
||||||
|
import MitgliedDetail from './pages/MitgliedDetail';
|
||||||
|
import Kalender from './pages/Kalender';
|
||||||
|
import UebungDetail from './pages/UebungDetail';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -55,6 +60,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/einsaetze/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<EinsatzDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/fahrzeuge"
|
path="/fahrzeuge"
|
||||||
element={
|
element={
|
||||||
@@ -63,6 +76,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/fahrzeuge/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<FahrzeugDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/ausruestung"
|
path="/ausruestung"
|
||||||
element={
|
element={
|
||||||
@@ -79,6 +100,30 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/mitglieder/:userId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MitgliedDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/kalender"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Kalender />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/training/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<UebungDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DirectionsCar,
|
DirectionsCar,
|
||||||
Build,
|
Build,
|
||||||
People,
|
People,
|
||||||
|
CalendarMonth as CalendarIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -51,6 +52,11 @@ const navigationItems: NavigationItem[] = [
|
|||||||
icon: <People />,
|
icon: <People />,
|
||||||
path: '/mitglieder',
|
path: '/mitglieder',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Dienstkalender',
|
||||||
|
icon: <CalendarIcon />,
|
||||||
|
path: '/kalender',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
People,
|
People,
|
||||||
Warning,
|
Warning,
|
||||||
EventNote,
|
EventNote,
|
||||||
LocalFireDepartment,
|
DirectionsCar,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
@@ -21,17 +21,27 @@ import VikunjaCard from '../components/dashboard/VikunjaCard';
|
|||||||
import BookstackCard from '../components/dashboard/BookstackCard';
|
import BookstackCard from '../components/dashboard/BookstackCard';
|
||||||
import StatsCard from '../components/dashboard/StatsCard';
|
import StatsCard from '../components/dashboard/StatsCard';
|
||||||
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
||||||
|
import InspectionAlerts from '../components/vehicles/InspectionAlerts';
|
||||||
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
|
import type { VehicleStats } from '../types/vehicle.types';
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
|
const [vehicleStats, setVehicleStats] = useState<VehicleStats | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Simulate loading data
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDataLoading(false);
|
setDataLoading(false);
|
||||||
}, 800);
|
}, 800);
|
||||||
|
|
||||||
|
// Fetch live vehicle stats for the KPI strip
|
||||||
|
vehiclesApi.getStats()
|
||||||
|
.then((stats) => setVehicleStats(stats))
|
||||||
|
.catch(() => {
|
||||||
|
// Non-critical — KPI will fall back to placeholder
|
||||||
|
});
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -118,6 +128,8 @@ function Dashboard() {
|
|||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Live vehicle KPI — einsatzbereit count from API */}
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
{dataLoading ? (
|
{dataLoading ? (
|
||||||
<SkeletonCard variant="basic" />
|
<SkeletonCard variant="basic" />
|
||||||
@@ -125,9 +137,13 @@ function Dashboard() {
|
|||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '350ms' }}>
|
<Fade in={true} timeout={600} style={{ transitionDelay: '350ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Fahrzeuge"
|
title="Fahrzeuge einsatzbereit"
|
||||||
value="5"
|
value={
|
||||||
icon={LocalFireDepartment}
|
vehicleStats
|
||||||
|
? `${vehicleStats.einsatzbereit}/${vehicleStats.total}`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
icon={DirectionsCar}
|
||||||
color="success.main"
|
color="success.main"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -135,6 +151,15 @@ function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Inspection Alerts Panel — safety-critical, shown immediately */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '380ms' }}>
|
||||||
|
<Box>
|
||||||
|
<InspectionAlerts daysAhead={30} hideWhenEmpty={true} />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Service Integration Cards */}
|
{/* Service Integration Cards */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||||
|
|||||||
@@ -1,66 +1,503 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
Container,
|
Container,
|
||||||
Typography,
|
Typography,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TablePagination,
|
||||||
|
TextField,
|
||||||
|
Grid,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Box,
|
Skeleton,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { LocalFireDepartment } from '@mui/icons-material';
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
LocalFireDepartment,
|
||||||
|
Build,
|
||||||
|
Warning,
|
||||||
|
CheckCircle,
|
||||||
|
Refresh,
|
||||||
|
FilterList,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import IncidentStatsChart from '../components/incidents/IncidentStatsChart';
|
||||||
|
import {
|
||||||
|
incidentsApi,
|
||||||
|
EinsatzListItem,
|
||||||
|
EinsatzStats,
|
||||||
|
EinsatzArt,
|
||||||
|
EinsatzStatus,
|
||||||
|
EINSATZ_ARTEN,
|
||||||
|
EINSATZ_ART_LABELS,
|
||||||
|
EINSATZ_STATUS_LABELS,
|
||||||
|
} from '../services/incidents';
|
||||||
|
import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// COLOUR MAP for Einsatzart chips
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ART_CHIP_COLOR: Record<
|
||||||
|
EinsatzArt,
|
||||||
|
'error' | 'primary' | 'secondary' | 'warning' | 'success' | 'default' | 'info'
|
||||||
|
> = {
|
||||||
|
Brand: 'error',
|
||||||
|
THL: 'primary',
|
||||||
|
ABC: 'secondary',
|
||||||
|
BMA: 'warning',
|
||||||
|
Hilfeleistung: 'success',
|
||||||
|
Fehlalarm: 'default',
|
||||||
|
Brandsicherheitswache: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CHIP_COLOR: Record<
|
||||||
|
EinsatzStatus,
|
||||||
|
'warning' | 'success' | 'default'
|
||||||
|
> = {
|
||||||
|
aktiv: 'warning',
|
||||||
|
abgeschlossen: 'success',
|
||||||
|
archiviert: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HELPER
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function formatDE(iso: string, fmt = 'dd.MM.yyyy HH:mm'): string {
|
||||||
|
try {
|
||||||
|
return format(parseISO(iso), fmt, { locale: de });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(min: number | null): string {
|
||||||
|
if (min === null || min < 0) return '—';
|
||||||
|
if (min < 60) return `${min} min`;
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return m === 0 ? `${h} h` : `${h} h ${m} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// STATS SUMMARY BAR
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface StatsSummaryProps {
|
||||||
|
stats: EinsatzStats | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsSummaryBar({ stats, loading }: StatsSummaryProps) {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
label: 'Einsätze gesamt (Jahr)',
|
||||||
|
value: stats ? String(stats.gesamt) : '—',
|
||||||
|
icon: <LocalFireDepartment sx={{ color: 'error.main' }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Häufigste Einsatzart',
|
||||||
|
value: stats?.haeufigste_art
|
||||||
|
? EINSATZ_ART_LABELS[stats.haeufigste_art]
|
||||||
|
: '—',
|
||||||
|
icon: <Build sx={{ color: 'primary.main' }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ø Hilfsfrist',
|
||||||
|
value: stats?.avg_hilfsfrist_min !== null && stats?.avg_hilfsfrist_min !== undefined
|
||||||
|
? `${stats.avg_hilfsfrist_min} min`
|
||||||
|
: '—',
|
||||||
|
icon: <Warning sx={{ color: 'warning.main' }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Abgeschlossen',
|
||||||
|
value: stats ? String(stats.abgeschlossen) : '—',
|
||||||
|
icon: <CheckCircle sx={{ color: 'success.main' }} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Grid item xs={6} sm={3} key={i}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||||
|
<Skeleton width="60%" height={18} />
|
||||||
|
<Skeleton width="40%" height={32} sx={{ mt: 0.5 }} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Einsaetze() {
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
<Container maxWidth="lg">
|
{items.map((item) => (
|
||||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
<Grid item xs={6} sm={3} key={item.label}>
|
||||||
Einsatzübersicht
|
<Card>
|
||||||
</Typography>
|
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Card>
|
{item.icon}
|
||||||
<CardContent>
|
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
{item.label}
|
||||||
<LocalFireDepartment color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6">Einsatzverwaltung</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Diese Funktion wird in Kürze verfügbar sein
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
<Typography variant="h5" fontWeight={700}>
|
||||||
<Box sx={{ mt: 3 }}>
|
{item.value}
|
||||||
<Typography variant="body1" color="text.secondary" paragraph>
|
|
||||||
Geplante Features:
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<ul>
|
</CardContent>
|
||||||
<li>
|
</Card>
|
||||||
<Typography variant="body2" color="text.secondary">
|
</Grid>
|
||||||
Einsatzliste mit Filteroptionen
|
))}
|
||||||
</Typography>
|
</Grid>
|
||||||
</li>
|
);
|
||||||
<li>
|
}
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Einsatzberichte erstellen und verwalten
|
// ---------------------------------------------------------------------------
|
||||||
</Typography>
|
// MAIN PAGE
|
||||||
</li>
|
// ---------------------------------------------------------------------------
|
||||||
<li>
|
function Einsaetze() {
|
||||||
<Typography variant="body2" color="text.secondary">
|
const navigate = useNavigate();
|
||||||
Statistiken und Auswertungen
|
|
||||||
</Typography>
|
// List state
|
||||||
</li>
|
const [items, setItems] = useState<EinsatzListItem[]>([]);
|
||||||
<li>
|
const [total, setTotal] = useState(0);
|
||||||
<Typography variant="body2" color="text.secondary">
|
const [page, setPage] = useState(0);
|
||||||
Einsatzdokumentation
|
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||||
</Typography>
|
const [listLoading, setListLoading] = useState(true);
|
||||||
</li>
|
const [listError, setListError] = useState<string | null>(null);
|
||||||
<li>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
// Filters
|
||||||
Alarmstufen und Kategorien
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
</Typography>
|
const [dateTo, setDateTo] = useState('');
|
||||||
</li>
|
const [selectedArts, setSelectedArts] = useState<EinsatzArt[]>([]);
|
||||||
</ul>
|
|
||||||
</Box>
|
// Stats
|
||||||
</CardContent>
|
const [stats, setStats] = useState<EinsatzStats | null>(null);
|
||||||
</Card>
|
const [statsLoading, setStatsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DATA FETCHING
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const fetchList = useCallback(async () => {
|
||||||
|
setListLoading(true);
|
||||||
|
setListError(null);
|
||||||
|
try {
|
||||||
|
const filters: Record<string, unknown> = {
|
||||||
|
limit: rowsPerPage,
|
||||||
|
offset: page * rowsPerPage,
|
||||||
|
};
|
||||||
|
if (dateFrom) filters.dateFrom = new Date(dateFrom).toISOString();
|
||||||
|
if (dateTo) {
|
||||||
|
// Set to end of day for dateTo
|
||||||
|
const end = new Date(dateTo);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
filters.dateTo = end.toISOString();
|
||||||
|
}
|
||||||
|
if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0];
|
||||||
|
|
||||||
|
const result = await incidentsApi.getAll(filters as Parameters<typeof incidentsApi.getAll>[0]);
|
||||||
|
setItems(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch (err) {
|
||||||
|
setListError('Fehler beim Laden der Einsätze. Bitte Seite neu laden.');
|
||||||
|
} finally {
|
||||||
|
setListLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, rowsPerPage, dateFrom, dateTo, selectedArts]);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
setStatsLoading(true);
|
||||||
|
try {
|
||||||
|
const s = await incidentsApi.getStats();
|
||||||
|
setStats(s);
|
||||||
|
} catch {
|
||||||
|
// Stats failure is non-critical
|
||||||
|
} finally {
|
||||||
|
setStatsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchList();
|
||||||
|
}, [fetchList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// HANDLERS
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const handlePageChange = (_: unknown, newPage: number) => setPage(newPage);
|
||||||
|
|
||||||
|
const handleRowsPerPageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setRowsPerPage(parseInt(e.target.value, 10));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleArtFilter = (art: EinsatzArt) => {
|
||||||
|
setSelectedArts((prev) =>
|
||||||
|
prev.includes(art) ? prev.filter((a) => a !== art) : [...prev, art]
|
||||||
|
);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setDateFrom('');
|
||||||
|
setDateTo('');
|
||||||
|
setSelectedArts([]);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (id: string) => {
|
||||||
|
navigate(`/einsaetze/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSuccess = () => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
fetchList();
|
||||||
|
fetchStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// RENDER
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
{/* Page header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||||
|
Einsatzübersicht
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Einsatzprotokoll — Feuerwehr Rems
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Tooltip title="Statistik aktualisieren">
|
||||||
|
<IconButton onClick={fetchStats} size="small">
|
||||||
|
<Refresh />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
Neuer Einsatz
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* KPI summary cards */}
|
||||||
|
<StatsSummaryBar stats={stats} loading={statsLoading} />
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<IncidentStatsChart stats={stats} loading={statsLoading} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Paper sx={{ p: 2, mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<FilterList fontSize="small" color="action" />
|
||||||
|
<Typography variant="subtitle2">Filter</Typography>
|
||||||
|
{(dateFrom || dateTo || selectedArts.length > 0) && (
|
||||||
|
<Button size="small" onClick={handleResetFilters} sx={{ ml: 'auto' }}>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2} alignItems="center">
|
||||||
|
<Grid item xs={12} sm={4} md={3}>
|
||||||
|
<TextField
|
||||||
|
label="Von (Alarmzeit)"
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => { setDateFrom(e.target.value); setPage(0); }}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ 'aria-label': 'Von-Datum' }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4} md={3}>
|
||||||
|
<TextField
|
||||||
|
label="Bis (Alarmzeit)"
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => { setDateTo(e.target.value); setPage(0); }}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ 'aria-label': 'Bis-Datum' }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Einsatzart chips */}
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5 }}>
|
||||||
|
{EINSATZ_ARTEN.map((art) => (
|
||||||
|
<Chip
|
||||||
|
key={art}
|
||||||
|
label={EINSATZ_ART_LABELS[art]}
|
||||||
|
color={selectedArts.includes(art) ? ART_CHIP_COLOR[art] : 'default'}
|
||||||
|
variant={selectedArts.includes(art) ? 'filled' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => toggleArtFilter(art)}
|
||||||
|
clickable
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{listError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setListError(null)}>
|
||||||
|
{listError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Incident table */}
|
||||||
|
<Paper>
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small" aria-label="Einsatzliste">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ fontWeight: 700, whiteSpace: 'nowrap' }}>Datum / Uhrzeit</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Nr.</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Einsatzart</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Stichwort</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Ort</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Hilfsfrist</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Dauer</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Status</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Einsatzleiter</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Kräfte</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{listLoading
|
||||||
|
? Array.from({ length: rowsPerPage > 10 ? 10 : rowsPerPage }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{Array.from({ length: 10 }).map((__, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton variant="text" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
: items.length === 0
|
||||||
|
? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} align="center" sx={{ py: 6 }}>
|
||||||
|
<Box sx={{ color: 'text.disabled', fontSize: 48, mb: 1 }}>
|
||||||
|
<LocalFireDepartment fontSize="inherit" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Keine Einsätze gefunden
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
: items.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
hover
|
||||||
|
onClick={() => handleRowClick(row.id)}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap', fontSize: '0.8125rem' }}>
|
||||||
|
{formatDE(row.alarm_time)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{row.einsatz_nr}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={row.einsatz_art}
|
||||||
|
color={ART_CHIP_COLOR[row.einsatz_art]}
|
||||||
|
size="small"
|
||||||
|
sx={{ fontSize: '0.7rem' }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{row.einsatz_stichwort ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{[row.strasse, row.ort].filter(Boolean).join(', ') || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>
|
||||||
|
{durationLabel(row.hilfsfrist_min)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>
|
||||||
|
{durationLabel(row.dauer_min)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={EINSATZ_STATUS_LABELS[row.status]}
|
||||||
|
color={STATUS_CHIP_COLOR[row.status]}
|
||||||
|
size="small"
|
||||||
|
sx={{ fontSize: '0.7rem' }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{row.einsatzleiter_name ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{row.personal_count > 0 ? row.personal_count : '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={total}
|
||||||
|
page={page}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
onRowsPerPageChange={handleRowsPerPageChange}
|
||||||
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
|
labelRowsPerPage="Einträge pro Seite:"
|
||||||
|
labelDisplayedRows={({ from, to, count }) =>
|
||||||
|
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<CreateEinsatzDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,66 +1,360 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
|
||||||
Typography,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Box,
|
Box,
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Fab,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { DirectionsCar } from '@mui/icons-material';
|
import {
|
||||||
|
Add,
|
||||||
|
CheckCircle,
|
||||||
|
DirectionsCar,
|
||||||
|
Error as ErrorIcon,
|
||||||
|
PauseCircle,
|
||||||
|
School,
|
||||||
|
Search,
|
||||||
|
Warning,
|
||||||
|
ReportProblem,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
|
import {
|
||||||
|
FahrzeugListItem,
|
||||||
|
FahrzeugStatus,
|
||||||
|
FahrzeugStatusLabel,
|
||||||
|
PruefungArt,
|
||||||
|
PruefungArtLabel,
|
||||||
|
} from '../types/vehicle.types';
|
||||||
|
|
||||||
|
// ── Status chip config ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
FahrzeugStatus,
|
||||||
|
{ color: 'success' | 'warning' | 'error' | 'info'; icon: React.ReactElement }
|
||||||
|
> = {
|
||||||
|
[FahrzeugStatus.Einsatzbereit]: { color: 'success', icon: <CheckCircle fontSize="small" /> },
|
||||||
|
[FahrzeugStatus.AusserDienstWartung]: { color: 'warning', icon: <PauseCircle fontSize="small" /> },
|
||||||
|
[FahrzeugStatus.AusserDienstSchaden]: { color: 'error', icon: <ErrorIcon fontSize="small" /> },
|
||||||
|
[FahrzeugStatus.InLehrgang]: { color: 'info', icon: <School fontSize="small" /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Inspection badge helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type InspBadgeColor = 'success' | 'warning' | 'error' | 'default';
|
||||||
|
|
||||||
|
function inspBadgeColor(tage: number | null): InspBadgeColor {
|
||||||
|
if (tage === null) return 'default';
|
||||||
|
if (tage < 0) return 'error';
|
||||||
|
if (tage <= 30) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string {
|
||||||
|
const artShort = art; // 'HU', 'AU', etc.
|
||||||
|
if (faelligAm === null) return '';
|
||||||
|
const date = new Date(faelligAm).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||||
|
if (tage === null) return `${artShort}: ${date}`;
|
||||||
|
if (tage < 0) return `${artShort}: ÜBERFÄLLIG (${date})`;
|
||||||
|
if (tage === 0) return `${artShort}: heute (${date})`;
|
||||||
|
return `${artShort}: ${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vehicle Card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface VehicleCardProps {
|
||||||
|
vehicle: FahrzeugListItem;
|
||||||
|
onClick: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||||
|
const status = vehicle.status as FahrzeugStatus;
|
||||||
|
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
|
||||||
|
|
||||||
|
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
|
||||||
|
|
||||||
|
// Collect inspection badges (only for types where a faellig_am exists)
|
||||||
|
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [
|
||||||
|
{ art: 'HU', tage: vehicle.hu_tage_bis_faelligkeit, faelligAm: vehicle.hu_faellig_am },
|
||||||
|
{ art: 'AU', tage: vehicle.au_tage_bis_faelligkeit, faelligAm: vehicle.au_faellig_am },
|
||||||
|
{ art: 'UVV', tage: vehicle.uvv_tage_bis_faelligkeit, faelligAm: vehicle.uvv_faellig_am },
|
||||||
|
{ art: 'Leiter', tage: vehicle.leiter_tage_bis_faelligkeit, faelligAm: vehicle.leiter_faellig_am },
|
||||||
|
].filter((b) => b.faelligAm !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
border: isSchaden ? '2px solid' : undefined,
|
||||||
|
borderColor: isSchaden ? 'error.main' : undefined,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSchaden && (
|
||||||
|
<Tooltip title="Fahrzeug außer Dienst (Schaden) — nicht einsatzbereit!">
|
||||||
|
<ReportProblem
|
||||||
|
color="error"
|
||||||
|
sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardActionArea
|
||||||
|
onClick={() => onClick(vehicle.id)}
|
||||||
|
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||||
|
>
|
||||||
|
{/* Vehicle image / placeholder */}
|
||||||
|
{vehicle.bild_url ? (
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height="140"
|
||||||
|
image={vehicle.bild_url}
|
||||||
|
alt={vehicle.bezeichnung}
|
||||||
|
sx={{ objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 120,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled' }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
|
||||||
|
{/* Title row */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" component="div" lineHeight={1.2}>
|
||||||
|
{vehicle.bezeichnung}
|
||||||
|
{vehicle.kurzname && (
|
||||||
|
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||||
|
({vehicle.kurzname})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{vehicle.amtliches_kennzeichen && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{vehicle.amtliches_kennzeichen}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Chip
|
||||||
|
icon={statusCfg.icon}
|
||||||
|
label={FahrzeugStatusLabel[status]}
|
||||||
|
color={statusCfg.color}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Crew config */}
|
||||||
|
{vehicle.besatzung_soll && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Besatzung: {vehicle.besatzung_soll}
|
||||||
|
{vehicle.baujahr && ` · Bj. ${vehicle.baujahr}`}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inspection badges */}
|
||||||
|
{inspBadges.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
{inspBadges.map((b) => {
|
||||||
|
const color = inspBadgeColor(b.tage);
|
||||||
|
const label = inspBadgeLabel(b.art, b.tage, b.faelligAm);
|
||||||
|
if (!label) return null;
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={b.art}
|
||||||
|
title={`${PruefungArtLabel[b.art as PruefungArt] ?? b.art}: ${
|
||||||
|
b.tage !== null && b.tage < 0
|
||||||
|
? `Seit ${Math.abs(b.tage)} Tagen überfällig!`
|
||||||
|
: `Fällig am ${new Date(b.faelligAm!).toLocaleDateString('de-DE')}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={label}
|
||||||
|
color={color}
|
||||||
|
variant={color === 'default' ? 'outlined' : 'filled'}
|
||||||
|
icon={b.tage !== null && b.tage < 0 ? <Warning fontSize="small" /> : undefined}
|
||||||
|
sx={{ fontSize: '0.7rem' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Fahrzeuge() {
|
function Fahrzeuge() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const fetchVehicles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await vehiclesApi.getAll();
|
||||||
|
setVehicles(data);
|
||||||
|
} catch {
|
||||||
|
setError('Fahrzeuge konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchVehicles(); }, [fetchVehicles]);
|
||||||
|
|
||||||
|
const filtered = vehicles.filter((v) => {
|
||||||
|
if (!search.trim()) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
v.bezeichnung.toLowerCase().includes(q) ||
|
||||||
|
(v.kurzname?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(v.amtliches_kennzeichen?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(v.hersteller?.toLowerCase().includes(q) ?? false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summary counts
|
||||||
|
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length;
|
||||||
|
const hasOverdue = vehicles.some(
|
||||||
|
(v) => v.naechste_pruefung_tage !== null && v.naechste_pruefung_tage < 0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="xl">
|
||||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
{/* Header */}
|
||||||
Fahrzeugverwaltung
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
</Typography>
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||||
<Card>
|
Fahrzeugverwaltung
|
||||||
<CardContent>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
{!loading && (
|
||||||
<DirectionsCar color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
<Typography variant="body2" color="text.secondary">
|
||||||
<Box>
|
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
|
||||||
<Typography variant="h6">Fahrzeuge</Typography>
|
{' · '}
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
Diese Funktion wird in Kürze verfügbar sein
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
color="success.main"
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{einsatzbereit} einsatzbereit
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
<Typography variant="body1" color="text.secondary" paragraph>
|
|
||||||
Geplante Features:
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<ul>
|
)}
|
||||||
<li>
|
</Box>
|
||||||
<Typography variant="body2" color="text.secondary">
|
</Box>
|
||||||
Fahrzeugliste mit Details
|
|
||||||
</Typography>
|
{/* Overdue inspection global warning */}
|
||||||
</li>
|
{hasOverdue && (
|
||||||
<li>
|
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist.
|
||||||
Wartungspläne und -historie
|
Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden.
|
||||||
</Typography>
|
</Alert>
|
||||||
</li>
|
)}
|
||||||
<li>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
{/* Search bar */}
|
||||||
Tankbuch und Kilometerstände
|
<TextField
|
||||||
</Typography>
|
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
|
||||||
</li>
|
value={search}
|
||||||
<li>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<Typography variant="body2" color="text.secondary">
|
fullWidth
|
||||||
TÜV/HU Erinnerungen
|
size="small"
|
||||||
</Typography>
|
sx={{ mb: 3, maxWidth: 480 }}
|
||||||
</li>
|
InputProps={{
|
||||||
<li>
|
startAdornment: (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<InputAdornment position="start">
|
||||||
Fahrzeugdokumentation
|
<Search />
|
||||||
</Typography>
|
</InputAdornment>
|
||||||
</li>
|
),
|
||||||
</ul>
|
}}
|
||||||
</Box>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* Loading state */}
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{!loading && error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && !error && filtered.length === 0 && (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{vehicles.length === 0
|
||||||
|
? 'Noch keine Fahrzeuge erfasst'
|
||||||
|
: 'Kein Fahrzeug entspricht dem Suchbegriff'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vehicle grid */}
|
||||||
|
{!loading && !error && filtered.length > 0 && (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{filtered.map((vehicle) => (
|
||||||
|
<Grid item key={vehicle.id} xs={12} sm={6} md={4} lg={3}>
|
||||||
|
<VehicleCard
|
||||||
|
vehicle={vehicle}
|
||||||
|
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
aria-label="Fahrzeug hinzufügen"
|
||||||
|
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||||
|
onClick={() => navigate('/fahrzeuge/neu')}
|
||||||
|
>
|
||||||
|
<Add />
|
||||||
|
</Fab>
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,67 +1,436 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Typography,
|
Typography,
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Box,
|
Box,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
Chip,
|
||||||
|
Avatar,
|
||||||
|
Fab,
|
||||||
|
Tooltip,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
OutlinedInput,
|
||||||
|
SelectChangeEvent,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TablePagination,
|
||||||
|
Paper,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { People } from '@mui/icons-material';
|
import {
|
||||||
|
Search as SearchIcon,
|
||||||
|
Add as AddIcon,
|
||||||
|
People as PeopleIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { membersService } from '../services/members';
|
||||||
|
import {
|
||||||
|
MemberListItem,
|
||||||
|
StatusEnum,
|
||||||
|
DienstgradEnum,
|
||||||
|
STATUS_VALUES,
|
||||||
|
DIENSTGRAD_VALUES,
|
||||||
|
STATUS_LABELS,
|
||||||
|
STATUS_COLORS,
|
||||||
|
getMemberDisplayName,
|
||||||
|
formatPhone,
|
||||||
|
} from '../types/member.types';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Helper: determine whether the current user can write member data
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
function useCanWrite(): boolean {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const groups: string[] = (user as any)?.groups ?? [];
|
||||||
|
return groups.includes('feuerwehr-admin') || groups.includes('feuerwehr-kommandant');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Debounce hook
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debounced, setDebounced] = useState<T>(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setDebounced(value), delay);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [value, delay]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ----------------------------------------------------------------
|
||||||
function Mitglieder() {
|
function Mitglieder() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const canWrite = useCanWrite();
|
||||||
|
|
||||||
|
// --- data state ---
|
||||||
|
const [members, setMembers] = useState<MemberListItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// --- filter / pagination state ---
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const debouncedSearch = useDebounce(searchInput, 300);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<StatusEnum[]>([]);
|
||||||
|
const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]);
|
||||||
|
const [page, setPage] = useState(0); // MUI uses 0-based
|
||||||
|
const pageSize = 25;
|
||||||
|
|
||||||
|
// Track previous debounced search to reset page
|
||||||
|
const prevSearch = useRef(debouncedSearch);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Data fetching
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { items, total: t } = await membersService.getMembers({
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
status: selectedStatus.length > 0 ? selectedStatus : undefined,
|
||||||
|
dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined,
|
||||||
|
page: page + 1, // convert to 1-based for API
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
setMembers(items);
|
||||||
|
setTotal(t);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Mitglieder konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [debouncedSearch, selectedStatus, selectedDienstgrad, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset to page 0 when search changes
|
||||||
|
if (debouncedSearch !== prevSearch.current) {
|
||||||
|
prevSearch.current = debouncedSearch;
|
||||||
|
setPage(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchMembers();
|
||||||
|
}, [fetchMembers, debouncedSearch]);
|
||||||
|
|
||||||
|
// Also fetch when page/filters change
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMembers();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, selectedStatus, selectedDienstgrad]);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Event handlers
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
const handleStatusChange = (e: SelectChangeEvent<StatusEnum[]>) => {
|
||||||
|
setSelectedStatus(e.target.value as StatusEnum[]);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDienstgradChange = (e: SelectChangeEvent<DienstgradEnum[]>) => {
|
||||||
|
setSelectedDienstgrad(e.target.value as DienstgradEnum[]);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (userId: string) => {
|
||||||
|
navigate(`/mitglieder/${userId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveStatusChip = (status: StatusEnum) => {
|
||||||
|
setSelectedStatus((prev) => prev.filter((s) => s !== status));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveDienstgradChip = (dg: DienstgradEnum) => {
|
||||||
|
setSelectedDienstgrad((prev) => prev.filter((d) => d !== dg));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
const activeFilters = selectedStatus.length + selectedDienstgrad.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="xl">
|
||||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
{/* Page heading */}
|
||||||
Mitgliederverwaltung
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2, flexWrap: 'wrap' }}>
|
||||||
</Typography>
|
<Typography variant="h4" sx={{ flex: 1 }}>
|
||||||
|
Mitgliederverwaltung
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{loading ? '...' : `${total} Mitglieder`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Card>
|
{/* Toolbar: search + filters */}
|
||||||
<CardContent>
|
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<TextField
|
||||||
<People color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
placeholder="Suche nach Name, E-Mail oder Mitgliedsnummer…"
|
||||||
<Box>
|
size="small"
|
||||||
<Typography variant="h6">Mitglieder</Typography>
|
value={searchInput}
|
||||||
<Typography variant="body2" color="text.secondary">
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
Diese Funktion wird in Kürze verfügbar sein
|
sx={{ flex: '1 1 280px', minWidth: 220 }}
|
||||||
</Typography>
|
InputProps={{
|
||||||
</Box>
|
startAdornment: (
|
||||||
</Box>
|
<InputAdornment position="start">
|
||||||
<Box sx={{ mt: 3 }}>
|
<SearchIcon fontSize="small" />
|
||||||
<Typography variant="body1" color="text.secondary" paragraph>
|
</InputAdornment>
|
||||||
Geplante Features:
|
),
|
||||||
</Typography>
|
}}
|
||||||
<ul>
|
/>
|
||||||
<li>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
{/* Status filter */}
|
||||||
Mitgliederliste mit Kontaktdaten
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
</Typography>
|
<InputLabel>Status</InputLabel>
|
||||||
</li>
|
<Select<StatusEnum[]>
|
||||||
<li>
|
multiple
|
||||||
<Typography variant="body2" color="text.secondary">
|
value={selectedStatus}
|
||||||
Qualifikationen und Lehrgänge
|
onChange={handleStatusChange}
|
||||||
</Typography>
|
input={<OutlinedInput label="Status" />}
|
||||||
</li>
|
renderValue={(selected) =>
|
||||||
<li>
|
selected.length === 0 ? 'Alle' : `${selected.length} gewählt`
|
||||||
<Typography variant="body2" color="text.secondary">
|
}
|
||||||
Anwesenheitsverwaltung
|
>
|
||||||
</Typography>
|
{STATUS_VALUES.map((s) => (
|
||||||
</li>
|
<MenuItem key={s} value={s}>
|
||||||
<li>
|
{STATUS_LABELS[s]}
|
||||||
<Typography variant="body2" color="text.secondary">
|
</MenuItem>
|
||||||
Dienstpläne und -einteilungen
|
))}
|
||||||
</Typography>
|
</Select>
|
||||||
</li>
|
</FormControl>
|
||||||
<li>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
{/* Dienstgrad filter */}
|
||||||
Atemschutz-G26 Untersuchungen
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
</Typography>
|
<InputLabel>Dienstgrad</InputLabel>
|
||||||
</li>
|
<Select<DienstgradEnum[]>
|
||||||
</ul>
|
multiple
|
||||||
</Box>
|
value={selectedDienstgrad}
|
||||||
</CardContent>
|
onChange={handleDienstgradChange}
|
||||||
</Card>
|
input={<OutlinedInput label="Dienstgrad" />}
|
||||||
|
renderValue={(selected) =>
|
||||||
|
selected.length === 0 ? 'Alle' : `${selected.length} gewählt`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{DIENSTGRAD_VALUES.map((dg) => (
|
||||||
|
<MenuItem key={dg} value={dg}>
|
||||||
|
{dg}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Active filter chips */}
|
||||||
|
{activeFilters > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||||
|
{selectedStatus.map((s) => (
|
||||||
|
<Chip
|
||||||
|
key={s}
|
||||||
|
label={STATUS_LABELS[s]}
|
||||||
|
size="small"
|
||||||
|
color={STATUS_COLORS[s]}
|
||||||
|
onDelete={() => handleRemoveStatusChip(s)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{selectedDienstgrad.map((dg) => (
|
||||||
|
<Chip
|
||||||
|
key={dg}
|
||||||
|
label={dg}
|
||||||
|
size="small"
|
||||||
|
onDelete={() => handleRemoveDienstgradChip(dg)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
|
||||||
|
<TableContainer>
|
||||||
|
<Table stickyHeader size="small" aria-label="Mitgliederliste">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ width: 56 }}>Foto</TableCell>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Mitgliedsnr.</TableCell>
|
||||||
|
<TableCell>Dienstgrad</TableCell>
|
||||||
|
<TableCell>Funktion</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Eintrittsdatum</TableCell>
|
||||||
|
<TableCell>Telefon</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
|
||||||
|
<CircularProgress size={32} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||||
|
<PeopleIcon sx={{ fontSize: 48, color: 'text.disabled' }} />
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Keine Mitglieder gefunden.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
members.map((member) => {
|
||||||
|
const displayName = getMemberDisplayName(member);
|
||||||
|
const initials = [member.given_name?.[0], member.family_name?.[0]]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase() || member.email[0].toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={member.id}
|
||||||
|
hover
|
||||||
|
onClick={() => handleRowClick(member.id)}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
aria-label={`Mitglied ${displayName} öffnen`}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<TableCell>
|
||||||
|
<Avatar
|
||||||
|
src={member.profile_picture_url ?? undefined}
|
||||||
|
alt={displayName}
|
||||||
|
sx={{ width: 36, height: 36, fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</Avatar>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Name + email */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{displayName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{member.email}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Mitgliedsnr */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{member.mitglieds_nr ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Dienstgrad */}
|
||||||
|
<TableCell>
|
||||||
|
{member.dienstgrad ? (
|
||||||
|
<Chip label={member.dienstgrad} size="small" variant="outlined" />
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">—</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Funktion(en) */}
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{member.funktion.length > 0
|
||||||
|
? member.funktion.map((f) => (
|
||||||
|
<Chip key={f} label={f} size="small" variant="outlined" color="secondary" />
|
||||||
|
))
|
||||||
|
: <Typography variant="body2" color="text.secondary">—</Typography>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<TableCell>
|
||||||
|
{member.status ? (
|
||||||
|
<Chip
|
||||||
|
label={STATUS_LABELS[member.status]}
|
||||||
|
size="small"
|
||||||
|
color={STATUS_COLORS[member.status]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">—</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Eintrittsdatum */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{member.eintrittsdatum
|
||||||
|
? new Date(member.eintrittsdatum).toLocaleDateString('de-AT')
|
||||||
|
: '—'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Telefon */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{formatPhone(member.telefon_mobil)}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={total}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, newPage) => setPage(newPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
rowsPerPageOptions={[pageSize]}
|
||||||
|
labelDisplayedRows={({ from, to, count }) =>
|
||||||
|
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
{/* FAB — only visible to Kommandant/Admin */}
|
||||||
|
{canWrite && (
|
||||||
|
<Tooltip title="Neues Mitglied anlegen">
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
aria-label="Neues Mitglied anlegen"
|
||||||
|
onClick={() => navigate('/mitglieder/neu')}
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 32,
|
||||||
|
right: 32,
|
||||||
|
zIndex: (theme) => theme.zIndex.speedDial,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</Fab>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user