fix: five dashboard improvements across booking, vehicles, profile, and UI

- fix(auth): guard extractNames() against Authentik sending full name in
  given_name field (e.g. "Matthias Hochmeister" + family_name "Hochmeister");
  detect by checking given_name ends with family_name suffix, fall through
  to name-splitting so Vorname/Nachname display correctly in Profile

- fix(db): add migration 018 to repair broken BEFORE UPDATE triggers on
  veranstaltungen and veranstaltung_kategorien; old triggers called
  update_updated_at_column() which references NEW.updated_at, but both
  tables use aktualisiert_am, causing every category/event edit to fail

- feat(booking): open vehicle booking creation to all authenticated users;
  only dashboard_admin / dashboard_moderator can change the Buchungsart
  (type select disabled for regular members); edit and cancel still
  restricted to WRITE_GROUPS

- feat(vehicles): VehicleDashboardCard now fetches equipment warnings via
  equipmentApi.getVehicleWarnings() in parallel and shows an alert when
  any vehicle equipment is not einsatzbereit

- fix(ui): add MuiTextField defaultProps (InputLabelProps.shrink=true) and
  MuiOutlinedInput notch legend font-size override to theme to eliminate
  floating-label / border conflict on click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-03 11:04:57 +01:00
parent 2306741c4d
commit 3101f1a9c5
6 changed files with 79 additions and 10 deletions

View File

@@ -19,9 +19,17 @@ function extractNames(userInfo: { name?: string; given_name?: string; family_nam
const familyName = userInfo.family_name?.trim(); const familyName = userInfo.family_name?.trim();
// If Authentik provides both and they differ, use them directly // If Authentik provides both and they differ, use them directly
// BUT: guard against the case where given_name is actually the full name
// (e.g. Authentik sends given_name="Matthias Hochmeister", family_name="Hochmeister")
if (givenName && familyName && givenName !== familyName) { if (givenName && familyName && givenName !== familyName) {
const looksLikeFullName =
givenName.includes(' ') &&
(givenName.endsWith(' ' + familyName) || givenName === familyName);
if (!looksLikeFullName) {
return { given_name: givenName, family_name: familyName }; return { given_name: givenName, family_name: familyName };
} }
// Fall through to split the name field
}
// Fall back to splitting the name field // Fall back to splitting the name field
if (userInfo.name) { if (userInfo.name) {

View File

@@ -0,0 +1,26 @@
-- Migration 018: Fix BEFORE UPDATE triggers on event tables
-- Problem: update_updated_at_column() sets NEW.updated_at but both event tables
-- use aktualisiert_am instead. This causes every UPDATE to fail inside the trigger.
-- Create a new trigger function that references the correct column name
CREATE OR REPLACE FUNCTION update_aktualisiert_am_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.aktualisiert_am = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Fix veranstaltungen table trigger
DROP TRIGGER IF EXISTS update_veranstaltungen_aktualisiert_am ON veranstaltungen;
CREATE TRIGGER update_veranstaltungen_aktualisiert_am
BEFORE UPDATE ON veranstaltungen
FOR EACH ROW
EXECUTE FUNCTION update_aktualisiert_am_column();
-- Fix veranstaltung_kategorien table trigger (if it was added)
DROP TRIGGER IF EXISTS update_veranstaltung_kategorien_aktualisiert_am ON veranstaltung_kategorien;
CREATE TRIGGER update_veranstaltung_kategorien_aktualisiert_am
BEFORE UPDATE ON veranstaltung_kategorien
FOR EACH ROW
EXECUTE FUNCTION update_aktualisiert_am_column();

View File

@@ -21,7 +21,7 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b
// ── Write operations ────────────────────────────────────────────────────────── // ── Write operations ──────────────────────────────────────────────────────────
router.post('/', authenticate, requireGroups(WRITE_GROUPS), bookingController.create.bind(bookingController)); router.post('/', authenticate, bookingController.create.bind(bookingController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.update.bind(bookingController)); router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.update.bind(bookingController));
// Soft-cancel (sets abgesagt=TRUE) // Soft-cancel (sets abgesagt=TRUE)

View File

@@ -11,7 +11,9 @@ import {
} from '@mui/material'; } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { vehiclesApi } from '../../services/vehicles'; import { vehiclesApi } from '../../services/vehicles';
import { equipmentApi } from '../../services/equipment';
import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types'; import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types';
import type { VehicleEquipmentWarning } from '../../types/equipment.types';
interface VehicleDashboardCardProps { interface VehicleDashboardCardProps {
hideWhenEmpty?: boolean; hideWhenEmpty?: boolean;
@@ -22,6 +24,7 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
}) => { }) => {
const [stats, setStats] = useState<VehicleStats | null>(null); const [stats, setStats] = useState<VehicleStats | null>(null);
const [alerts, setAlerts] = useState<InspectionAlert[]>([]); const [alerts, setAlerts] = useState<InspectionAlert[]>([]);
const [equipmentWarnings, setEquipmentWarnings] = useState<VehicleEquipmentWarning[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -31,13 +34,15 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const [statsData, alertsData] = await Promise.all([ const [statsData, alertsData, warningsData] = await Promise.all([
vehiclesApi.getStats(), vehiclesApi.getStats(),
vehiclesApi.getAlerts(30), vehiclesApi.getAlerts(30),
equipmentApi.getVehicleWarnings(),
]); ]);
if (mounted) { if (mounted) {
setStats(statsData); setStats(statsData);
setAlerts(alertsData); setAlerts(alertsData);
setEquipmentWarnings(warningsData);
} }
} catch { } catch {
if (mounted) setError('Fahrzeugstatus konnte nicht geladen werden.'); if (mounted) setError('Fahrzeugstatus konnte nicht geladen werden.');
@@ -84,7 +89,8 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
const hasConcerns = const hasConcerns =
overdueAlerts.length > 0 || overdueAlerts.length > 0 ||
upcomingAlerts.length > 0 || upcomingAlerts.length > 0 ||
stats.ausserDienst > 0; stats.ausserDienst > 0 ||
equipmentWarnings.length > 0;
const allGood = stats.einsatzbereit === stats.total && !hasConcerns; const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
@@ -133,6 +139,14 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
</Typography> </Typography>
</Alert> </Alert>
)} )}
{equipmentWarnings.length > 0 && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Ausrüstung nicht verfügbar</AlertTitle>
<Typography variant="body2">
{equipmentWarnings.length} Ausrüstungsgegenstand{equipmentWarnings.length !== 1 ? 'stände' : 'stand'} nicht einsatzbereit
</Typography>
</Alert>
)}
</Box> </Box>
)} )}

