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:
@@ -19,8 +19,16 @@ 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) {
|
||||||
return { given_name: givenName, family_name: familyName };
|
const looksLikeFullName =
|
||||||
|
givenName.includes(' ') &&
|
||||||
|
(givenName.endsWith(' ' + familyName) || givenName === familyName);
|
||||||
|
if (!looksLikeFullName) {
|
||||||
|
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
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -144,6 +144,22 @@ const lightThemeOptions: ThemeOptions = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
defaultProps: {
|
||||||
|
InputLabelProps: {
|
||||||
|
shrink: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiOutlinedInput: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiOutlinedInput-notchedOutline legend': {
|
||||||
|
fontSize: '0.75em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user