widget icon rework, widget grouping rework

This commit is contained in:
Matthias Hochmeister
2026-04-14 10:53:03 +02:00
parent 4fbea8af81
commit 588d8e81db
22 changed files with 245 additions and 327 deletions

View File

@@ -3,16 +3,16 @@ import {
Alert,
AlertTitle,
Box,
Card,
CardContent,
CircularProgress,
Typography,
} from '@mui/material';
import AirIcon from '@mui/icons-material/Air';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { atemschutzApi } from '../../services/atemschutz';
import { useCountUp } from '../../hooks/useCountUp';
import type { AtemschutzStats } from '../../types/atemschutz.types';
import { WidgetCard } from '../templates/WidgetCard';
import { StatSkeleton } from '../templates/SkeletonPresets';
interface AtemschutzDashboardCardProps {
hideWhenEmpty?: boolean;
@@ -30,102 +30,74 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
const animatedReady = useCountUp(stats?.einsatzbereit ?? 0);
const animatedTotal = useCountUp(stats?.total ?? 0);
if (isLoading) {
return (
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Atemschutzstatus wird geladen...
</Typography>
</CardContent>
</Card>
);
}
const hasConcerns = stats
? stats.untersuchungAbgelaufen > 0 ||
stats.leistungstestAbgelaufen > 0 ||
stats.untersuchungBaldFaellig > 0 ||
stats.leistungstestBaldFaellig > 0
: false;
const allGood = stats ? stats.einsatzbereit === stats.total && !hasConcerns : false;
if (isError) {
return (
<Card>
<CardContent>
<Typography variant="body2" color="error">
Atemschutzstatus konnte nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
if (!stats) return null;
const hasConcerns =
stats.untersuchungAbgelaufen > 0 ||
stats.leistungstestAbgelaufen > 0 ||
stats.untersuchungBaldFaellig > 0 ||
stats.leistungstestBaldFaellig > 0;
const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
if (hideWhenEmpty && allGood) return null;
if (!isLoading && !isError && stats && hideWhenEmpty && allGood) return null;
return (
<Card sx={{ height: '100%', cursor: 'pointer' }} onClick={() => navigate('/atemschutz')}>
<CardContent>
<Typography variant="h6" gutterBottom>
Atemschutz
</Typography>
<WidgetCard
title="Atemschutz"
icon={<AirIcon />}
isLoading={isLoading}
isError={isError}
errorMessage="Atemschutzstatus konnte nicht geladen werden."
skeleton={<StatSkeleton />}
onClick={() => navigate('/atemschutz')}
>
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
{animatedReady}/{animatedTotal}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
einsatzbereit
</Typography>
{/* Main metric */}
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
{animatedReady}/{animatedTotal}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
einsatzbereit
</Typography>
{hasConcerns && stats && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{(stats.untersuchungAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0) && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Abgelaufen</AlertTitle>
{stats.untersuchungAbgelaufen > 0 && (
<Typography variant="body2">
{stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen
</Typography>
)}
{stats.leistungstestAbgelaufen > 0 && (
<Typography variant="body2">
{stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen
</Typography>
)}
</Alert>
)}
{(stats.untersuchungBaldFaellig > 0 || stats.leistungstestBaldFaellig > 0) && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Bald fällig</AlertTitle>
{stats.untersuchungBaldFaellig > 0 && (
<Typography variant="body2">
{stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig
</Typography>
)}
{stats.leistungstestBaldFaellig > 0 && (
<Typography variant="body2">
{stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig
</Typography>
)}
</Alert>
)}
</Box>
)}
{/* Concerns list — using Alert components for consistent warning styling */}
{hasConcerns && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{(stats.untersuchungAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0) && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Abgelaufen</AlertTitle>
{stats.untersuchungAbgelaufen > 0 && (
<Typography variant="body2">
{stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen
</Typography>
)}
{stats.leistungstestAbgelaufen > 0 && (
<Typography variant="body2">
{stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen
</Typography>
)}
</Alert>
)}
{(stats.untersuchungBaldFaellig > 0 || stats.leistungstestBaldFaellig > 0) && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Bald fällig</AlertTitle>
{stats.untersuchungBaldFaellig > 0 && (
<Typography variant="body2">
{stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig
</Typography>
)}
{stats.leistungstestBaldFaellig > 0 && (
<Typography variant="body2">
{stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig
</Typography>
)}
</Alert>
)}
</Box>
)}
{/* All good message */}
{allGood && (
<Typography variant="body2" color="success.main">
Alle Atemschutzträger einsatzbereit
</Typography>
)}
</CardContent>
</Card>
{allGood && (
<Typography variant="body2" color="success.main">
Alle Atemschutzträger einsatzbereit
</Typography>
)}
</WidgetCard>
);
};

View File

@@ -33,7 +33,7 @@ function AdminStatusWidget() {
return (
<WidgetCard
title="Service Status"
icon={<MonitorHeartOutlined color={color} />}
icon={<MonitorHeartOutlined />}
isLoading={!data}
skeleton={<StatSkeleton />}
onClick={() => navigate('/admin')}

View File

@@ -22,7 +22,7 @@ function AusruestungsanfrageWidget() {
return (
<WidgetCard
title="Interne Bestellungen"
icon={<Build fontSize="small" color="action" />}
icon={<Build fontSize="small" />}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError || (!isLoading && !overview)}

View File

@@ -26,7 +26,7 @@ export default function BannerWidget() {
return (
<WidgetCard
title="Mitteilungen"
icon={<Campaign color="primary" />}
icon={<Campaign />}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{widgetBanners.map(banner => (

View File

@@ -31,7 +31,7 @@ function BestellungenWidget() {
return (
<WidgetCard
title="Bestellungen"
icon={<LocalShipping fontSize="small" color="action" />}
icon={<LocalShipping fontSize="small" />}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError}

View File

@@ -76,7 +76,7 @@ const BookStackRecentWidget: React.FC = () => {
return (
<WidgetCard
title="Wissen — Neueste Seiten"
icon={<MenuBook color="disabled" />}
icon={<MenuBook />}
isEmpty
emptyMessage="BookStack nicht eingerichtet"
/>
@@ -87,7 +87,7 @@ const BookStackRecentWidget: React.FC = () => {
return (
<WidgetCard
title="Wissen — Neueste Seiten"
icon={<MenuBook color="primary" />}
icon={<MenuBook />}
isError
errorMessage="BookStack nicht erreichbar"
/>
@@ -97,7 +97,7 @@ const BookStackRecentWidget: React.FC = () => {
return (
<ListCard
title="Wissen — Neueste Seiten"
icon={<MenuBook color="primary" />}
icon={<MenuBook />}
items={pages}
renderItem={(page) => <PageRow key={page.id} page={page} />}
isLoading={isLoading}

View File

@@ -122,7 +122,7 @@ const BookStackSearchWidget: React.FC = () => {
return (
<WidgetCard
title="Wissen — Suche"
icon={<MenuBook color="primary" />}
icon={<MenuBook />}
isLoading
skeleton={<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} animation="wave" />}
/>
@@ -133,7 +133,7 @@ const BookStackSearchWidget: React.FC = () => {
return (
<WidgetCard
title="Wissen — Suche"
icon={<MenuBook color="disabled" />}
icon={<MenuBook />}
isEmpty
emptyMessage="BookStack nicht eingerichtet"
/>
@@ -143,7 +143,7 @@ const BookStackSearchWidget: React.FC = () => {
return (
<WidgetCard
title="Wissen — Suche"
icon={<MenuBook color="primary" />}
icon={<MenuBook />}
>
<TextField
fullWidth

View File

@@ -37,7 +37,7 @@ function BuchhaltungWidget() {
return (
<WidgetCard
title="Buchhaltung"
icon={<AccountBalance fontSize="small" color="action" />}
icon={<AccountBalance fontSize="small" />}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError || (!isLoading && !activeJahr)}

View File

@@ -25,7 +25,7 @@ function ChecklistWidget() {
return (
<WidgetCard
title="Checklisten"
icon={<AssignmentTurnedIn fontSize="small" color="action" />}
icon={<AssignmentTurnedIn fontSize="small" />}
action={titleAction}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}

View File

@@ -111,7 +111,7 @@ const EventQuickAddWidget: React.FC = () => {
return (
<FormCard
title="Veranstaltung"
icon={<CalendarMonth color="primary" />}
icon={<CalendarMonth />}
isSubmitting={mutation.isPending}
onSubmit={handleSubmit}
submitLabel="Erstellen"

View File

@@ -21,7 +21,7 @@ function IssueOverviewWidget() {
return (
<WidgetCard
title="Issues"
icon={<BugReport fontSize="small" color="action" />}
icon={<BugReport fontSize="small" />}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError}

View File

@@ -66,7 +66,7 @@ const IssueQuickAddWidget: React.FC = () => {
return (
<WidgetCard
title="Issue melden"
icon={<BugReport color="primary" />}
icon={<BugReport />}
isLoading
skeleton={<FormSkeleton />}
/>
@@ -76,7 +76,7 @@ const IssueQuickAddWidget: React.FC = () => {
return (
<FormCard
title="Issue melden"
icon={<BugReport color="primary" />}
icon={<BugReport />}
isSubmitting={mutation.isPending}
onSubmit={handleSubmit}
submitLabel="Melden"

View File

@@ -13,7 +13,7 @@ function LinksWidget({ collection }: LinksWidgetProps) {
return (
<ListCard
title={collection.name}
icon={<LinkIcon color="primary" />}
icon={<LinkIcon />}
items={collection.links}
renderItem={(link) => (
<Link

View File

@@ -138,7 +138,7 @@ const UpcomingEventsWidget: React.FC = () => {
return (
<WidgetCard
title="Nächste Termine"
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
icon={<CalendarMonthIcon fontSize="small" />}
isError
errorMessage="Termine konnten nicht geladen werden."
/>
@@ -148,7 +148,7 @@ const UpcomingEventsWidget: React.FC = () => {
return (
<ListCard
title="Nächste Termine"
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
icon={<CalendarMonthIcon fontSize="small" />}
items={entries}
renderItem={(entry) => (
<Box

View File

@@ -56,7 +56,7 @@ const VehicleBookingListWidget: React.FC = () => {
return (
<WidgetCard
title="Nächste Fahrzeugbuchungen"
icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
icon={<DirectionsCarIcon fontSize="small" />}
isError
errorMessage="Buchungen konnten nicht geladen werden."
/>
@@ -66,7 +66,7 @@ const VehicleBookingListWidget: React.FC = () => {
return (
<ListCard
title="Nächste Fahrzeugbuchungen"
icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
icon={<DirectionsCarIcon fontSize="small" />}
items={items}
renderItem={(booking) => {
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';

View File

@@ -95,7 +95,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
return (
<WidgetCard
title="Fahrzeugbuchung"
icon={<DirectionsCar color="primary" />}
icon={<DirectionsCar />}
isLoading
skeleton={<FormSkeleton />}
/>
@@ -105,7 +105,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
return (
<FormCard
title="Fahrzeugbuchung"
icon={<DirectionsCar color="primary" />}
icon={<DirectionsCar />}
isSubmitting={mutation.isPending}
onSubmit={handleSubmit}
submitLabel="Erstellen"

View File

@@ -96,7 +96,7 @@ const VikunjaMyTasksWidget: React.FC = () => {
return (
<WidgetCard
title="Meine Aufgaben"
icon={<AssignmentInd color="disabled" />}
icon={<AssignmentInd />}
isEmpty
emptyMessage="Vikunja nicht eingerichtet"
/>
@@ -107,7 +107,7 @@ const VikunjaMyTasksWidget: React.FC = () => {
return (
<WidgetCard
title="Meine Aufgaben"
icon={<AssignmentInd color="primary" />}
icon={<AssignmentInd />}
isError
errorMessage="Vikunja nicht erreichbar"
/>
@@ -115,13 +115,13 @@ const VikunjaMyTasksWidget: React.FC = () => {
}
const titleAction = !isLoading && !isError && tasks.length > 0 ? (
<Chip label={animatedTaskCount} size="small" color="primary" />
<Chip label={animatedTaskCount} size="small" />
) : undefined;
return (
<ListCard
title="Meine Aufgaben"
icon={<AssignmentInd color="primary" />}
icon={<AssignmentInd />}
action={titleAction}
items={tasks}
renderItem={(task) => (

View File

@@ -62,7 +62,7 @@ const VikunjaQuickAddWidget: React.FC = () => {
return (
<WidgetCard
title="Aufgabe erstellen"
icon={<AddTask color="disabled" />}
icon={<AddTask />}
isEmpty
emptyMessage="Vikunja nicht eingerichtet"
/>
@@ -73,7 +73,7 @@ const VikunjaQuickAddWidget: React.FC = () => {
return (
<WidgetCard
title="Aufgabe erstellen"
icon={<AddTask color="primary" />}
icon={<AddTask />}
isLoading
skeleton={<FormSkeleton />}
/>
@@ -83,7 +83,7 @@ const VikunjaQuickAddWidget: React.FC = () => {
return (
<FormCard
title="Aufgabe erstellen"
icon={<AddTask color="primary" />}
icon={<AddTask />}
isSubmitting={mutation.isPending}
onSubmit={handleSubmit}
submitLabel="Erstellen"

View File

@@ -3,16 +3,16 @@ import {
Alert,
AlertTitle,
Box,
Card,
CardContent,
CircularProgress,
Typography,
} from '@mui/material';
import HandymanIcon from '@mui/icons-material/Handyman';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { equipmentApi } from '../../services/equipment';
import { useCountUp } from '../../hooks/useCountUp';
import type { EquipmentStats } from '../../types/equipment.types';
import { WidgetCard } from '../templates/WidgetCard';
import { StatSkeleton } from '../templates/SkeletonPresets';
interface EquipmentDashboardCardProps {
hideWhenEmpty?: boolean;
@@ -30,112 +30,72 @@ const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({
const animatedReady = useCountUp(stats?.einsatzbereit ?? 0);
const animatedTotal = useCountUp(stats?.total ?? 0);
if (isLoading) {
return (
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Ausrüstungsstatus wird geladen...
</Typography>
</CardContent>
</Card>
);
}
const hasCritical = stats
? stats.wichtigNichtBereit > 0 || stats.inspectionsOverdue > 0 || stats.beschaedigt > 0
: false;
const hasWarning = stats
? stats.inspectionsDue > 0 || stats.inWartung > 0 || stats.ausserDienst > 0
: false;
const allGood = stats ? stats.einsatzbereit === stats.total && !hasCritical && !hasWarning : false;
if (isError) {
return (
<Card>
<CardContent>
<Typography variant="body2" color="error">
Ausrüstungsstatus konnte nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
if (!stats) return null;
const hasCritical =
stats.wichtigNichtBereit > 0 || stats.inspectionsOverdue > 0 || stats.beschaedigt > 0;
const hasWarning =
stats.inspectionsDue > 0 || stats.inWartung > 0 || stats.ausserDienst > 0;
const allGood = stats.einsatzbereit === stats.total && !hasCritical && !hasWarning;
if (hideWhenEmpty && allGood) return null;
if (!isLoading && !isError && stats && hideWhenEmpty && allGood) return null;
return (
<Card sx={{ height: '100%', cursor: 'pointer' }} onClick={() => navigate('/ausruestung')}>
<CardContent>
<Typography variant="h6" gutterBottom>
Ausrüstung
</Typography>
<WidgetCard
title="Ausrüstung"
icon={<HandymanIcon />}
isLoading={isLoading}
isError={isError}
errorMessage="Ausrüstungsstatus konnte nicht geladen werden."
skeleton={<StatSkeleton />}
onClick={() => navigate('/ausruestung')}
>
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
{animatedReady}/{animatedTotal}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
einsatzbereit
</Typography>
{/* Main metric */}
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
{animatedReady}/{animatedTotal}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
einsatzbereit
</Typography>
{(hasCritical || hasWarning) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{hasCritical && stats && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Kritisch</AlertTitle>
{stats.wichtigNichtBereit > 0 && (
<Typography variant="body2">{stats.wichtigNichtBereit} wichtige Teile nicht einsatzbereit</Typography>
)}
{stats.inspectionsOverdue > 0 && (
<Typography variant="body2">{stats.inspectionsOverdue} Prüfung{stats.inspectionsOverdue !== 1 ? 'en' : ''} überfällig</Typography>
)}
{stats.beschaedigt > 0 && (
<Typography variant="body2">{stats.beschaedigt} beschädigt</Typography>
)}
</Alert>
)}
{hasWarning && stats && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Achtung</AlertTitle>
{stats.inspectionsDue > 0 && (
<Typography variant="body2">{stats.inspectionsDue} Prüfung{stats.inspectionsDue !== 1 ? 'en' : ''} bald fällig</Typography>
)}
{stats.inWartung > 0 && (
<Typography variant="body2">{stats.inWartung} in Wartung</Typography>
)}
{stats.ausserDienst > 0 && (
<Typography variant="body2">{stats.ausserDienst} außer Dienst</Typography>
)}
</Alert>
)}
</Box>
)}
{/* Alerts — consolidated counts only */}
{(hasCritical || hasWarning) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{hasCritical && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Kritisch</AlertTitle>
{stats.wichtigNichtBereit > 0 && (
<Typography variant="body2">
{stats.wichtigNichtBereit} wichtige Teile nicht einsatzbereit
</Typography>
)}
{stats.inspectionsOverdue > 0 && (
<Typography variant="body2">
{stats.inspectionsOverdue} Prüfung{stats.inspectionsOverdue !== 1 ? 'en' : ''} überfällig
</Typography>
)}
{stats.beschaedigt > 0 && (
<Typography variant="body2">
{stats.beschaedigt} beschädigt
</Typography>
)}
</Alert>
)}
{hasWarning && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Achtung</AlertTitle>
{stats.inspectionsDue > 0 && (
<Typography variant="body2">
{stats.inspectionsDue} Prüfung{stats.inspectionsDue !== 1 ? 'en' : ''} bald fällig
</Typography>
)}
{stats.inWartung > 0 && (
<Typography variant="body2">
{stats.inWartung} in Wartung
</Typography>
)}
{stats.ausserDienst > 0 && (
<Typography variant="body2">
{stats.ausserDienst} außer Dienst
</Typography>
)}
</Alert>
)}
</Box>
)}
{/* All good message */}
{allGood && (
<Typography variant="body2" color="success.main">
Alle Ausrüstung einsatzbereit
</Typography>
)}
</CardContent>
</Card>
{allGood && (
<Typography variant="body2" color="success.main">
Alle Ausrüstung einsatzbereit
</Typography>
)}
</WidgetCard>
);
};

View File

@@ -3,11 +3,9 @@ import {
Alert,
AlertTitle,
Box,
Card,
CardContent,
CircularProgress,
Typography,
} from '@mui/material';
import FireTruckIcon from '@mui/icons-material/FireTruck';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { vehiclesApi } from '../../services/vehicles';
@@ -15,6 +13,8 @@ import { equipmentApi } from '../../services/equipment';
import { useCountUp } from '../../hooks/useCountUp';
import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types';
import type { VehicleEquipmentWarning } from '../../types/equipment.types';
import { WidgetCard } from '../templates/WidgetCard';
import { StatSkeleton } from '../templates/SkeletonPresets';
interface VehicleDashboardCardProps {
hideWhenEmpty?: boolean;
@@ -44,107 +44,85 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
const loading = statsLoading || alertsLoading || warningsLoading;
if (loading) {
return (
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Fahrzeugstatus wird geladen...
</Typography>
</CardContent>
</Card>
);
if (!loading && !statsError && stats) {
const overdueAlerts = alerts.filter((a) => a.tage < 0);
const hasConcerns =
overdueAlerts.length > 0 ||
stats.ausserDienst > 0 ||
equipmentWarnings.length > 0;
const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
if (hideWhenEmpty && allGood) return null;
}
if (statsError) {
return (
<Card>
<CardContent>
<Typography variant="body2" color="error">
Fahrzeugstatus konnte nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
if (!stats) return null;
const overdueAlerts = alerts.filter((a) => a.tage < 0);
const upcomingAlerts = alerts.filter((a) => a.tage >= 0 && a.tage <= 30);
const hasConcerns =
overdueAlerts.length > 0 ||
upcomingAlerts.length > 0 ||
stats.ausserDienst > 0 ||
(stats?.ausserDienst ?? 0) > 0 ||
equipmentWarnings.length > 0;
const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
if (hideWhenEmpty && allGood) return null;
const allGood = stats ? stats.einsatzbereit === stats.total && !hasConcerns : false;
return (
<Card sx={{ height: '100%', cursor: 'pointer' }} onClick={() => navigate('/fahrzeuge')}>
<CardContent>
<Typography variant="h6" gutterBottom>
Fahrzeuge
</Typography>
<WidgetCard
title="Fahrzeuge"
icon={<FireTruckIcon />}
isLoading={loading}
isError={statsError}
errorMessage="Fahrzeugstatus konnte nicht geladen werden."
skeleton={<StatSkeleton />}
onClick={() => navigate('/fahrzeuge')}
>
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
{animatedReady}/{animatedTotal}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
einsatzbereit
</Typography>
{/* Main metric */}
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
{animatedReady}/{animatedTotal}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
einsatzbereit
</Typography>
{hasConcerns && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{overdueAlerts.length > 0 && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Überfällig</AlertTitle>
<Typography variant="body2">
{overdueAlerts.length} Prüfung{overdueAlerts.length !== 1 ? 'en' : ''} überfällig
</Typography>
</Alert>
)}
{(stats?.ausserDienst ?? 0) > 0 && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Außer Dienst</AlertTitle>
<Typography variant="body2">
{stats!.ausserDienst} Fahrzeug{stats!.ausserDienst !== 1 ? 'e' : ''} außer Dienst
</Typography>
</Alert>
)}
{upcomingAlerts.length > 0 && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Bald fällig</AlertTitle>
<Typography variant="body2">
{upcomingAlerts.length} Prüfung{upcomingAlerts.length !== 1 ? 'en' : ''} bald fällig
</Typography>
</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>
)}
{/* Concerns list — using Alert components for consistent warning styling */}
{hasConcerns && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{overdueAlerts.length > 0 && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Überfällig</AlertTitle>
<Typography variant="body2">
{overdueAlerts.length} Prüfung{overdueAlerts.length !== 1 ? 'en' : ''} überfällig
</Typography>
</Alert>
)}
{stats.ausserDienst > 0 && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Außer Dienst</AlertTitle>
<Typography variant="body2">
{stats.ausserDienst} Fahrzeug{stats.ausserDienst !== 1 ? 'e' : ''} außer Dienst
</Typography>
</Alert>
)}
{upcomingAlerts.length > 0 && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Bald fällig</AlertTitle>
<Typography variant="body2">
{upcomingAlerts.length} Prüfung{upcomingAlerts.length !== 1 ? 'en' : ''} bald fällig
</Typography>
</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>
)}
{/* All good message */}
{allGood && (
<Typography variant="body2" color="success.main">
Alle Fahrzeuge einsatzbereit
</Typography>
)}
</CardContent>
</Card>
{allGood && (
<Typography variant="body2" color="success.main">
Alle Fahrzeuge einsatzbereit
</Typography>
)}
</WidgetCard>
);
};

View File

@@ -74,7 +74,7 @@ import type {
Kategorie,
Transaktion, TransaktionFormData, TransaktionFilters,
TransaktionTyp,
TransaktionStatus,
AusgabenTyp,
WiederkehrendBuchung, WiederkehrendFormData,
WiederkehrendIntervall,
@@ -83,14 +83,11 @@ import type {
TransferFormData,
} from '../types/buchhaltung.types';
import {
TRANSAKTION_STATUS_LABELS,
TRANSAKTION_STATUS_COLORS,
TRANSAKTION_TYP_LABELS,
INTERVALL_LABELS,
KONTO_ART_LABELS,
} from '../types/buchhaltung.types';
import { StatusChip } from '../components/templates';
// ─── helpers ───────────────────────────────────────────────────────────────────

View File

@@ -181,12 +181,21 @@ function Dashboard() {
for (const k of (groupKeys as string[])) allSavedKeys.add(k);
}
// Widgets in custom groups take priority — don't let built-in groups claim them
const customGroupKeys = new Set<string>();
if (preferences.customGroups) {
for (const cg of preferences.customGroups as { name: string; title: string }[]) {
const cgOrder = preferences.widgetOrder[cg.name] as string[] | undefined;
if (cgOrder) { for (const k of cgOrder) customGroupKeys.add(k); }
}
}
for (const group of Object.keys(DEFAULT_ORDER)) {
if (preferences.widgetOrder[group]) {
// Merge: saved order first, then any new widgets not in saved order
// Merge: saved order first (excluding custom-group widgets), then new widgets
const saved = preferences.widgetOrder[group] as string[];
const allKeys = DEFAULT_ORDER[group];
const ordered = saved.filter((k: string) => allKeys.includes(k));
const ordered = saved.filter((k: string) => allKeys.includes(k) && !customGroupKeys.has(k));
const remaining = allKeys.filter((k) => !allSavedKeys.has(k));
merged[group] = [...ordered, ...remaining];
}
@@ -377,7 +386,9 @@ function Dashboard() {
if (keys.includes(widgetKey)) { targetGroup = group; break; }
}
if (!updatedOrder[targetGroup]) updatedOrder[targetGroup] = [];
updatedOrder[targetGroup].push(widgetKey);
if (!updatedOrder[targetGroup].includes(widgetKey)) {
updatedOrder[targetGroup].push(widgetKey);
}
}
const updatedCustomGroups = localCustomGroups.filter((g) => g.name !== groupName);