View File

@@ -81,6 +81,7 @@ const EMPTY_FORM: CreateBuchungInput = {
}; };
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator']; const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
const MANAGE_ART_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main Page // Main Page
@@ -89,8 +90,11 @@ const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_mod
function FahrzeugBuchungen() { function FahrzeugBuchungen() {
const { user } = useAuth(); const { user } = useAuth();
const notification = useNotification(); const notification = useNotification();
const canCreate = !!user; // All authenticated users can create bookings
const canWrite = const canWrite =
user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false; user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false; // Can edit/cancel
const canChangeBuchungsArt =
user?.groups?.some((g) => MANAGE_ART_GROUPS.includes(g)) ?? false; // Can change booking type
// ── Week navigation ──────────────────────────────────────────────────────── // ── Week navigation ────────────────────────────────────────────────────────
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() => const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
@@ -194,7 +198,7 @@ function FahrzeugBuchungen() {
}; };
const handleCellClick = (vehicleId: string, day: Date) => { const handleCellClick = (vehicleId: string, day: Date) => {
if (!canWrite) return; if (!canCreate) return;
const dateStr = format(day, "yyyy-MM-dd'T'08:00"); const dateStr = format(day, "yyyy-MM-dd'T'08:00");
const dateEndStr = format(day, "yyyy-MM-dd'T'17:00"); const dateEndStr = format(day, "yyyy-MM-dd'T'17:00");
setEditingBooking(null); setEditingBooking(null);
@@ -339,7 +343,7 @@ function FahrzeugBuchungen() {
> >
Kalender Kalender
</Button> </Button>
{canWrite && ( {canCreate && (
<Button <Button
startIcon={<Add />} startIcon={<Add />}
onClick={openCreateDialog} onClick={openCreateDialog}
@@ -442,8 +446,8 @@ function FahrzeugBuchungen() {
} }
sx={{ sx={{
bgcolor: isFree ? 'success.50' : undefined, bgcolor: isFree ? 'success.50' : undefined,
cursor: isFree && canWrite ? 'pointer' : 'default', cursor: isFree && canCreate ? 'pointer' : 'default',
'&:hover': isFree && canWrite '&:hover': isFree && canCreate
? { bgcolor: 'success.100' } ? { bgcolor: 'success.100' }
: {}, : {},
p: 0.5, p: 0.5,
@@ -534,7 +538,7 @@ function FahrzeugBuchungen() {
)} )}
{/* ── FAB ── */} {/* ── FAB ── */}
{canWrite && ( {canCreate && (
<Fab <Fab
color="primary" color="primary"
aria-label="Buchung erstellen" aria-label="Buchung erstellen"
@@ -706,6 +710,7 @@ function FahrzeugBuchungen() {
<InputLabel>Buchungsart</InputLabel> <InputLabel>Buchungsart</InputLabel>
<Select <Select
value={form.buchungsArt} value={form.buchungsArt}
disabled={!canChangeBuchungsArt}
onChange={(e) => onChange={(e) =>
setForm((f) => ({ setForm((f) => ({
...f, ...f,

View File

@@ -144,6 +144,22 @@ const lightThemeOptions: ThemeOptions = {
}, },
}, },
}, },
MuiTextField: {
defaultProps: {
InputLabelProps: {
shrink: true,
},
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-notchedOutline legend': {
fontSize: '0.75em',
},
},
},
},
}, },
}; };