widget icon rework, widget grouping rework
This commit is contained in:
@@ -3,16 +3,16 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CircularProgress,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import AirIcon from '@mui/icons-material/Air';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { atemschutzApi } from '../../services/atemschutz';
|
import { atemschutzApi } from '../../services/atemschutz';
|
||||||
import { useCountUp } from '../../hooks/useCountUp';
|
import { useCountUp } from '../../hooks/useCountUp';
|
||||||
import type { AtemschutzStats } from '../../types/atemschutz.types';
|
import type { AtemschutzStats } from '../../types/atemschutz.types';
|
||||||
|
import { WidgetCard } from '../templates/WidgetCard';
|
||||||
|
import { StatSkeleton } from '../templates/SkeletonPresets';
|
||||||
|
|
||||||
interface AtemschutzDashboardCardProps {
|
interface AtemschutzDashboardCardProps {
|
||||||
hideWhenEmpty?: boolean;
|
hideWhenEmpty?: boolean;
|
||||||
@@ -30,51 +30,26 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
|
|||||||
const animatedReady = useCountUp(stats?.einsatzbereit ?? 0);
|
const animatedReady = useCountUp(stats?.einsatzbereit ?? 0);
|
||||||
const animatedTotal = useCountUp(stats?.total ?? 0);
|
const animatedTotal = useCountUp(stats?.total ?? 0);
|
||||||
|
|
||||||
if (isLoading) {
|
const hasConcerns = stats
|
||||||
return (
|
? stats.untersuchungAbgelaufen > 0 ||
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.leistungstestAbgelaufen > 0 ||
|
||||||
stats.untersuchungBaldFaellig > 0 ||
|
stats.untersuchungBaldFaellig > 0 ||
|
||||||
stats.leistungstestBaldFaellig > 0;
|
stats.leistungstestBaldFaellig > 0
|
||||||
|
: false;
|
||||||
|
const allGood = stats ? stats.einsatzbereit === stats.total && !hasConcerns : false;
|
||||||
|
|
||||||
const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
|
if (!isLoading && !isError && stats && hideWhenEmpty && allGood) return null;
|
||||||
|
|
||||||
if (hideWhenEmpty && allGood) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ height: '100%', cursor: 'pointer' }} onClick={() => navigate('/atemschutz')}>
|
<WidgetCard
|
||||||
<CardContent>
|
title="Atemschutz"
|
||||||
<Typography variant="h6" gutterBottom>
|
icon={<AirIcon />}
|
||||||
Atemschutz
|
isLoading={isLoading}
|
||||||
</Typography>
|
isError={isError}
|
||||||
|
errorMessage="Atemschutzstatus konnte nicht geladen werden."
|
||||||
{/* Main metric */}
|
skeleton={<StatSkeleton />}
|
||||||
|
onClick={() => navigate('/atemschutz')}
|
||||||
|
>
|
||||||
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
||||||
{animatedReady}/{animatedTotal}
|
{animatedReady}/{animatedTotal}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -82,8 +57,7 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
|
|||||||
einsatzbereit
|
einsatzbereit
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Concerns list — using Alert components for consistent warning styling */}
|
{hasConcerns && stats && (
|
||||||
{hasConcerns && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
||||||
{(stats.untersuchungAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0) && (
|
{(stats.untersuchungAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0) && (
|
||||||
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
||||||
@@ -118,14 +92,12 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All good message */}
|
|
||||||
{allGood && (
|
{allGood && (
|
||||||
<Typography variant="body2" color="success.main">
|
<Typography variant="body2" color="success.main">
|
||||||
Alle Atemschutzträger einsatzbereit
|
Alle Atemschutzträger einsatzbereit
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</WidgetCard>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function AdminStatusWidget() {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Service Status"
|
title="Service Status"
|
||||||
icon={<MonitorHeartOutlined color={color} />}
|
icon={<MonitorHeartOutlined />}
|
||||||
isLoading={!data}
|
isLoading={!data}
|
||||||
skeleton={<StatSkeleton />}
|
skeleton={<StatSkeleton />}
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => navigate('/admin')}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function AusruestungsanfrageWidget() {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Interne Bestellungen"
|
title="Interne Bestellungen"
|
||||||
icon={<Build fontSize="small" color="action" />}
|
icon={<Build fontSize="small" />}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skeleton={<ChipListSkeleton />}
|
skeleton={<ChipListSkeleton />}
|
||||||
isError={isError || (!isLoading && !overview)}
|
isError={isError || (!isLoading && !overview)}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function BannerWidget() {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Mitteilungen"
|
title="Mitteilungen"
|
||||||
icon={<Campaign color="primary" />}
|
icon={<Campaign />}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
{widgetBanners.map(banner => (
|
{widgetBanners.map(banner => (
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function BestellungenWidget() {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Bestellungen"
|
title="Bestellungen"
|
||||||
icon={<LocalShipping fontSize="small" color="action" />}
|
icon={<LocalShipping fontSize="small" />}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skeleton={<ChipListSkeleton />}
|
skeleton={<ChipListSkeleton />}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const BookStackRecentWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Wissen — Neueste Seiten"
|
title="Wissen — Neueste Seiten"
|
||||||
icon={<MenuBook color="disabled" />}
|
icon={<MenuBook />}
|
||||||
isEmpty
|
isEmpty
|
||||||
emptyMessage="BookStack nicht eingerichtet"
|
emptyMessage="BookStack nicht eingerichtet"
|
||||||
/>
|
/>
|
||||||
@@ -87,7 +87,7 @@ const BookStackRecentWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Wissen — Neueste Seiten"
|
title="Wissen — Neueste Seiten"
|
||||||
icon={<MenuBook color="primary" />}
|
icon={<MenuBook />}
|
||||||
isError
|
isError
|
||||||
errorMessage="BookStack nicht erreichbar"
|
errorMessage="BookStack nicht erreichbar"
|
||||||
/>
|
/>
|
||||||
@@ -97,7 +97,7 @@ const BookStackRecentWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<ListCard
|
<ListCard
|
||||||
title="Wissen — Neueste Seiten"
|
title="Wissen — Neueste Seiten"
|
||||||
icon={<MenuBook color="primary" />}
|
icon={<MenuBook />}
|
||||||
items={pages}
|
items={pages}
|
||||||
renderItem={(page) => <PageRow key={page.id} page={page} />}
|
renderItem={(page) => <PageRow key={page.id} page={page} />}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const BookStackSearchWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Wissen — Suche"
|
title="Wissen — Suche"
|
||||||
icon={<MenuBook color="primary" />}
|
icon={<MenuBook />}
|
||||||
isLoading
|
isLoading
|
||||||
skeleton={<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} animation="wave" />}
|
skeleton={<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} animation="wave" />}
|
||||||
/>
|
/>
|
||||||
@@ -133,7 +133,7 @@ const BookStackSearchWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Wissen — Suche"
|
title="Wissen — Suche"
|
||||||
icon={<MenuBook color="disabled" />}
|
icon={<MenuBook />}
|
||||||
isEmpty
|
isEmpty
|
||||||
emptyMessage="BookStack nicht eingerichtet"
|
emptyMessage="BookStack nicht eingerichtet"
|
||||||
/>
|
/>
|
||||||
@@ -143,7 +143,7 @@ const BookStackSearchWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Wissen — Suche"
|
title="Wissen — Suche"
|
||||||
icon={<MenuBook color="primary" />}
|
icon={<MenuBook />}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function BuchhaltungWidget() {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Buchhaltung"
|
title="Buchhaltung"
|
||||||
icon={<AccountBalance fontSize="small" color="action" />}
|
icon={<AccountBalance fontSize="small" />}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skeleton={<ChipListSkeleton />}
|
skeleton={<ChipListSkeleton />}
|
||||||
isError={isError || (!isLoading && !activeJahr)}
|
isError={isError || (!isLoading && !activeJahr)}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function ChecklistWidget() {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Checklisten"
|
title="Checklisten"
|
||||||
icon={<AssignmentTurnedIn fontSize="small" color="action" />}
|
icon={<AssignmentTurnedIn fontSize="small" />}
|
||||||
action={titleAction}
|
action={titleAction}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skeleton={<ChipListSkeleton />}
|
skeleton={<ChipListSkeleton />}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ const EventQuickAddWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Veranstaltung"
|
title="Veranstaltung"
|
||||||
icon={<CalendarMonth color="primary" />}
|
icon={<CalendarMonth />}
|
||||||
isSubmitting={mutation.isPending}
|
isSubmitting={mutation.isPending}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel="Erstellen"
|
submitLabel="Erstellen"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function IssueOverviewWidget() {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Issues"
|
title="Issues"
|
||||||
icon={<BugReport fontSize="small" color="action" />}
|
icon={<BugReport fontSize="small" />}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skeleton={<ChipListSkeleton />}
|
skeleton={<ChipListSkeleton />}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const IssueQuickAddWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Issue melden"
|
title="Issue melden"
|
||||||
icon={<BugReport color="primary" />}
|
icon={<BugReport />}
|
||||||
isLoading
|
isLoading
|
||||||
skeleton={<FormSkeleton />}
|
skeleton={<FormSkeleton />}
|
||||||
/>
|
/>
|
||||||
@@ -76,7 +76,7 @@ const IssueQuickAddWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Issue melden"
|
title="Issue melden"
|
||||||
icon={<BugReport color="primary" />}
|
icon={<BugReport />}
|
||||||
isSubmitting={mutation.isPending}
|
isSubmitting={mutation.isPending}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel="Melden"
|
submitLabel="Melden"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function LinksWidget({ collection }: LinksWidgetProps) {
|
|||||||
return (
|
return (
|
||||||
<ListCard
|
<ListCard
|
||||||
title={collection.name}
|
title={collection.name}
|
||||||
icon={<LinkIcon color="primary" />}
|
icon={<LinkIcon />}
|
||||||
items={collection.links}
|
items={collection.links}
|
||||||
renderItem={(link) => (
|
renderItem={(link) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const UpcomingEventsWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Nächste Termine"
|
title="Nächste Termine"
|
||||||
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
|
icon={<CalendarMonthIcon fontSize="small" />}
|
||||||
isError
|
isError
|
||||||
errorMessage="Termine konnten nicht geladen werden."
|
errorMessage="Termine konnten nicht geladen werden."
|
||||||
/>
|
/>
|
||||||
@@ -148,7 +148,7 @@ const UpcomingEventsWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<ListCard
|
<ListCard
|
||||||
title="Nächste Termine"
|
title="Nächste Termine"
|
||||||
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
|
icon={<CalendarMonthIcon fontSize="small" />}
|
||||||
items={entries}
|
items={entries}
|
||||||
renderItem={(entry) => (
|
renderItem={(entry) => (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const VehicleBookingListWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Nächste Fahrzeugbuchungen"
|
title="Nächste Fahrzeugbuchungen"
|
||||||
icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
|
icon={<DirectionsCarIcon fontSize="small" />}
|
||||||
isError
|
isError
|
||||||
errorMessage="Buchungen konnten nicht geladen werden."
|
errorMessage="Buchungen konnten nicht geladen werden."
|
||||||
/>
|
/>
|
||||||
@@ -66,7 +66,7 @@ const VehicleBookingListWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<ListCard
|
<ListCard
|
||||||
title="Nächste Fahrzeugbuchungen"
|
title="Nächste Fahrzeugbuchungen"
|
||||||
icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
|
icon={<DirectionsCarIcon fontSize="small" />}
|
||||||
items={items}
|
items={items}
|
||||||
renderItem={(booking) => {
|
renderItem={(booking) => {
|
||||||
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';
|
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Fahrzeugbuchung"
|
title="Fahrzeugbuchung"
|
||||||
icon={<DirectionsCar color="primary" />}
|
icon={<DirectionsCar />}
|
||||||
isLoading
|
isLoading
|
||||||
skeleton={<FormSkeleton />}
|
skeleton={<FormSkeleton />}
|
||||||
/>
|
/>
|
||||||
@@ -105,7 +105,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Fahrzeugbuchung"
|
title="Fahrzeugbuchung"
|
||||||
icon={<DirectionsCar color="primary" />}
|
icon={<DirectionsCar />}
|
||||||
isSubmitting={mutation.isPending}
|
isSubmitting={mutation.isPending}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel="Erstellen"
|
submitLabel="Erstellen"
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const VikunjaMyTasksWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Meine Aufgaben"
|
title="Meine Aufgaben"
|
||||||
icon={<AssignmentInd color="disabled" />}
|
icon={<AssignmentInd />}
|
||||||
isEmpty
|
isEmpty
|
||||||
emptyMessage="Vikunja nicht eingerichtet"
|
emptyMessage="Vikunja nicht eingerichtet"
|
||||||
/>
|
/>
|
||||||
@@ -107,7 +107,7 @@ const VikunjaMyTasksWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Meine Aufgaben"
|
title="Meine Aufgaben"
|
||||||
icon={<AssignmentInd color="primary" />}
|
icon={<AssignmentInd />}
|
||||||
isError
|
isError
|
||||||
errorMessage="Vikunja nicht erreichbar"
|
errorMessage="Vikunja nicht erreichbar"
|
||||||
/>
|
/>
|
||||||
@@ -115,13 +115,13 @@ const VikunjaMyTasksWidget: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const titleAction = !isLoading && !isError && tasks.length > 0 ? (
|
const titleAction = !isLoading && !isError && tasks.length > 0 ? (
|
||||||
<Chip label={animatedTaskCount} size="small" color="primary" />
|
<Chip label={animatedTaskCount} size="small" />
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListCard
|
<ListCard
|
||||||
title="Meine Aufgaben"
|
title="Meine Aufgaben"
|
||||||
icon={<AssignmentInd color="primary" />}
|
icon={<AssignmentInd />}
|
||||||
action={titleAction}
|
action={titleAction}
|
||||||
items={tasks}
|
items={tasks}
|
||||||
renderItem={(task) => (
|
renderItem={(task) => (
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const VikunjaQuickAddWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Aufgabe erstellen"
|
title="Aufgabe erstellen"
|
||||||
icon={<AddTask color="disabled" />}
|
icon={<AddTask />}
|
||||||
isEmpty
|
isEmpty
|
||||||
emptyMessage="Vikunja nicht eingerichtet"
|
emptyMessage="Vikunja nicht eingerichtet"
|
||||||
/>
|
/>
|
||||||
@@ -73,7 +73,7 @@ const VikunjaQuickAddWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Aufgabe erstellen"
|
title="Aufgabe erstellen"
|
||||||
icon={<AddTask color="primary" />}
|
icon={<AddTask />}
|
||||||
isLoading
|
isLoading
|
||||||
skeleton={<FormSkeleton />}
|
skeleton={<FormSkeleton />}
|
||||||
/>
|
/>
|
||||||
@@ -83,7 +83,7 @@ const VikunjaQuickAddWidget: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Aufgabe erstellen"
|
title="Aufgabe erstellen"
|
||||||
icon={<AddTask color="primary" />}
|
icon={<AddTask />}
|
||||||
isSubmitting={mutation.isPending}
|
isSubmitting={mutation.isPending}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel="Erstellen"
|
submitLabel="Erstellen"
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CircularProgress,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import HandymanIcon from '@mui/icons-material/Handyman';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { equipmentApi } from '../../services/equipment';
|
import { equipmentApi } from '../../services/equipment';
|
||||||
import { useCountUp } from '../../hooks/useCountUp';
|
import { useCountUp } from '../../hooks/useCountUp';
|
||||||
import type { EquipmentStats } from '../../types/equipment.types';
|
import type { EquipmentStats } from '../../types/equipment.types';
|
||||||
|
import { WidgetCard } from '../templates/WidgetCard';
|
||||||
|
import { StatSkeleton } from '../templates/SkeletonPresets';
|
||||||
|
|
||||||
interface EquipmentDashboardCardProps {
|
interface EquipmentDashboardCardProps {
|
||||||
hideWhenEmpty?: boolean;
|
hideWhenEmpty?: boolean;
|
||||||
@@ -30,51 +30,26 @@ const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({
|
|||||||
const animatedReady = useCountUp(stats?.einsatzbereit ?? 0);
|
const animatedReady = useCountUp(stats?.einsatzbereit ?? 0);
|
||||||
const animatedTotal = useCountUp(stats?.total ?? 0);
|
const animatedTotal = useCountUp(stats?.total ?? 0);
|
||||||
|
|
||||||
if (isLoading) {
|
const hasCritical = stats
|
||||||
return (
|
? stats.wichtigNichtBereit > 0 || stats.inspectionsOverdue > 0 || stats.beschaedigt > 0
|
||||||
<Card>
|
: false;
|
||||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
|
const hasWarning = stats
|
||||||
<CircularProgress size={16} />
|
? stats.inspectionsDue > 0 || stats.inWartung > 0 || stats.ausserDienst > 0
|
||||||
<Typography variant="body2" color="text.secondary">
|
: false;
|
||||||
Ausrüstungsstatus wird geladen...
|
const allGood = stats ? stats.einsatzbereit === stats.total && !hasCritical && !hasWarning : false;
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
if (!isLoading && !isError && stats && hideWhenEmpty && allGood) return null;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ height: '100%', cursor: 'pointer' }} onClick={() => navigate('/ausruestung')}>
|
<WidgetCard
|
||||||
<CardContent>
|
title="Ausrüstung"
|
||||||
<Typography variant="h6" gutterBottom>
|
icon={<HandymanIcon />}
|
||||||
Ausrüstung
|
isLoading={isLoading}
|
||||||
</Typography>
|
isError={isError}
|
||||||
|
errorMessage="Ausrüstungsstatus konnte nicht geladen werden."
|
||||||
{/* Main metric */}
|
skeleton={<StatSkeleton />}
|
||||||
|
onClick={() => navigate('/ausruestung')}
|
||||||
|
>
|
||||||
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
||||||
{animatedReady}/{animatedTotal}
|
{animatedReady}/{animatedTotal}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -82,60 +57,45 @@ const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({
|
|||||||
einsatzbereit
|
einsatzbereit
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Alerts — consolidated counts only */}
|
|
||||||
{(hasCritical || hasWarning) && (
|
{(hasCritical || hasWarning) && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
||||||
{hasCritical && (
|
{hasCritical && stats && (
|
||||||
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
||||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Kritisch</AlertTitle>
|
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Kritisch</AlertTitle>
|
||||||
{stats.wichtigNichtBereit > 0 && (
|
{stats.wichtigNichtBereit > 0 && (
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">{stats.wichtigNichtBereit} wichtige Teile nicht einsatzbereit</Typography>
|
||||||
{stats.wichtigNichtBereit} wichtige Teile nicht einsatzbereit
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
{stats.inspectionsOverdue > 0 && (
|
{stats.inspectionsOverdue > 0 && (
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">{stats.inspectionsOverdue} Prüfung{stats.inspectionsOverdue !== 1 ? 'en' : ''} überfällig</Typography>
|
||||||
{stats.inspectionsOverdue} Prüfung{stats.inspectionsOverdue !== 1 ? 'en' : ''} überfällig
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
{stats.beschaedigt > 0 && (
|
{stats.beschaedigt > 0 && (
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">{stats.beschaedigt} beschädigt</Typography>
|
||||||
{stats.beschaedigt} beschädigt
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{hasWarning && (
|
{hasWarning && stats && (
|
||||||
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
|
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
|
||||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Achtung</AlertTitle>
|
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Achtung</AlertTitle>
|
||||||
{stats.inspectionsDue > 0 && (
|
{stats.inspectionsDue > 0 && (
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">{stats.inspectionsDue} Prüfung{stats.inspectionsDue !== 1 ? 'en' : ''} bald fällig</Typography>
|
||||||
{stats.inspectionsDue} Prüfung{stats.inspectionsDue !== 1 ? 'en' : ''} bald fällig
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
{stats.inWartung > 0 && (
|
{stats.inWartung > 0 && (
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">{stats.inWartung} in Wartung</Typography>
|
||||||
{stats.inWartung} in Wartung
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
{stats.ausserDienst > 0 && (
|
{stats.ausserDienst > 0 && (
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">{stats.ausserDienst} außer Dienst</Typography>
|
||||||
{stats.ausserDienst} außer Dienst
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All good message */}
|
|
||||||
{allGood && (
|
{allGood && (
|
||||||
<Typography variant="body2" color="success.main">
|
<Typography variant="body2" color="success.main">
|
||||||
Alle Ausrüstung einsatzbereit
|
Alle Ausrüstung einsatzbereit
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</WidgetCard>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CircularProgress,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import FireTruckIcon from '@mui/icons-material/FireTruck';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { vehiclesApi } from '../../services/vehicles';
|
import { vehiclesApi } from '../../services/vehicles';
|
||||||
@@ -15,6 +13,8 @@ import { equipmentApi } from '../../services/equipment';
|
|||||||
import { useCountUp } from '../../hooks/useCountUp';
|
import { useCountUp } from '../../hooks/useCountUp';
|
||||||
import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types';
|
import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types';
|
||||||
import type { VehicleEquipmentWarning } from '../../types/equipment.types';
|
import type { VehicleEquipmentWarning } from '../../types/equipment.types';
|
||||||
|
import { WidgetCard } from '../templates/WidgetCard';
|
||||||
|
import { StatSkeleton } from '../templates/SkeletonPresets';
|
||||||
|
|
||||||
interface VehicleDashboardCardProps {
|
interface VehicleDashboardCardProps {
|
||||||
hideWhenEmpty?: boolean;
|
hideWhenEmpty?: boolean;
|
||||||
@@ -44,54 +44,35 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
|
|||||||
|
|
||||||
const loading = statsLoading || alertsLoading || warningsLoading;
|
const loading = statsLoading || alertsLoading || warningsLoading;
|
||||||
|
|
||||||
if (loading) {
|
if (!loading && !statsError && stats) {
|
||||||
return (
|
const overdueAlerts = alerts.filter((a) => a.tage < 0);
|
||||||
<Card>
|
const hasConcerns =
|
||||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
|
overdueAlerts.length > 0 ||
|
||||||
<CircularProgress size={16} />
|
stats.ausserDienst > 0 ||
|
||||||
<Typography variant="body2" color="text.secondary">
|
equipmentWarnings.length > 0;
|
||||||
Fahrzeugstatus wird geladen...
|
const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
|
||||||
</Typography>
|
if (hideWhenEmpty && allGood) return null;
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 overdueAlerts = alerts.filter((a) => a.tage < 0);
|
||||||
const upcomingAlerts = alerts.filter((a) => a.tage >= 0 && a.tage <= 30);
|
const upcomingAlerts = alerts.filter((a) => a.tage >= 0 && a.tage <= 30);
|
||||||
|
|
||||||
const hasConcerns =
|
const hasConcerns =
|
||||||
overdueAlerts.length > 0 ||
|
overdueAlerts.length > 0 ||
|
||||||
upcomingAlerts.length > 0 ||
|
upcomingAlerts.length > 0 ||
|
||||||
stats.ausserDienst > 0 ||
|
(stats?.ausserDienst ?? 0) > 0 ||
|
||||||
equipmentWarnings.length > 0;
|
equipmentWarnings.length > 0;
|
||||||
|
const allGood = stats ? stats.einsatzbereit === stats.total && !hasConcerns : false;
|
||||||
const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
|
|
||||||
|
|
||||||
if (hideWhenEmpty && allGood) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ height: '100%', cursor: 'pointer' }} onClick={() => navigate('/fahrzeuge')}>
|
<WidgetCard
|
||||||
<CardContent>
|
title="Fahrzeuge"
|
||||||
<Typography variant="h6" gutterBottom>
|
icon={<FireTruckIcon />}
|
||||||
Fahrzeuge
|
isLoading={loading}
|
||||||
</Typography>
|
isError={statsError}
|
||||||
|
errorMessage="Fahrzeugstatus konnte nicht geladen werden."
|
||||||
{/* Main metric */}
|
skeleton={<StatSkeleton />}
|
||||||
|
onClick={() => navigate('/fahrzeuge')}
|
||||||
|
>
|
||||||
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
||||||
{animatedReady}/{animatedTotal}
|
{animatedReady}/{animatedTotal}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -99,7 +80,6 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
|
|||||||
einsatzbereit
|
einsatzbereit
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Concerns list — using Alert components for consistent warning styling */}
|
|
||||||
{hasConcerns && (
|
{hasConcerns && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
||||||
{overdueAlerts.length > 0 && (
|
{overdueAlerts.length > 0 && (
|
||||||
@@ -110,11 +90,11 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{stats.ausserDienst > 0 && (
|
{(stats?.ausserDienst ?? 0) > 0 && (
|
||||||
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
||||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Außer Dienst</AlertTitle>
|
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Außer Dienst</AlertTitle>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
{stats.ausserDienst} Fahrzeug{stats.ausserDienst !== 1 ? 'e' : ''} außer Dienst
|
{stats!.ausserDienst} Fahrzeug{stats!.ausserDienst !== 1 ? 'e' : ''} außer Dienst
|
||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -137,14 +117,12 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All good message */}
|
|
||||||
{allGood && (
|
{allGood && (
|
||||||
<Typography variant="body2" color="success.main">
|
<Typography variant="body2" color="success.main">
|
||||||
Alle Fahrzeuge einsatzbereit
|
Alle Fahrzeuge einsatzbereit
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</WidgetCard>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ import type {
|
|||||||
Kategorie,
|
Kategorie,
|
||||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
Transaktion, TransaktionFormData, TransaktionFilters,
|
||||||
TransaktionTyp,
|
TransaktionTyp,
|
||||||
TransaktionStatus,
|
|
||||||
AusgabenTyp,
|
AusgabenTyp,
|
||||||
WiederkehrendBuchung, WiederkehrendFormData,
|
WiederkehrendBuchung, WiederkehrendFormData,
|
||||||
WiederkehrendIntervall,
|
WiederkehrendIntervall,
|
||||||
@@ -83,14 +83,11 @@ import type {
|
|||||||
TransferFormData,
|
TransferFormData,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
import {
|
import {
|
||||||
TRANSAKTION_STATUS_LABELS,
|
|
||||||
TRANSAKTION_STATUS_COLORS,
|
|
||||||
TRANSAKTION_TYP_LABELS,
|
TRANSAKTION_TYP_LABELS,
|
||||||
INTERVALL_LABELS,
|
INTERVALL_LABELS,
|
||||||
KONTO_ART_LABELS,
|
KONTO_ART_LABELS,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
|
|
||||||
import { StatusChip } from '../components/templates';
|
|
||||||
|
|
||||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -181,12 +181,21 @@ function Dashboard() {
|
|||||||
for (const k of (groupKeys as string[])) allSavedKeys.add(k);
|
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)) {
|
for (const group of Object.keys(DEFAULT_ORDER)) {
|
||||||
if (preferences.widgetOrder[group]) {
|
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 saved = preferences.widgetOrder[group] as string[];
|
||||||
const allKeys = DEFAULT_ORDER[group];
|
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));
|
const remaining = allKeys.filter((k) => !allSavedKeys.has(k));
|
||||||
merged[group] = [...ordered, ...remaining];
|
merged[group] = [...ordered, ...remaining];
|
||||||
}
|
}
|
||||||
@@ -377,8 +386,10 @@ function Dashboard() {
|
|||||||
if (keys.includes(widgetKey)) { targetGroup = group; break; }
|
if (keys.includes(widgetKey)) { targetGroup = group; break; }
|
||||||
}
|
}
|
||||||
if (!updatedOrder[targetGroup]) updatedOrder[targetGroup] = [];
|
if (!updatedOrder[targetGroup]) updatedOrder[targetGroup] = [];
|
||||||
|
if (!updatedOrder[targetGroup].includes(widgetKey)) {
|
||||||
updatedOrder[targetGroup].push(widgetKey);
|
updatedOrder[targetGroup].push(widgetKey);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedCustomGroups = localCustomGroups.filter((g) => g.name !== groupName);
|
const updatedCustomGroups = localCustomGroups.filter((g) => g.name !== groupName);
|
||||||
const updatedGroupOrder = localGroupOrder.filter(n => n !== groupName);
|
const updatedGroupOrder = localGroupOrder.filter(n => n !== groupName);
|
||||||
|
|||||||
Reference in New Issue
Block a user