new features
This commit is contained in:
@@ -215,6 +215,25 @@ class PermissionController {
|
|||||||
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Konfiguration' });
|
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Konfiguration' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/permissions/users-with?permission=bestellungen:create
|
||||||
|
* Returns users who have a specific permission.
|
||||||
|
*/
|
||||||
|
async getUsersWithPermission(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const permission = req.query.permission as string;
|
||||||
|
if (!permission) {
|
||||||
|
res.status(400).json({ success: false, message: 'permission query parameter required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const users = await permissionService.getUsersWithPermission(permission);
|
||||||
|
res.json({ success: true, data: users });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get users with permission', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Benutzer' });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new PermissionController();
|
export default new PermissionController();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { requirePermission } from '../middleware/rbac.middleware';
|
|||||||
import { auditExport } from '../middleware/audit.middleware';
|
import { auditExport } from '../middleware/audit.middleware';
|
||||||
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
|
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
|
||||||
import cleanupService from '../services/cleanup.service';
|
import cleanupService from '../services/cleanup.service';
|
||||||
|
import pool from '../config/database';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -264,4 +265,64 @@ router.delete(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /api/admin/users/:userId/sync-data — selective sync data deletion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const syncDataBodySchema = z.object({
|
||||||
|
types: z.array(z.enum(['profile', 'ausbildung', 'untersuchungen', 'fuehrerschein', 'befoerderungen'])).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SYNC_TABLE_MAP: Record<string, string> = {
|
||||||
|
profile: 'mitglieder_profile',
|
||||||
|
ausbildung: 'ausbildungen',
|
||||||
|
untersuchungen: 'untersuchungen',
|
||||||
|
fuehrerschein: 'fahrgenehmigungen',
|
||||||
|
befoerderungen: 'befoerderungen',
|
||||||
|
};
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/users/:userId/sync-data',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('admin:write'),
|
||||||
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.userId;
|
||||||
|
const { types } = syncDataBodySchema.parse(req.body);
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const results: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
const table = SYNC_TABLE_MAP[type];
|
||||||
|
if (!table) continue;
|
||||||
|
const result = await client.query(
|
||||||
|
`DELETE FROM ${table} WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
results[type] = result.rowCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
logger.info('Admin deleted sync data', { userId, types, results, admin: req.user?.id });
|
||||||
|
res.json({ success: true, data: results });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid parameters', errors: error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Failed to delete sync data', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Sync-Daten' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ router.get('/:id/status-history', authenticate, equipmentController.getStatusHi
|
|||||||
router.post('/', authenticate, requirePermission('ausruestung:create'), equipmentController.createEquipment.bind(equipmentController));
|
router.post('/', authenticate, requirePermission('ausruestung:create'), equipmentController.createEquipment.bind(equipmentController));
|
||||||
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
|
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
|
||||||
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
|
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
|
||||||
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController));
|
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.addWartung.bind(equipmentController));
|
||||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:create'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController));
|
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:manage_maintenance'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController));
|
||||||
|
|
||||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
// ── Delete — admin only ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const router = Router();
|
|||||||
|
|
||||||
// ── User-facing (any authenticated user) ──────────────────────────────────
|
// ── User-facing (any authenticated user) ──────────────────────────────────
|
||||||
router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController));
|
router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController));
|
||||||
|
router.get('/users-with', authenticate, permissionController.getUsersWithPermission.bind(permissionController));
|
||||||
|
|
||||||
// ── Admin-only routes ─────────────────────────────────────────────────────
|
// ── Admin-only routes ─────────────────────────────────────────────────────
|
||||||
router.get('/admin/matrix', authenticate, requirePermission('admin:view'), permissionController.getMatrix.bind(permissionController));
|
router.get('/admin/matrix', authenticate, requirePermission('admin:view'), permissionController.getMatrix.bind(permissionController));
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehic
|
|||||||
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
|
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
|
||||||
|
|
||||||
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||||
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController));
|
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.addWartung.bind(vehicleController));
|
||||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:change_status'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController));
|
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:manage_maintenance'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -174,13 +174,14 @@ async function getOrderById(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; notizen?: string; budget?: number }, userId: string) {
|
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; notizen?: string; budget?: number }, userId: string) {
|
||||||
try {
|
try {
|
||||||
|
const bestellerId = data.besteller_id && data.besteller_id.trim() ? data.besteller_id.trim() : null;
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO bestellungen (bezeichnung, lieferant_id, notizen, budget, erstellt_von)
|
`INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, erstellt_von)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[data.bezeichnung, data.lieferant_id || null, data.notizen || null, data.budget || null, userId]
|
[data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, userId]
|
||||||
);
|
);
|
||||||
const order = result.rows[0];
|
const order = result.rows[0];
|
||||||
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
|
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
|
||||||
|
|||||||
@@ -248,7 +248,8 @@ class EventsService {
|
|||||||
k.farbe AS kategorie_farbe,
|
k.farbe AS kategorie_farbe,
|
||||||
k.icon AS kategorie_icon,
|
k.icon AS kategorie_icon,
|
||||||
v.datum_von, v.datum_bis, v.ganztaegig,
|
v.datum_von, v.datum_bis, v.ganztaegig,
|
||||||
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
|
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich,
|
||||||
|
v.wiederholung, v.wiederholung_parent_id
|
||||||
FROM veranstaltungen v
|
FROM veranstaltungen v
|
||||||
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||||
WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2 OR (v.datum_von <= $1 AND v.datum_bis >= $2))
|
WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2 OR (v.datum_von <= $1 AND v.datum_bis >= $2))
|
||||||
@@ -274,7 +275,8 @@ class EventsService {
|
|||||||
k.farbe AS kategorie_farbe,
|
k.farbe AS kategorie_farbe,
|
||||||
k.icon AS kategorie_icon,
|
k.icon AS kategorie_icon,
|
||||||
v.datum_von, v.datum_bis, v.ganztaegig,
|
v.datum_von, v.datum_bis, v.ganztaegig,
|
||||||
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
|
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich,
|
||||||
|
v.wiederholung, v.wiederholung_parent_id
|
||||||
FROM veranstaltungen v
|
FROM veranstaltungen v
|
||||||
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||||
WHERE v.datum_von > NOW()
|
WHERE v.datum_von > NOW()
|
||||||
@@ -454,6 +456,8 @@ class EventsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing event.
|
* Updates an existing event.
|
||||||
|
* If the event is a recurrence parent and wiederholung is provided,
|
||||||
|
* it deletes all future instances and regenerates them.
|
||||||
* Returns the updated record or null if not found.
|
* Returns the updated record or null if not found.
|
||||||
*/
|
*/
|
||||||
async updateEvent(id: string, data: UpdateVeranstaltungData): Promise<Veranstaltung | null> {
|
async updateEvent(id: string, data: UpdateVeranstaltungData): Promise<Veranstaltung | null> {
|
||||||
@@ -475,6 +479,7 @@ class EventsService {
|
|||||||
max_teilnehmer: data.max_teilnehmer,
|
max_teilnehmer: data.max_teilnehmer,
|
||||||
anmeldung_erforderlich: data.anmeldung_erforderlich,
|
anmeldung_erforderlich: data.anmeldung_erforderlich,
|
||||||
anmeldung_bis: data.anmeldung_bis,
|
anmeldung_bis: data.anmeldung_bis,
|
||||||
|
wiederholung: data.wiederholung,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [col, val] of Object.entries(fieldMap)) {
|
for (const [col, val] of Object.entries(fieldMap)) {
|
||||||
@@ -494,7 +499,58 @@ class EventsService {
|
|||||||
values
|
values
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) return null;
|
if (result.rows.length === 0) return null;
|
||||||
return rowToVeranstaltung(result.rows[0]);
|
|
||||||
|
const updated = result.rows[0];
|
||||||
|
|
||||||
|
// If this is a recurrence parent and wiederholung was updated, regenerate instances
|
||||||
|
if (data.wiederholung !== undefined && updated.wiederholung_parent_id === null) {
|
||||||
|
// Delete all existing children of this parent
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.wiederholung) {
|
||||||
|
// Regenerate instances from the (possibly new) dates and config
|
||||||
|
const datumVon = data.datum_von ? new Date(data.datum_von) : new Date(updated.datum_von);
|
||||||
|
const datumBis = data.datum_bis ? new Date(data.datum_bis) : new Date(updated.datum_bis);
|
||||||
|
const occurrenceDates = this.generateRecurrenceDates(datumVon, datumBis, data.wiederholung);
|
||||||
|
if (occurrenceDates.length > 0) {
|
||||||
|
const duration = datumBis.getTime() - datumVon.getTime();
|
||||||
|
for (const occDate of occurrenceDates) {
|
||||||
|
const occBis = new Date(occDate.getTime() + duration);
|
||||||
|
await pool.query(
|
||||||
|
`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)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
updated.titel,
|
||||||
|
updated.beschreibung ?? null,
|
||||||
|
updated.ort ?? null,
|
||||||
|
updated.ort_url ?? null,
|
||||||
|
updated.kategorie_id ?? null,
|
||||||
|
occDate,
|
||||||
|
occBis,
|
||||||
|
updated.ganztaegig,
|
||||||
|
updated.zielgruppen,
|
||||||
|
updated.alle_gruppen,
|
||||||
|
updated.max_teilnehmer ?? null,
|
||||||
|
updated.anmeldung_erforderlich,
|
||||||
|
updated.erstellt_von,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info(`Regenerated ${occurrenceDates.length} recurrence instances for event ${id}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`Removed recurrence from event ${id}, all instances deleted`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowToVeranstaltung(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -390,6 +390,31 @@ class PermissionService {
|
|||||||
]);
|
]);
|
||||||
return { groupHierarchy, permissionDeps };
|
return { groupHierarchy, permissionDeps };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns users whose Authentik groups grant a specific permission,
|
||||||
|
* or who are dashboard_admin (always have all permissions).
|
||||||
|
*/
|
||||||
|
async getUsersWithPermission(permissionId: string): Promise<Array<{ id: string; name: string }>> {
|
||||||
|
// Find all groups that have this permission
|
||||||
|
const groupsWithPerm: string[] = [];
|
||||||
|
for (const [group, perms] of this.groupPermissions.entries()) {
|
||||||
|
if (perms.has(permissionId)) {
|
||||||
|
groupsWithPerm.push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always include dashboard_admin
|
||||||
|
groupsWithPerm.push('dashboard_admin');
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT u.id, COALESCE(u.name, u.email) AS name
|
||||||
|
FROM users u
|
||||||
|
WHERE u.authentik_groups && $1::text[]
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
[groupsWithPerm]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const permissionService = new PermissionService();
|
export const permissionService = new PermissionService();
|
||||||
|
|||||||
@@ -37,16 +37,15 @@ async function createItem(
|
|||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
kategorie?: string;
|
kategorie?: string;
|
||||||
geschaetzter_preis?: number;
|
geschaetzter_preis?: number;
|
||||||
url?: string;
|
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
},
|
},
|
||||||
userId: string,
|
userId: string,
|
||||||
) {
|
) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, url, aktiv, erstellt_von)
|
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von)
|
||||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true), $7)
|
VALUES ($1, $2, $3, $4, COALESCE($5, true), $6)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.url || null, data.aktiv ?? true, userId],
|
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId],
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
@@ -58,10 +57,9 @@ async function updateItem(
|
|||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
kategorie?: string;
|
kategorie?: string;
|
||||||
geschaetzter_preis?: number;
|
geschaetzter_preis?: number;
|
||||||
url?: string;
|
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
},
|
},
|
||||||
userId: string,
|
_userId: string,
|
||||||
) {
|
) {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
@@ -82,10 +80,6 @@ async function updateItem(
|
|||||||
params.push(data.geschaetzter_preis);
|
params.push(data.geschaetzter_preis);
|
||||||
fields.push(`geschaetzter_preis = $${params.length}`);
|
fields.push(`geschaetzter_preis = $${params.length}`);
|
||||||
}
|
}
|
||||||
if (data.url !== undefined) {
|
|
||||||
params.push(data.url);
|
|
||||||
fields.push(`url = $${params.length}`);
|
|
||||||
}
|
|
||||||
if (data.aktiv !== undefined) {
|
if (data.aktiv !== undefined) {
|
||||||
params.push(data.aktiv);
|
params.push(data.aktiv);
|
||||||
fields.push(`aktiv = $${params.length}`);
|
fields.push(`aktiv = $${params.length}`);
|
||||||
@@ -95,8 +89,6 @@ async function updateItem(
|
|||||||
return getItemById(id);
|
return getItemById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
params.push(userId);
|
|
||||||
fields.push(`aktualisiert_von = $${params.length}`);
|
|
||||||
params.push(new Date());
|
params.push(new Date());
|
||||||
fields.push(`aktualisiert_am = $${params.length}`);
|
fields.push(`aktualisiert_am = $${params.length}`);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function usePermissions() {
|
|||||||
isFahrmeister: false, // No longer needed — use hasPermission() instead
|
isFahrmeister: false, // No longer needed — use hasPermission() instead
|
||||||
isZeugmeister: false, // No longer needed — use hasPermission() instead
|
isZeugmeister: false, // No longer needed — use hasPermission() instead
|
||||||
canChangeStatus: hasPermission('fahrzeuge:change_status'),
|
canChangeStatus: hasPermission('fahrzeuge:change_status'),
|
||||||
|
canManageMaintenance: hasPermission('fahrzeuge:manage_maintenance'),
|
||||||
|
canManageEquipmentMaintenance: hasPermission('ausruestung:manage_maintenance'),
|
||||||
canManageEquipment: hasPermission('ausruestung:create'),
|
canManageEquipment: hasPermission('ausruestung:create'),
|
||||||
canManageMotorizedEquipment: hasPermission('ausruestung:create'),
|
canManageMotorizedEquipment: hasPermission('ausruestung:create'),
|
||||||
canManageNonMotorizedEquipment: hasPermission('ausruestung:create'),
|
canManageNonMotorizedEquipment: hasPermission('ausruestung:create'),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function AdminDashboard() {
|
|||||||
<Typography variant="h4" sx={{ mb: 3 }}>Administration</Typography>
|
<Typography variant="h4" sx={{ mb: 3 }}>Administration</Typography>
|
||||||
|
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/admin?tab=${v}`, { replace: true }); }}>
|
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/admin?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
|
||||||
<Tab label="Services" />
|
<Tab label="Services" />
|
||||||
<Tab label="System" />
|
<Tab label="System" />
|
||||||
<Tab label="Benutzer" />
|
<Tab label="Benutzer" />
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
Tab,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -34,6 +40,7 @@ import {
|
|||||||
DeleteOutline,
|
DeleteOutline,
|
||||||
Edit,
|
Edit,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
|
History,
|
||||||
MoreHoriz,
|
MoreHoriz,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
RemoveCircle,
|
RemoveCircle,
|
||||||
@@ -97,6 +104,65 @@ function fmtDate(iso: string | null | undefined): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDatetime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '---';
|
||||||
|
return new Date(iso).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Status History Section ---------------------------------------------------
|
||||||
|
|
||||||
|
const StatusHistorySection: React.FC<{ equipmentId: string }> = ({ equipmentId }) => {
|
||||||
|
const [history, setHistory] = useState<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
equipmentApi.getStatusHistory(equipmentId)
|
||||||
|
.then(setHistory)
|
||||||
|
.catch(() => setHistory([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [equipmentId]);
|
||||||
|
|
||||||
|
if (loading || history.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<History fontSize="small" /> Status-Verlauf
|
||||||
|
</Typography>
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Datum</TableCell>
|
||||||
|
<TableCell>Von</TableCell>
|
||||||
|
<TableCell>Nach</TableCell>
|
||||||
|
<TableCell>Bemerkung</TableCell>
|
||||||
|
<TableCell>Geändert von</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{history.map((h, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
<TableCell>{fmtDatetime(h.erstellt_am)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={AusruestungStatusLabel[h.alter_status as AusruestungStatus] || h.alter_status} color={STATUS_CHIP_COLOR[h.alter_status as AusruestungStatus] || 'default'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={AusruestungStatusLabel[h.neuer_status as AusruestungStatus] || h.neuer_status} color={STATUS_CHIP_COLOR[h.neuer_status as AusruestungStatus] || 'default'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{h.bemerkung || '—'}</TableCell>
|
||||||
|
<TableCell>{h.geaendert_von_name || '—'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// -- Wartungslog Art config ---------------------------------------------------
|
// -- Wartungslog Art config ---------------------------------------------------
|
||||||
|
|
||||||
const WARTUNG_ART_CHIP_COLOR: Record<AusruestungWartungslogArt, 'info' | 'warning' | 'default'> = {
|
const WARTUNG_ART_CHIP_COLOR: Record<AusruestungWartungslogArt, 'info' | 'warning' | 'default'> = {
|
||||||
@@ -337,6 +403,8 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdate
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<StatusHistorySection equipmentId={equipment.id} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -430,7 +498,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
<Typography variant="body2">{entry.beschreibung}</Typography>
|
<Typography variant="body2">{entry.beschreibung}</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||||
{[
|
{[
|
||||||
entry.kosten != null && `${entry.kosten.toFixed(2)} EUR`,
|
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} EUR`,
|
||||||
entry.pruefende_stelle && entry.pruefende_stelle,
|
entry.pruefende_stelle && entry.pruefende_stelle,
|
||||||
].filter(Boolean).join(' · ')}
|
].filter(Boolean).join(' · ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -563,7 +631,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
function AusruestungDetailPage() {
|
function AusruestungDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAdmin, canManageCategory } = usePermissions();
|
const { isAdmin, canManageCategory, canManageEquipmentMaintenance } = usePermissions();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
|
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
|
||||||
@@ -706,6 +774,8 @@ function AusruestungDetailPage() {
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={(_, v) => setActiveTab(v)}
|
onChange={(_, v) => setActiveTab(v)}
|
||||||
aria-label="Ausrüstung Detailansicht"
|
aria-label="Ausrüstung Detailansicht"
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
>
|
>
|
||||||
<Tab label="Übersicht" />
|
<Tab label="Übersicht" />
|
||||||
<Tab
|
<Tab
|
||||||
@@ -733,7 +803,7 @@ function AusruestungDetailPage() {
|
|||||||
equipmentId={equipment.id}
|
equipmentId={equipment.id}
|
||||||
wartungslog={equipment.wartungslog ?? []}
|
wartungslog={equipment.wartungslog ?? []}
|
||||||
onAdded={fetchEquipment}
|
onAdded={fetchEquipment}
|
||||||
canWrite={canWrite}
|
canWrite={canManageEquipmentMaintenance}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export default function Bestellungen() {
|
|||||||
queryFn: bestellungApi.getVendors,
|
queryFn: bestellungApi.getVendors,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: orderUsers = [] } = useQuery({
|
||||||
|
queryKey: ['bestellungen', 'order-users'],
|
||||||
|
queryFn: bestellungApi.getOrderUsers,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
const createOrder = useMutation({
|
const createOrder = useMutation({
|
||||||
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
|
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
|
||||||
@@ -194,7 +199,7 @@ export default function Bestellungen() {
|
|||||||
<Typography variant="h4" sx={{ mb: 3 }}>Bestellungen</Typography>
|
<Typography variant="h4" sx={{ mb: 3 }}>Bestellungen</Typography>
|
||||||
|
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }}>
|
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
|
||||||
<Tab label="Bestellungen" />
|
<Tab label="Bestellungen" />
|
||||||
<Tab label="Lieferanten" />
|
<Tab label="Lieferanten" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -366,10 +371,12 @@ export default function Bestellungen() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
<TextField
|
<Autocomplete
|
||||||
label="Besteller"
|
options={orderUsers}
|
||||||
value={orderForm.besteller_id || ''}
|
getOptionLabel={(o) => o.name}
|
||||||
onChange={(e) => setOrderForm((f) => ({ ...f, besteller_id: e.target.value }))}
|
value={orderUsers.find((u) => u.id === orderForm.besteller_id) || null}
|
||||||
|
onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Besteller" />}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Budget"
|
label="Budget"
|
||||||
|
|||||||
@@ -234,22 +234,31 @@ function FahrzeugBuchungen() {
|
|||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const beginn = new Date(form.beginn);
|
||||||
|
const ende = new Date(form.ende);
|
||||||
|
if (isNaN(beginn.getTime()) || isNaN(ende.getTime()) || ende <= beginn) {
|
||||||
|
setAvailability(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
bookingApi
|
const timer = setTimeout(() => {
|
||||||
.checkAvailability(
|
bookingApi
|
||||||
form.fahrzeugId,
|
.checkAvailability(
|
||||||
new Date(form.beginn),
|
form.fahrzeugId,
|
||||||
new Date(form.ende),
|
beginn,
|
||||||
editingBooking?.id
|
ende,
|
||||||
)
|
editingBooking?.id
|
||||||
.then((result) => {
|
)
|
||||||
if (!cancelled) setAvailability(result);
|
.then((result) => {
|
||||||
})
|
if (!cancelled) setAvailability(result);
|
||||||
.catch(() => {
|
})
|
||||||
if (!cancelled) setAvailability(null);
|
.catch(() => {
|
||||||
});
|
if (!cancelled) setAvailability(null);
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [form.fahrzeugId, form.beginn, form.ende, editingBooking?.id]);
|
}, [form.fahrzeugId, form.beginn, form.ende, editingBooking?.id]);
|
||||||
|
|
||||||
@@ -698,6 +707,23 @@ function FahrzeugBuchungen() {
|
|||||||
Von: {detailBooking.gebucht_von_name}
|
Von: {detailBooking.gebucht_von_name}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
{(() => {
|
||||||
|
const mw = maintenanceWindows.find((w) => w.id === detailBooking.fahrzeug_id);
|
||||||
|
if (mw?.ausser_dienst_von && mw?.ausser_dienst_bis) {
|
||||||
|
const bookingStart = new Date(detailBooking.beginn);
|
||||||
|
const bookingEnd = new Date(detailBooking.ende);
|
||||||
|
const serviceStart = new Date(mw.ausser_dienst_von);
|
||||||
|
const serviceEnd = new Date(mw.ausser_dienst_bis);
|
||||||
|
if (bookingStart < serviceEnd && bookingEnd > serviceStart) {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" sx={{ mt: 1, py: 0, fontSize: '0.75rem' }}>
|
||||||
|
Fahrzeug außer Dienst: {format(serviceStart, 'dd.MM.')} – {format(serviceEnd, 'dd.MM.yyyy')}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
{(canWrite || (canCancelOwn && detailBooking.gebucht_von === user?.id)) && (
|
{(canWrite || (canCancelOwn && detailBooking.gebucht_von === user?.id)) && (
|
||||||
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
|
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
|
|||||||
@@ -526,8 +526,8 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||||
{[
|
{[
|
||||||
entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`,
|
entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`,
|
||||||
entry.kraftstoff_liter != null && `${entry.kraftstoff_liter.toFixed(1)} L`,
|
entry.kraftstoff_liter != null && `${Number(entry.kraftstoff_liter).toFixed(1)} L`,
|
||||||
entry.kosten != null && `${entry.kosten.toFixed(2)} €`,
|
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} €`,
|
||||||
entry.externe_werkstatt && entry.externe_werkstatt,
|
entry.externe_werkstatt && entry.externe_werkstatt,
|
||||||
].filter(Boolean).join(' · ')}
|
].filter(Boolean).join(' · ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -818,7 +818,7 @@ const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId: _
|
|||||||
function FahrzeugDetail() {
|
function FahrzeugDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAdmin, canChangeStatus } = usePermissions();
|
const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
|
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
|
||||||
@@ -959,6 +959,8 @@ function FahrzeugDetail() {
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={(_, v) => setActiveTab(v)}
|
onChange={(_, v) => setActiveTab(v)}
|
||||||
aria-label="Fahrzeug Detailansicht"
|
aria-label="Fahrzeug Detailansicht"
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
>
|
>
|
||||||
<Tab label="Übersicht" />
|
<Tab label="Übersicht" />
|
||||||
<Tab
|
<Tab
|
||||||
@@ -988,7 +990,7 @@ function FahrzeugDetail() {
|
|||||||
fahrzeugId={vehicle.id}
|
fahrzeugId={vehicle.id}
|
||||||
wartungslog={vehicle.wartungslog ?? []}
|
wartungslog={vehicle.wartungslog ?? []}
|
||||||
onAdded={fetchVehicle}
|
onAdded={fetchVehicle}
|
||||||
canWrite={canChangeStatus}
|
canWrite={canManageMaintenance}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -2172,6 +2172,8 @@ export default function Kalender() {
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={(_, v) => { setActiveTab(v); navigate(`/kalender?tab=${v}`, { replace: true }); }}
|
onChange={(_, v) => { setActiveTab(v); navigate(`/kalender?tab=${v}`, { replace: true }); }}
|
||||||
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
|
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
>
|
>
|
||||||
<Tab icon={<EventIcon />} iconPosition="start" label="Dienste & Veranstaltungen" />
|
<Tab icon={<EventIcon />} iconPosition="start" label="Dienste & Veranstaltungen" />
|
||||||
<Tab icon={<CarIcon />} iconPosition="start" label="Fahrzeugbuchungen" />
|
<Tab icon={<CarIcon />} iconPosition="start" label="Fahrzeugbuchungen" />
|
||||||
|
|||||||
@@ -575,6 +575,8 @@ function MitgliedDetail() {
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={(_e, v) => setActiveTab(v)}
|
onChange={(_e, v) => setActiveTab(v)}
|
||||||
aria-label="Mitglied Details"
|
aria-label="Mitglied Details"
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
>
|
>
|
||||||
<Tab label="Stammdaten" id="tab-0" aria-controls="tabpanel-0" />
|
<Tab label="Stammdaten" id="tab-0" aria-controls="tabpanel-0" />
|
||||||
<Tab label="Qualifikationen" id="tab-1" aria-controls="tabpanel-1" />
|
<Tab label="Qualifikationen" id="tab-1" aria-controls="tabpanel-1" />
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ 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 } from '../types/shop.types';
|
||||||
import type { Bestellung } from '../types/bestellung.types';
|
import type { Bestellung } from '../types/bestellung.types';
|
||||||
|
|
||||||
const priceFormat = new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' });
|
|
||||||
|
|
||||||
// ─── Catalog Tab ────────────────────────────────────────────────────────────
|
// ─── Catalog Tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface DraftItem {
|
interface DraftItem {
|
||||||
@@ -116,7 +114,7 @@ function KatalogTab() {
|
|||||||
};
|
};
|
||||||
const openEditArtikel = (a: ShopArtikel) => {
|
const openEditArtikel = (a: ShopArtikel) => {
|
||||||
setEditArtikel(a);
|
setEditArtikel(a);
|
||||||
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie, geschaetzter_preis: a.geschaetzter_preis });
|
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie });
|
||||||
setArtikelDialogOpen(true);
|
setArtikelDialogOpen(true);
|
||||||
};
|
};
|
||||||
const saveArtikel = () => {
|
const saveArtikel = () => {
|
||||||
@@ -173,12 +171,11 @@ function KatalogTab() {
|
|||||||
<CardContent sx={{ flexGrow: 1 }}>
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
<Typography variant="subtitle1" fontWeight={600}>{item.bezeichnung}</Typography>
|
<Typography variant="subtitle1" fontWeight={600}>{item.bezeichnung}</Typography>
|
||||||
{item.beschreibung && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{item.beschreibung}</Typography>}
|
{item.beschreibung && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{item.beschreibung}</Typography>}
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
|
{item.kategorie && (
|
||||||
{item.kategorie && <Chip label={item.kategorie} size="small" />}
|
<Box sx={{ mt: 1 }}>
|
||||||
{item.geschaetzter_preis != null && (
|
<Chip label={item.kategorie} size="small" />
|
||||||
<Typography variant="body2" color="text.secondary">ca. {priceFormat.format(item.geschaetzter_preis)}</Typography>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardActions sx={{ justifyContent: 'space-between' }}>
|
<CardActions sx={{ justifyContent: 'space-between' }}>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
@@ -197,10 +194,12 @@ function KatalogTab() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom item + draft summary */}
|
{/* Free-text item + draft summary */}
|
||||||
{canCreate && draft.length > 0 && (
|
{canCreate && (
|
||||||
<Paper variant="outlined" sx={{ mt: 3, p: 2 }}>
|
<Paper variant="outlined" sx={{ mt: 3, p: 2 }}>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Anfrage-Entwurf</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
{draft.length > 0 ? 'Anfrage-Entwurf' : 'Freitext-Position hinzufügen'}
|
||||||
|
</Typography>
|
||||||
{draft.map((d, idx) => (
|
{draft.map((d, idx) => (
|
||||||
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Typography variant="body2" sx={{ flexGrow: 1 }}>{d.bezeichnung}</Typography>
|
<Typography variant="body2" sx={{ flexGrow: 1 }}>{d.bezeichnung}</Typography>
|
||||||
@@ -208,12 +207,14 @@ function KatalogTab() {
|
|||||||
<IconButton size="small" onClick={() => removeDraftItem(idx)}><DeleteIcon fontSize="small" /></IconButton>
|
<IconButton size="small" onClick={() => removeDraftItem(idx)}><DeleteIcon fontSize="small" /></IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Divider sx={{ my: 1 }} />
|
{draft.length > 0 && <Divider sx={{ my: 1 }} />}
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
<TextField size="small" placeholder="Eigener Artikel (Freitext)" value={customText} onChange={e => setCustomText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addCustomToDraft(); }} sx={{ flexGrow: 1 }} />
|
<TextField size="small" placeholder="Eigener Artikel (Freitext)" value={customText} onChange={e => setCustomText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addCustomToDraft(); }} sx={{ flexGrow: 1 }} />
|
||||||
<Button size="small" onClick={addCustomToDraft} disabled={!customText.trim()}>Hinzufügen</Button>
|
<Button size="small" onClick={addCustomToDraft} disabled={!customText.trim()}>Hinzufügen</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="contained" sx={{ mt: 1.5 }} startIcon={<ShoppingCart />} onClick={() => setSubmitOpen(true)}>Anfrage absenden</Button>
|
{draft.length > 0 && (
|
||||||
|
<Button variant="contained" sx={{ mt: 1.5 }} startIcon={<ShoppingCart />} onClick={() => setSubmitOpen(true)}>Anfrage absenden</Button>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -246,13 +247,6 @@ function KatalogTab() {
|
|||||||
onInputChange={(_, val) => setArtikelForm(f => ({ ...f, kategorie: val || undefined }))}
|
onInputChange={(_, val) => setArtikelForm(f => ({ ...f, kategorie: val || undefined }))}
|
||||||
renderInput={params => <TextField {...params} label="Kategorie" />}
|
renderInput={params => <TextField {...params} label="Kategorie" />}
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
label="Geschätzter Preis (EUR)"
|
|
||||||
type="number"
|
|
||||||
value={artikelForm.geschaetzter_preis ?? ''}
|
|
||||||
onChange={e => setArtikelForm(f => ({ ...f, geschaetzter_preis: e.target.value ? Number(e.target.value) : undefined }))}
|
|
||||||
inputProps={{ min: 0, step: 0.01 }}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button>
|
<Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button>
|
||||||
@@ -607,7 +601,7 @@ export default function Shop() {
|
|||||||
<Typography variant="h5" fontWeight={700} sx={{ mb: 2 }}>Shop</Typography>
|
<Typography variant="h5" fontWeight={700} sx={{ mb: 2 }}>Shop</Typography>
|
||||||
|
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||||
<Tabs value={activeTab} onChange={handleTabChange}>
|
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
|
||||||
<Tab label="Katalog" />
|
<Tab label="Katalog" />
|
||||||
{canCreate && <Tab label="Meine Anfragen" />}
|
{canCreate && <Tab label="Meine Anfragen" />}
|
||||||
{canApprove && <Tab label="Alle Anfragen" />}
|
{canApprove && <Tab label="Alle Anfragen" />}
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ function EventFormDialog({
|
|||||||
max_teilnehmer: null,
|
max_teilnehmer: null,
|
||||||
anmeldung_erforderlich: editingEvent.anmeldung_erforderlich,
|
anmeldung_erforderlich: editingEvent.anmeldung_erforderlich,
|
||||||
anmeldung_bis: null,
|
anmeldung_bis: null,
|
||||||
|
wiederholung: editingEvent.wiederholung ?? undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -927,10 +928,15 @@ function EventFormDialog({
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Recurrence / Wiederholung — only for new events */}
|
{/* Recurrence / Wiederholung — for new events or when editing a parent event */}
|
||||||
{!editingEvent && (
|
{(!editingEvent || (editingEvent.wiederholung && !editingEvent.wiederholung_parent_id)) && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
{editingEvent && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Änderungen an der Wiederholung werden alle bestehenden Instanzen löschen und neu generieren.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -114,4 +114,10 @@ export const bestellungApi = {
|
|||||||
const r = await api.get(`/api/bestellungen/${orderId}/history`);
|
const r = await api.get(`/api/bestellungen/${orderId}/history`);
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Users with order permission ──
|
||||||
|
getOrderUsers: async (): Promise<Array<{ id: string; name: string }>> => {
|
||||||
|
const r = await api.get('/api/permissions/users-with', { params: { permission: 'bestellungen:create' } });
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -108,4 +108,17 @@ export const equipmentApi = {
|
|||||||
}
|
}
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getStatusHistory(id: string): Promise<Array<{
|
||||||
|
alter_status: string;
|
||||||
|
neuer_status: string;
|
||||||
|
bemerkung?: string;
|
||||||
|
geaendert_von_name?: string;
|
||||||
|
erstellt_am: string;
|
||||||
|
}>> {
|
||||||
|
const response = await api.get<{ success: boolean; data: any[] }>(
|
||||||
|
`/api/equipment/${id}/status-history`
|
||||||
|
);
|
||||||
|
return response.data.data ?? [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -733,7 +733,7 @@ async function scrapeAusbildungenFromDetailPage(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${BASE_URL}/fdisk/module/mgvw/ausbildungen/AusbildungenListEdit.aspx`
|
const url = `${BASE_URL}/fdisk/module/mgvw/kursteilnehmer/KursteilnehmerListEdit.aspx`
|
||||||
+ `?search=1&searchid_mitgliedschaften=${idMitgliedschaft}&id_personen=${idPersonen}`
|
+ `?search=1&searchid_mitgliedschaften=${idMitgliedschaft}&id_personen=${idPersonen}`
|
||||||
+ `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`;
|
+ `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`;
|
||||||
|
|
||||||
@@ -1084,7 +1084,35 @@ async function scrapeMemberUntersuchungen(
|
|||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (hasHistoryLink) {
|
if (hasHistoryLink) {
|
||||||
log(` → Found history link: ${hasHistoryLink}`);
|
log(` → Found history link: ${hasHistoryLink}, navigating...`);
|
||||||
|
// Try to click or navigate to the history page for more complete data
|
||||||
|
try {
|
||||||
|
const navigated = await frame.evaluate(() => {
|
||||||
|
const links = Array.from(document.querySelectorAll('a, input[type="button"], button'));
|
||||||
|
for (const el of links) {
|
||||||
|
const text = (el.textContent || '').toLowerCase();
|
||||||
|
const title = (el.getAttribute('title') || '').toLowerCase();
|
||||||
|
if (text.includes('verlauf') || text.includes('historie') || text.includes('alle anzeigen')
|
||||||
|
|| title.includes('verlauf') || title.includes('historie')) {
|
||||||
|
if ((el as HTMLAnchorElement).href) {
|
||||||
|
return (el as HTMLAnchorElement).href;
|
||||||
|
}
|
||||||
|
(el as HTMLElement).click();
|
||||||
|
return 'clicked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).catch(() => null);
|
||||||
|
if (navigated && navigated !== 'clicked') {
|
||||||
|
await frame_goto(frame, navigated);
|
||||||
|
} else if (navigated === 'clicked') {
|
||||||
|
await frame.waitForNavigation({ timeout: 5000 }).catch(() => {});
|
||||||
|
}
|
||||||
|
await selectAlleAnzeige(frame);
|
||||||
|
await dumpHtml(frame, `untersuchungen_history_StNr${standesbuchNr}`);
|
||||||
|
} catch (e) {
|
||||||
|
log(` → Failed to follow history link: ${e}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the table using navigateAndGetTableRows logic (reuse existing page state)
|
// Parse the table using navigateAndGetTableRows logic (reuse existing page state)
|
||||||
@@ -1262,10 +1290,19 @@ async function scrapeMemberFahrgenehmigungen(
|
|||||||
|
|
||||||
// If form-field approach found rows, use them
|
// If form-field approach found rows, use them
|
||||||
if (rawRows.length > 0) {
|
if (rawRows.length > 0) {
|
||||||
|
const VALID_LICENSE_CLASSES = new Set([
|
||||||
|
'A', 'A1', 'A2', 'AM', 'B', 'B1', 'BE', 'C', 'C1', 'CE', 'C1E',
|
||||||
|
'D', 'D1', 'DE', 'D1E', 'F', 'G', 'L', 'T',
|
||||||
|
]);
|
||||||
const results: FdiskFahrgenehmigung[] = [];
|
const results: FdiskFahrgenehmigung[] = [];
|
||||||
for (const row of rawRows) {
|
for (const row of rawRows) {
|
||||||
const klasse = cellText(row.klasse);
|
const klasse = cellText(row.klasse);
|
||||||
if (!klasse) continue;
|
if (!klasse) continue;
|
||||||
|
// Validate klasse against whitelist — skip non-class data
|
||||||
|
if (!VALID_LICENSE_CLASSES.has(klasse.toUpperCase())) {
|
||||||
|
log(` → Skipping invalid klasse: "${klasse}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const ausstellungsdatum = parseDate(row.ausstellungsdatum);
|
const ausstellungsdatum = parseDate(row.ausstellungsdatum);
|
||||||
const syncKey = `${standesbuchNr}::${klasse}::${ausstellungsdatum ?? ''}`;
|
const syncKey = `${standesbuchNr}::${klasse}::${ausstellungsdatum ?? ''}`;
|
||||||
results.push({
|
results.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user