diff --git a/frontend/src/components/admin/DataManagementTab.tsx b/frontend/src/components/admin/DataManagementTab.tsx index a898a9c..7060b74 100644 --- a/frontend/src/components/admin/DataManagementTab.tsx +++ b/frontend/src/components/admin/DataManagementTab.tsx @@ -1,13 +1,13 @@ import { useState, useCallback } from 'react'; import { Box, Paper, Typography, TextField, Button, Alert, - Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, CircularProgress, Divider, } from '@mui/material'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import { api } from '../../services/api'; import { useNotification } from '../../contexts/NotificationContext'; +import { ConfirmDialog } from '../templates'; interface CleanupSection { key: string; @@ -193,31 +193,21 @@ export default function DataManagementTab() { ); })} - !deleting && setConfirmDialog(null)}> - Daten loeschen? - - - {confirmDialog && ( - <> - {confirmDialog.count} {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus {confirmDialog.label} werden - unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden. - - )} - - - - - - - + setConfirmDialog(null)} + onConfirm={handleDelete} + title="Daten loeschen?" + message={confirmDialog ? ( + <> + {confirmDialog.count} {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus {confirmDialog.label} werden + unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden. + + ) : ''} + confirmLabel="Endgueltig loeschen" + confirmColor="error" + isLoading={deleting} + /> {/* ---- Reset / Truncate sections ---- */} @@ -268,31 +258,21 @@ export default function DataManagementTab() { ); })} - !resetDeleting && setResetConfirmDialog(null)}> - Daten zuruecksetzen? - - - {resetConfirmDialog && ( - <> - {resetConfirmDialog.count} {resetConfirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus {resetConfirmDialog.label} werden - unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht werden. - - )} - - - - - - - + setResetConfirmDialog(null)} + onConfirm={handleResetDelete} + title="Daten zuruecksetzen?" + message={resetConfirmDialog ? ( + <> + {resetConfirmDialog.count} {resetConfirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus {resetConfirmDialog.label} werden + unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht werden. + + ) : ''} + confirmLabel="Endgueltig zuruecksetzen" + confirmColor="error" + isLoading={resetDeleting} + /> ); } diff --git a/frontend/src/components/dashboard/AdminStatusWidget.tsx b/frontend/src/components/dashboard/AdminStatusWidget.tsx index 1858de2..fad5345 100644 --- a/frontend/src/components/dashboard/AdminStatusWidget.tsx +++ b/frontend/src/components/dashboard/AdminStatusWidget.tsx @@ -1,10 +1,12 @@ -import { Card, CardContent, Typography, Box, Chip } from '@mui/material'; +import { Typography, Box, Chip } from '@mui/material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { MonitorHeartOutlined } from '@mui/icons-material'; import { adminApi } from '../../services/admin'; import { useCountUp } from '../../hooks/useCountUp'; import { usePermissionContext } from '../../contexts/PermissionContext'; +import { WidgetCard } from '../templates/WidgetCard'; +import { StatSkeleton } from '../templates/SkeletonPresets'; function AdminStatusWidget() { const { hasPermission } = usePermissionContext(); @@ -29,38 +31,32 @@ function AdminStatusWidget() { const color = allUp ? 'success' : majorityDown ? 'error' : 'warning'; return ( - } + isLoading={!data} + skeleton={} onClick={() => navigate('/admin')} > - - - - - Service Status - - + + + {up} + + + / {total} + + + Services online + + - - - {up} - - - / {total} - - - Services online - - - - - - + + ); } diff --git a/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx b/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx index 0587116..7a086c0 100644 --- a/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx +++ b/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx @@ -1,9 +1,11 @@ -import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; +import { Box, Chip } from '@mui/material'; import { Build } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage'; import type { AusruestungWidgetOverview } from '../../types/ausruestungsanfrage.types'; +import { WidgetCard } from '../templates/WidgetCard'; +import { ChipListSkeleton } from '../templates/SkeletonPresets'; function AusruestungsanfrageWidget() { const navigate = useNavigate(); @@ -15,59 +17,32 @@ function AusruestungsanfrageWidget() { retry: 1, }); - if (isLoading) { - return ( - - - Interne Bestellungen - - - - ); - } - - if (isError || !overview) { - return ( - - - Interne Bestellungen - - Daten konnten nicht geladen werden. - - - - ); - } - - const hasAny = overview.total_count > 0; + const hasAny = (overview?.total_count ?? 0) > 0; return ( - } + isLoading={isLoading} + skeleton={} + isError={isError || (!isLoading && !overview)} + errorMessage="Daten konnten nicht geladen werden." + isEmpty={!isLoading && !isError && !!overview && !hasAny} + emptyMessage="Keine Anfragen vorhanden." onClick={() => navigate('/ausruestungsanfrage')} > - - - Interne Bestellungen - - - {!hasAny ? ( - Keine Anfragen vorhanden. - ) : ( - - {overview.unhandled_count > 0 && ( - - )} - {overview.pending_count > 0 && ( - - )} - {overview.approved_count > 0 && ( - - )} - + + {overview && overview.unhandled_count > 0 && ( + )} - - + {overview && overview.pending_count > 0 && ( + + )} + {overview && overview.approved_count > 0 && ( + + )} + + ); } diff --git a/frontend/src/components/dashboard/BannerWidget.tsx b/frontend/src/components/dashboard/BannerWidget.tsx index 67e7385..4b361ff 100644 --- a/frontend/src/components/dashboard/BannerWidget.tsx +++ b/frontend/src/components/dashboard/BannerWidget.tsx @@ -1,8 +1,9 @@ -import { Card, CardContent, Typography, Box } from '@mui/material'; +import { Typography, Box } from '@mui/material'; import { Campaign } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { bannerApi } from '../../services/banners'; import type { BannerLevel } from '../../types/banner.types'; +import { WidgetCard } from '../templates/WidgetCard'; const SEVERITY_COLOR: Record = { info: '#1976d2', @@ -23,29 +24,26 @@ export default function BannerWidget() { if (widgetBanners.length === 0) return null; return ( - - - - - Mitteilungen - - - {widgetBanners.map(banner => ( - - - {banner.message} - - - ))} - - - + } + > + + {widgetBanners.map(banner => ( + + + {banner.message} + + + ))} + + ); } diff --git a/frontend/src/components/dashboard/BestellungenWidget.tsx b/frontend/src/components/dashboard/BestellungenWidget.tsx index f3e8c03..436d5e6 100644 --- a/frontend/src/components/dashboard/BestellungenWidget.tsx +++ b/frontend/src/components/dashboard/BestellungenWidget.tsx @@ -1,10 +1,12 @@ -import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; +import { Box, Chip } from '@mui/material'; import { LocalShipping } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { bestellungApi } from '../../services/bestellung'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types'; import type { BestellungStatus } from '../../types/bestellung.types'; +import { WidgetCard } from '../templates/WidgetCard'; +import { ChipListSkeleton } from '../templates/SkeletonPresets'; function BestellungenWidget() { const navigate = useNavigate(); @@ -26,54 +28,30 @@ function BestellungenWidget() { .map((s) => ({ status: s, count: openOrders.filter((o) => o.status === s).length })) .filter((s) => s.count > 0); - if (isLoading) { - return ( - - - Bestellungen - - - - ); - } - - if (isError) { - return ( - - - Bestellungen - - Bestellungen konnten nicht geladen werden. - - - - ); - } - return ( - navigate('/bestellungen')}> - - - Bestellungen - - - {statusCounts.length === 0 ? ( - Keine offenen Bestellungen - ) : ( - - {statusCounts.map(({ status, count }) => ( - - ))} - - )} - - + } + isLoading={isLoading} + skeleton={} + isError={isError} + errorMessage="Bestellungen konnten nicht geladen werden." + isEmpty={!isLoading && !isError && statusCounts.length === 0} + emptyMessage="Keine offenen Bestellungen" + onClick={() => navigate('/bestellungen')} + > + + {statusCounts.map(({ status, count }) => ( + + ))} + + ); } diff --git a/frontend/src/components/dashboard/BookStackRecentWidget.tsx b/frontend/src/components/dashboard/BookStackRecentWidget.tsx index cf6cb43..7375e9e 100644 --- a/frontend/src/components/dashboard/BookStackRecentWidget.tsx +++ b/frontend/src/components/dashboard/BookStackRecentWidget.tsx @@ -1,10 +1,7 @@ import React from 'react'; import { - Card, - CardContent, Typography, Box, - Divider, Skeleton, } from '@mui/material'; import { MenuBook } from '@mui/icons-material'; @@ -14,11 +11,10 @@ import { de } from 'date-fns/locale'; import { bookstackApi } from '../../services/bookstack'; import type { BookStackPage } from '../../types/bookstack.types'; import { safeOpenUrl } from '../../utils/safeOpenUrl'; +import { ListCard } from '../templates/ListCard'; +import { WidgetCard } from '../templates/WidgetCard'; -const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({ - page, - showDivider, -}) => { +const PageRow: React.FC<{ page: BookStackPage }> = ({ page }) => { const handleClick = () => { safeOpenUrl(page.url); }; @@ -28,43 +24,39 @@ const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({ : null; return ( - <> - - - - {page.name} - - {page.book && ( - - {page.book.name} - - )} - - {relativeTime && ( - - {relativeTime} + + + + {page.name} + + {page.book && ( + + {page.book.name} )} - {showDivider && } - + {relativeTime && ( + + {relativeTime} + + )} + ); }; @@ -80,77 +72,44 @@ const BookStackRecentWidget: React.FC = () => { const pages = (data?.data ?? []).slice(0, 5); // Only show "nicht eingerichtet" when we got a successful response with configured=false - // (not when the request errored out with 403 etc.) if (data && !configured) { return ( - - - - - - Wissen — Neueste Seiten - - - - BookStack nicht eingerichtet - - - + } + isEmpty + emptyMessage="BookStack nicht eingerichtet" + /> + ); + } + + if (isError) { + return ( + } + isError + errorMessage="BookStack nicht erreichbar" + /> ); } return ( - - - - - - Wissen — Neueste Seiten - + } + items={pages} + renderItem={(page) => } + isLoading={isLoading} + skeletonCount={5} + skeletonItem={ + + + - - {isLoading && ( - - {[1, 2, 3, 4, 5].map((n) => ( - - - - - ))} - - )} - - {isError && ( - - BookStack nicht erreichbar - - )} - - {!isLoading && !isError && pages.length === 0 && ( - - Keine Seiten gefunden - - )} - - {!isLoading && !isError && pages.length > 0 && ( - - {pages.map((page, index) => ( - - ))} - - )} - - + } + emptyMessage="Keine Seiten gefunden" + /> ); }; diff --git a/frontend/src/components/dashboard/BookStackSearchWidget.tsx b/frontend/src/components/dashboard/BookStackSearchWidget.tsx index 7253756..fa0326e 100644 --- a/frontend/src/components/dashboard/BookStackSearchWidget.tsx +++ b/frontend/src/components/dashboard/BookStackSearchWidget.tsx @@ -1,11 +1,8 @@ import React, { useState, useEffect, useRef } from 'react'; import { - Card, - CardContent, Typography, Box, TextField, - Divider, CircularProgress, InputAdornment, Skeleton, @@ -15,6 +12,7 @@ import { useQuery } from '@tanstack/react-query'; import { bookstackApi } from '../../services/bookstack'; import type { BookStackSearchResult } from '../../types/bookstack.types'; import { safeOpenUrl } from '../../utils/safeOpenUrl'; +import { WidgetCard } from '../templates/WidgetCard'; function stripHtml(html: string): string { return html.replace(/<[^>]*>/g, '').trim(); @@ -22,7 +20,7 @@ function stripHtml(html: string): string { const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({ result, - showDivider, + showDivider: _showDivider, }) => { const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : ''; @@ -58,7 +56,6 @@ const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean )} - {showDivider && } ); }; @@ -123,86 +120,64 @@ const BookStackSearchWidget: React.FC = () => { if (configured === undefined) { return ( - - - - - - - - - + } + isLoading + skeleton={} + /> ); } if (configured === false) { return ( - - - - - - Wissen — Suche - - - - BookStack nicht eingerichtet - - - + } + isEmpty + emptyMessage="BookStack nicht eingerichtet" + /> ); } return ( - } > - - - - - Wissen — Suche - + setQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + {searching ? : } + + ), + }} + /> + + {!searching && query.trim() && results.length === 0 && ( + + Keine Ergebnisse für \u201e{query}\u201c + + )} + + {results.length > 0 && ( + + {results.map((result, index) => ( + + ))} - - setQuery(e.target.value)} - InputProps={{ - startAdornment: ( - - {searching ? : } - - ), - }} - /> - - {!searching && query.trim() && results.length === 0 && ( - - Keine Ergebnisse für „{query}" - - )} - - {results.length > 0 && ( - - {results.map((result, index) => ( - - ))} - - )} - - + )} + ); }; diff --git a/frontend/src/components/dashboard/BuchhaltungWidget.tsx b/frontend/src/components/dashboard/BuchhaltungWidget.tsx index 488525c..9ec0f06 100644 --- a/frontend/src/components/dashboard/BuchhaltungWidget.tsx +++ b/frontend/src/components/dashboard/BuchhaltungWidget.tsx @@ -1,8 +1,10 @@ -import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; +import { Typography, Box, Chip } from '@mui/material'; import { AccountBalance } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { buchhaltungApi } from '../../services/buchhaltung'; +import { WidgetCard } from '../templates/WidgetCard'; +import { ChipListSkeleton } from '../templates/SkeletonPresets'; function fmtEur(val: number) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val); @@ -30,76 +32,40 @@ function BuchhaltungWidget() { const isLoading = loadingJahre || (!!activeJahr && loadingStats); - if (isLoading) { - return ( - - - Buchhaltung - - - - ); - } - - if (!activeJahr) { - return ( - navigate('/buchhaltung')}> - - - Buchhaltung - - - Kein aktives Haushaltsjahr - - - ); - } - - if (isError || !stats) { - return ( - navigate('/buchhaltung')}> - - - Buchhaltung - - - Daten konnten nicht geladen werden - - - ); - } - - const overBudgetCount = stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length; + const overBudgetCount = stats ? stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length : 0; return ( - navigate('/buchhaltung')}> - - - Buchhaltung - - - - Einnahmen: - {fmtEur(stats.total_einnahmen)} - - - Ausgaben: - {fmtEur(stats.total_ausgaben)} - - 0 ? 1 : 0 }}> - Saldo: - = 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats.saldo)} - - {overBudgetCount > 0 && ( - 1 ? 'n' : ''} über 80% Budget`} - color="warning" - size="small" - variant="outlined" - /> - )} - - + } + isLoading={isLoading} + skeleton={} + isError={isError || (!isLoading && !activeJahr)} + errorMessage={!activeJahr ? 'Kein aktives Haushaltsjahr' : 'Daten konnten nicht geladen werden'} + isEmpty={!isLoading && !isError && !!activeJahr && !stats} + onClick={() => navigate('/buchhaltung')} + > + + Einnahmen: + {fmtEur(stats?.total_einnahmen ?? 0)} + + + Ausgaben: + {fmtEur(stats?.total_ausgaben ?? 0)} + + 0 ? 1 : 0 }}> + Saldo: + = 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats?.saldo ?? 0)} + + {overBudgetCount > 0 && ( + 1 ? 'n' : ''} über 80% Budget`} + color="warning" + size="small" + variant="outlined" + /> + )} + ); } diff --git a/frontend/src/components/dashboard/ChecklistWidget.tsx b/frontend/src/components/dashboard/ChecklistWidget.tsx index f9b3159..8b6994b 100644 --- a/frontend/src/components/dashboard/ChecklistWidget.tsx +++ b/frontend/src/components/dashboard/ChecklistWidget.tsx @@ -1,8 +1,10 @@ -import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; +import { Typography, Box, Chip } from '@mui/material'; import { AssignmentTurnedIn, Warning } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { checklistenApi } from '../../services/checklisten'; +import { WidgetCard } from '../templates/WidgetCard'; +import { ChipListSkeleton } from '../templates/SkeletonPresets'; function ChecklistWidget() { const navigate = useNavigate(); @@ -16,77 +18,48 @@ function ChecklistWidget() { const overdueItems = overdue ?? []; - if (isLoading) { - return ( - - - Checklisten - - - - ); - } - - if (isError) { - return ( - - - Checklisten - - Checklisten konnten nicht geladen werden. - - - - ); - } + const titleAction = !isLoading && !isError && overdueItems.length > 0 ? ( + } label={overdueItems.length} color="error" size="small" /> + ) : undefined; return ( - navigate('/checklisten')}> - - - - Checklisten - {overdueItems.length > 0 && ( - } - label={overdueItems.length} - color="error" - size="small" - /> - )} - - - - {overdueItems.length === 0 ? ( - Alle Checklisten aktuell - ) : ( - - {overdueItems.slice(0, 5).map((item) => { - const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000); - const targetName = item.fahrzeug_name || item.ausruestung_name || '–'; - return ( - - - {targetName} - - 0 ? `${days}d` : 'heute'}`} - color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'} - size="small" - variant="outlined" - /> - - ); - })} - {overdueItems.length > 5 && ( - - + {overdueItems.length - 5} weitere + } + action={titleAction} + isLoading={isLoading} + skeleton={} + isError={isError} + errorMessage="Checklisten konnten nicht geladen werden." + isEmpty={!isLoading && !isError && overdueItems.length === 0} + emptyMessage="Alle Checklisten aktuell" + onClick={() => navigate('/checklisten')} + > + + {overdueItems.slice(0, 5).map((item) => { + const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000); + const targetName = item.fahrzeug_name || item.ausruestung_name || '–'; + return ( + + + {targetName} - )} - + 0 ? `${days}d` : 'heute'}`} + color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'} + size="small" + variant="outlined" + /> + + ); + })} + {overdueItems.length > 5 && ( + + + {overdueItems.length - 5} weitere + )} - - + + ); } diff --git a/frontend/src/components/dashboard/EventQuickAddWidget.tsx b/frontend/src/components/dashboard/EventQuickAddWidget.tsx index b66f702..4c96daf 100644 --- a/frontend/src/components/dashboard/EventQuickAddWidget.tsx +++ b/frontend/src/components/dashboard/EventQuickAddWidget.tsx @@ -1,13 +1,9 @@ import React, { useState } from 'react'; import { - Card, - CardContent, - Typography, - Box, TextField, - Button, Switch, FormControlLabel, + Typography, } from '@mui/material'; import { CalendarMonth } from '@mui/icons-material'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -15,6 +11,7 @@ import { eventsApi } from '../../services/events'; import type { CreateVeranstaltungInput } from '../../types/events.types'; import { useNotification } from '../../contexts/NotificationContext'; import { usePermissionContext } from '../../contexts/PermissionContext'; +import { FormCard } from '../templates/FormCard'; function toDatetimeLocal(date: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); @@ -112,93 +109,74 @@ const EventQuickAddWidget: React.FC = () => { const datumBisValue = ganztaegig ? datumBis.slice(0, 10) : datumBis; return ( - } + isSubmitting={mutation.isPending} + onSubmit={handleSubmit} + submitLabel="Erstellen" > - - - - Veranstaltung - + setTitel(e.target.value)} + required + inputProps={{ maxLength: 250 }} + /> - - setTitel(e.target.value)} - required - inputProps={{ maxLength: 250 }} - /> + setGanztaegig(e.target.checked)} + size="small" + /> + } + label={Ganztägig} + sx={{ mx: 0 }} + /> - setGanztaegig(e.target.checked)} - size="small" - /> - } - label={Ganztägig} - sx={{ mx: 0 }} - /> + { + const val = e.target.value; + setDatumVon(ganztaegig ? val + 'T00:00' : val); + }} + required + InputLabelProps={{ shrink: true }} + /> - { - const val = e.target.value; - setDatumVon(ganztaegig ? val + 'T00:00' : val); - }} - required - InputLabelProps={{ shrink: true }} - /> + { + const val = e.target.value; + setDatumBis(ganztaegig ? val + 'T00:00' : val); + }} + required + InputLabelProps={{ shrink: true }} + /> - { - const val = e.target.value; - setDatumBis(ganztaegig ? val + 'T00:00' : val); - }} - required - InputLabelProps={{ shrink: true }} - /> - - setBeschreibung(e.target.value)} - multiline - rows={2} - inputProps={{ maxLength: 1000 }} - /> - - - - - + setBeschreibung(e.target.value)} + multiline + rows={2} + inputProps={{ maxLength: 1000 }} + /> + ); }; diff --git a/frontend/src/components/dashboard/IssueOverviewWidget.tsx b/frontend/src/components/dashboard/IssueOverviewWidget.tsx index 8c266da..75d9866 100644 --- a/frontend/src/components/dashboard/IssueOverviewWidget.tsx +++ b/frontend/src/components/dashboard/IssueOverviewWidget.tsx @@ -1,8 +1,10 @@ -import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; +import { Box, Chip } from '@mui/material'; import { BugReport } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { issuesApi } from '../../services/issues'; +import { WidgetCard } from '../templates/WidgetCard'; +import { ChipListSkeleton } from '../templates/SkeletonPresets'; function IssueOverviewWidget() { const navigate = useNavigate(); @@ -14,65 +16,31 @@ function IssueOverviewWidget() { retry: 1, }); - if (isLoading) { - return ( - - - Issues - - - - ); - } - - if (isError) { - return ( - - - Issues - - Issues konnten nicht geladen werden. - - - - ); - } - const visibleCounts = data.filter((s) => s.count > 0); - if (visibleCounts.length === 0) { - return ( - navigate('/issues')}> - - Issues - - - Keine offenen Issues - - - - ); - } - return ( - navigate('/issues')}> - - - Issues - - - - {visibleCounts.map((s) => ( - - ))} - - - + } + isLoading={isLoading} + skeleton={} + isError={isError} + errorMessage="Issues konnten nicht geladen werden." + isEmpty={!isLoading && !isError && visibleCounts.length === 0} + emptyMessage="Keine offenen Issues" + onClick={() => navigate('/issues')} + > + + {visibleCounts.map((s) => ( + + ))} + + ); } diff --git a/frontend/src/components/dashboard/IssueQuickAddWidget.tsx b/frontend/src/components/dashboard/IssueQuickAddWidget.tsx index c629a9c..8eed281 100644 --- a/frontend/src/components/dashboard/IssueQuickAddWidget.tsx +++ b/frontend/src/components/dashboard/IssueQuickAddWidget.tsx @@ -1,22 +1,19 @@ import React, { useState } from 'react'; import { - Card, - CardContent, - Typography, - Box, TextField, - Button, MenuItem, Select, FormControl, InputLabel, - Skeleton, SelectChangeEvent, } from '@mui/material'; import { BugReport } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { issuesApi } from '../../services/issues'; import { useNotification } from '../../contexts/NotificationContext'; +import { FormCard } from '../templates/FormCard'; +import { WidgetCard } from '../templates/WidgetCard'; +import { FormSkeleton } from '../templates/SkeletonPresets'; const PRIO_OPTIONS = [ { value: 'niedrig', label: 'Niedrig' }, @@ -65,83 +62,67 @@ const IssueQuickAddWidget: React.FC = () => { mutation.mutate(); }; + if (typesLoading) { + return ( + } + isLoading + skeleton={} + /> + ); + } + return ( - } + isSubmitting={mutation.isPending} + onSubmit={handleSubmit} + submitLabel="Melden" > - - - - Issue melden - + setTitel(e.target.value)} + inputProps={{ maxLength: 255 }} + autoComplete="off" + /> - {typesLoading ? ( - - - - - - ) : ( - - setTitel(e.target.value)} - inputProps={{ maxLength: 255 }} - autoComplete="off" - /> + + Typ + + - - Typ - - - - - Priorität - - - - - - )} - - + + Priorität + + + ); }; diff --git a/frontend/src/components/dashboard/LinksWidget.tsx b/frontend/src/components/dashboard/LinksWidget.tsx index f72c677..22b5128 100644 --- a/frontend/src/components/dashboard/LinksWidget.tsx +++ b/frontend/src/components/dashboard/LinksWidget.tsx @@ -1,14 +1,9 @@ import { - Card, - CardContent, - Typography, Link, - Stack, - Box, - Divider, } from '@mui/material'; import { Link as LinkIcon, OpenInNew } from '@mui/icons-material'; import type { LinkCollection } from '../../types/config.types'; +import { ListCard } from '../templates/ListCard'; interface LinksWidgetProps { collection: LinkCollection; @@ -16,35 +11,27 @@ interface LinksWidgetProps { function LinksWidget({ collection }: LinksWidgetProps) { return ( - - - - - {collection.name} - - - - {collection.links.map((link, i) => ( - - {link.name} - - - ))} - - - + } + items={collection.links} + renderItem={(link) => ( + + {link.name} + + + )} + /> ); } diff --git a/frontend/src/components/dashboard/PersonalWarningsBanner.tsx b/frontend/src/components/dashboard/PersonalWarningsBanner.tsx index 7f386bf..0080287 100644 --- a/frontend/src/components/dashboard/PersonalWarningsBanner.tsx +++ b/frontend/src/components/dashboard/PersonalWarningsBanner.tsx @@ -3,8 +3,6 @@ import { Alert, AlertTitle, Box, - CircularProgress, - Divider, Link, Typography, } from '@mui/material'; @@ -14,6 +12,7 @@ import { useQuery } from '@tanstack/react-query'; import { atemschutzApi } from '../../services/atemschutz'; import type { User } from '../../types/auth.types'; import type { AtemschutzUebersicht } from '../../types/atemschutz.types'; +import { WidgetCard } from '../templates/WidgetCard'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -84,12 +83,11 @@ const PersonalWarningsBanner: React.FC = ({ user: _ if (isLoading) { return ( - - - - Persönliche Fristen werden geprüft… - - + } + isLoading + /> ); } @@ -115,44 +113,20 @@ const PersonalWarningsBanner: React.FC = ({ user: _ const upcoming = warnings.filter((w) => w.tageRest >= 0); return ( - 0 ? 'error.main' : 'warning.main', - borderRadius: 1, - overflow: 'hidden', - }} - > - {/* Banner header */} - 0 ? 'error.light' : 'warning.light', - }} - > + 0 ? 'error.dark' : 'warning.dark' }} /> - 0 ? 'error.dark' : 'warning.dark', - }} - > - Persönliche Warnungen ({warnings.length}) - - - - - - {/* Warning alerts */} - + } + sx={{ + border: '1px solid', + borderColor: overdue.length > 0 ? 'error.main' : 'warning.main', + }} + > + {overdue.length > 0 && ( Überfällig — Handlungsbedarf @@ -209,7 +183,7 @@ const PersonalWarningsBanner: React.FC = ({ user: _ )} - + ); }; diff --git a/frontend/src/components/dashboard/ServiceCard.tsx b/frontend/src/components/dashboard/ServiceCard.tsx index fc8922d..8046d37 100644 --- a/frontend/src/components/dashboard/ServiceCard.tsx +++ b/frontend/src/components/dashboard/ServiceCard.tsx @@ -1,13 +1,11 @@ import React from 'react'; import { - Card, - CardActionArea, - CardContent, Typography, Box, Chip, } from '@mui/material'; import { SvgIconComponent } from '@mui/icons-material'; +import { WidgetCard } from '../templates/WidgetCard'; interface ServiceCardProps { title: string; @@ -27,79 +25,49 @@ const ServiceCard: React.FC = ({ const isConnected = status === 'connected'; return ( - - - - + - - - - - - - - - {title} - - - - {description} - - - - - - + + } + action={ + + } + onClick={onClick} + > + + {description} + + + + ); }; diff --git a/frontend/src/components/dashboard/StatsCard.tsx b/frontend/src/components/dashboard/StatsCard.tsx index 6c4a081..c6da541 100644 --- a/frontend/src/components/dashboard/StatsCard.tsx +++ b/frontend/src/components/dashboard/StatsCard.tsx @@ -1,74 +1,3 @@ -import React from 'react'; -import { Card, CardContent, Typography, Box } from '@mui/material'; -import { SvgIconComponent } from '@mui/icons-material'; - -interface StatsCardProps { - title: string; - value: string | number; - icon: SvgIconComponent; - color?: string; -} - -const StatsCard: React.FC = ({ - title, - value, - icon: Icon, - color = 'primary.main', -}) => { - return ( - - - - - - {title} - - - {value} - - - - - - - - - - ); -}; - -export default StatsCard; +// Re-export StatCard from templates for backward compatibility +export { StatCard as default } from '../templates/StatCard'; +export type { StatCardProps } from '../templates/StatCard'; diff --git a/frontend/src/components/dashboard/UpcomingEventsWidget.tsx b/frontend/src/components/dashboard/UpcomingEventsWidget.tsx index 1967bae..8e5f32f 100644 --- a/frontend/src/components/dashboard/UpcomingEventsWidget.tsx +++ b/frontend/src/components/dashboard/UpcomingEventsWidget.tsx @@ -1,14 +1,9 @@ import React from 'react'; import { Box, - Card, - CardContent, - CircularProgress, Chip, - Divider, Link, - List, - ListItem, + Skeleton, Typography, } from '@mui/material'; import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material'; @@ -18,6 +13,8 @@ import { trainingApi } from '../../services/training'; import { eventsApi } from '../../services/events'; import type { UebungListItem, UebungTyp } from '../../types/training.types'; import type { VeranstaltungListItem } from '../../types/events.types'; +import { ListCard } from '../templates/ListCard'; +import { WidgetCard } from '../templates/WidgetCard'; // --------------------------------------------------------------------------- // Color map — matches TYP_DOT_COLOR in Kalender.tsx @@ -136,153 +133,110 @@ const UpcomingEventsWidget: React.FC = () => { .slice(0, DISPLAY_LIMIT); }, [trainingItems, eventItems, loading]); - // ── Loading state ───────────────────────────────────────────────────────── - if (loading) { - return ( - - - - - Termine werden geladen... - - - - ); - } - - // ── Error state ─────────────────────────────────────────────────────────── + // ── Render ──────────────────────────────────────────────────────────────── if (error) { return ( - - - - - Nächste Termine - - - Termine konnten nicht geladen werden. - - - + } + isError + errorMessage="Termine konnten nicht geladen werden." + /> ); } - // ── Main render ─────────────────────────────────────────────────────────── return ( - - - {/* Header */} - - - - Nächste Termine - - + } + items={entries} + renderItem={(entry) => ( + + {/* Colored type indicator dot */} + - + {/* Date + title block */} + + + + {formatDateShort(entry.date)} + {!entry.allDay && ( + <> · {formatTime(entry.date.toISOString())} + )} + - {/* Empty state */} - {entries.length === 0 ? ( - - - Keine bevorstehenden Termine + + + + + {entry.title} - ) : ( - - {entries.map((entry, index) => ( - - - {/* Colored type indicator dot */} - - - {/* Date + title block */} - - - - {formatDateShort(entry.date)} - {!entry.allDay && ( - <> · {formatTime(entry.date.toISOString())} - )} - - - - - - - {entry.title} - - - - - {index < entries.length - 1 && ( - - )} - - ))} - - )} - - {/* Footer link */} - + + )} + isLoading={loading} + skeletonCount={5} + skeletonItem={ + + + + + + + + } + emptyMessage="Keine bevorstehenden Termine" + footer={ { Alle Termine - - + } + /> ); }; diff --git a/frontend/src/components/dashboard/VehicleBookingListWidget.tsx b/frontend/src/components/dashboard/VehicleBookingListWidget.tsx index 619477f..e34090a 100644 --- a/frontend/src/components/dashboard/VehicleBookingListWidget.tsx +++ b/frontend/src/components/dashboard/VehicleBookingListWidget.tsx @@ -1,14 +1,9 @@ import React from 'react'; import { Box, - Card, - CardContent, - CircularProgress, Chip, - Divider, Link, - List, - ListItem, + Skeleton, Typography, } from '@mui/material'; import { DirectionsCar as DirectionsCarIcon } from '@mui/icons-material'; @@ -17,6 +12,8 @@ import { useQuery } from '@tanstack/react-query'; import { bookingApi } from '../../services/bookings'; import type { FahrzeugBuchungListItem } from '../../types/booking.types'; import { BUCHUNGS_ART_COLORS, BUCHUNGS_ART_LABELS } from '../../types/booking.types'; +import { ListCard } from '../templates/ListCard'; +import { WidgetCard } from '../templates/WidgetCard'; // --------------------------------------------------------------------------- // Helpers @@ -55,169 +52,125 @@ const VehicleBookingListWidget: React.FC = () => { [rawItems], ); - // ── Loading state ───────────────────────────────────────────────────────── - if (isLoading) { - return ( - - - - - Buchungen werden geladen... - - - - ); - } - - // ── Error state ─────────────────────────────────────────────────────────── if (isError) { return ( - - - - - Nächste Fahrzeugbuchungen - - - Buchungen konnten nicht geladen werden. - - - + } + isError + errorMessage="Buchungen konnten nicht geladen werden." + /> ); } - // ── Main render ─────────────────────────────────────────────────────────── return ( - - - {/* Header */} - - - - Nächste Fahrzeugbuchungen - - + } + items={items} + renderItem={(booking) => { + const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e'; + const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art; + return ( + + {/* Colored type indicator dot */} + - + {/* Date + title block */} + + + + {formatDateShort(booking.beginn)} + {' '}·{' '} + {formatTime(booking.beginn)} + - {/* Empty state */} - {items.length === 0 ? ( - - - Keine bevorstehenden Buchungen - + + + + + {booking.titel} + + + + {booking.fahrzeug_name} + + - ) : ( - - {items.map((booking, index) => { - const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e'; - const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art; - return ( - - - {/* Colored type indicator dot */} - - - {/* Date + title block */} - - - - {formatDateShort(booking.beginn)} - {' '}·{' '} - {formatTime(booking.beginn)} - - - - - - - {booking.titel} - - - - {booking.fahrzeug_name} - - - - - {index < items.length - 1 && ( - - )} - - ); - })} - - )} - - {/* Footer link */} - + ); + }} + isLoading={isLoading} + skeletonCount={5} + skeletonItem={ + + + + + + + + } + emptyMessage="Keine bevorstehenden Buchungen" + footer={ { Alle Buchungen - - + } + /> ); }; diff --git a/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx b/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx index b569c39..2a1c417 100644 --- a/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx +++ b/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx @@ -1,16 +1,10 @@ import React, { useState } from 'react'; import { - Card, - CardContent, - Typography, - Box, TextField, - Button, MenuItem, Select, FormControl, InputLabel, - Skeleton, SelectChangeEvent, } from '@mui/material'; import { DirectionsCar } from '@mui/icons-material'; @@ -19,6 +13,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { bookingApi, fetchVehicles } from '../../services/bookings'; import type { CreateBuchungInput } from '../../types/booking.types'; import { useNotification } from '../../contexts/NotificationContext'; +import { FormCard } from '../templates/FormCard'; +import { WidgetCard } from '../templates/WidgetCard'; +import { FormSkeleton } from '../templates/SkeletonPresets'; function toDatetimeLocal(date: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); @@ -94,98 +91,81 @@ const VehicleBookingQuickAddWidget: React.FC = () => { mutation.mutate(); }; + if (vehiclesLoading) { + return ( + } + isLoading + skeleton={} + /> + ); + } + return ( - } + isSubmitting={mutation.isPending} + onSubmit={handleSubmit} + submitLabel="Erstellen" > - - - - Fahrzeugbuchung - + + Fahrzeug + + - {vehiclesLoading ? ( - - - - - - - ) : ( - - - Fahrzeug - - + setTitel(e.target.value)} + required + inputProps={{ maxLength: 250 }} + /> - setTitel(e.target.value)} - required - inputProps={{ maxLength: 250 }} - /> + setBeginn(v)} + required + /> - setBeginn(v)} - required - /> + setEnde(v)} + required + /> - setEnde(v)} - required - /> - - setBeschreibung(e.target.value)} - multiline - rows={2} - inputProps={{ maxLength: 1000 }} - /> - - - - )} - - + setBeschreibung(e.target.value)} + multiline + rows={2} + inputProps={{ maxLength: 1000 }} + /> + ); }; diff --git a/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx b/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx index 39c34e3..47f04bb 100644 --- a/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx +++ b/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx @@ -1,10 +1,7 @@ import React from 'react'; import { - Card, - CardContent, Typography, Box, - Divider, Skeleton, Chip, } from '@mui/material'; @@ -16,6 +13,8 @@ import { vikunjaApi } from '../../services/vikunja'; import type { VikunjaTask } from '../../types/vikunja.types'; import { safeOpenUrl } from '../../utils/safeOpenUrl'; import { useCountUp } from '../../hooks/useCountUp'; +import { ListCard } from '../templates/ListCard'; +import { WidgetCard } from '../templates/WidgetCard'; const PRIORITY_LABELS: Record = { 0: { label: 'Keine', color: 'default' }, @@ -26,9 +25,8 @@ const PRIORITY_LABELS: Record = ({ +const TaskRow: React.FC<{ task: VikunjaTask; vikunjaUrl: string }> = ({ task, - showDivider, vikunjaUrl, }) => { const handleClick = () => { @@ -42,47 +40,43 @@ const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: s const priority = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0]; return ( - <> - - - - {task.title} - - - {dueDateStr && ( - - {dueDateStr} - - )} - {task.priority > 0 && ( - - )} - + + + + {task.title} + + + {dueDateStr && ( + + {dueDateStr} + + )} + {task.priority > 0 && ( + + )} - {showDivider && } - + ); }; @@ -100,78 +94,49 @@ const VikunjaMyTasksWidget: React.FC = () => { if (!configured) { return ( - - - - - - Meine Aufgaben - - - - Vikunja nicht eingerichtet - - - + } + isEmpty + emptyMessage="Vikunja nicht eingerichtet" + /> ); } + if (isError) { + return ( + } + isError + errorMessage="Vikunja nicht erreichbar" + /> + ); + } + + const titleAction = !isLoading && !isError && tasks.length > 0 ? ( + + ) : undefined; + return ( - - - - - - Meine Aufgaben - - {!isLoading && !isError && tasks.length > 0 && ( - - )} + } + action={titleAction} + items={tasks} + renderItem={(task) => ( + + )} + isLoading={isLoading} + skeletonCount={3} + skeletonItem={ + + + - - {isLoading && ( - - {[1, 2, 3].map((n) => ( - - - - - ))} - - )} - - {isError && ( - - Vikunja nicht erreichbar - - )} - - {!isLoading && !isError && tasks.length === 0 && ( - - Keine offenen Aufgaben - - )} - - {!isLoading && !isError && tasks.length > 0 && ( - - {tasks.map((task, index) => ( - - ))} - - )} - - + } + emptyMessage="Keine offenen Aufgaben" + /> ); }; diff --git a/frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx b/frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx index 84cd3c3..366d3d0 100644 --- a/frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx +++ b/frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx @@ -1,16 +1,10 @@ import React, { useState } from 'react'; import { - Card, - CardContent, - Typography, - Box, TextField, - Button, MenuItem, Select, FormControl, InputLabel, - Skeleton, SelectChangeEvent, } from '@mui/material'; import { AddTask } from '@mui/icons-material'; @@ -18,6 +12,9 @@ import GermanDateField from '../shared/GermanDateField'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { vikunjaApi } from '../../services/vikunja'; import { useNotification } from '../../contexts/NotificationContext'; +import { FormCard } from '../templates/FormCard'; +import { WidgetCard } from '../templates/WidgetCard'; +import { FormSkeleton } from '../templates/SkeletonPresets'; const VikunjaQuickAddWidget: React.FC = () => { const [title, setTitle] = useState(''); @@ -63,92 +60,69 @@ const VikunjaQuickAddWidget: React.FC = () => { if (!configured) { return ( - - - - - - Aufgabe erstellen - - - - Vikunja nicht eingerichtet - - - + } + isEmpty + emptyMessage="Vikunja nicht eingerichtet" + /> + ); + } + + if (projectsLoading) { + return ( + } + isLoading + skeleton={} + /> ); } return ( - } + isSubmitting={mutation.isPending} + onSubmit={handleSubmit} + submitLabel="Erstellen" > - - - - Aufgabe erstellen - + + Projekt + + - {projectsLoading ? ( - - - - - - ) : ( - - - Projekt - - + setTitle(e.target.value)} + inputProps={{ maxLength: 250 }} + /> - setTitle(e.target.value)} - inputProps={{ maxLength: 250 }} - /> - - setDueDate(v)} - /> - - - - )} - - + setDueDate(v)} + /> + ); }; diff --git a/frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx b/frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx index b025529..dad2659 100644 --- a/frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx +++ b/frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { - Alert, Box, Button, Chip, diff --git a/frontend/src/components/templates/ConfirmDialog.tsx b/frontend/src/components/templates/ConfirmDialog.tsx new file mode 100644 index 0000000..24ac694 --- /dev/null +++ b/frontend/src/components/templates/ConfirmDialog.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; + +export interface ConfirmDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string | React.ReactNode; + confirmLabel?: string; + confirmColor?: 'primary' | 'error' | 'warning'; + isLoading?: boolean; +} + +/** Standard confirmation dialog with cancel/confirm buttons. */ +export const ConfirmDialog: React.FC = ({ + open, + onClose, + onConfirm, + title, + message, + confirmLabel = 'Bestätigen', + confirmColor = 'primary', + isLoading = false, +}) => { + return ( + + {title} + + {typeof message === 'string' ? ( + {message} + ) : ( + message + )} + + + + + + + ); +}; diff --git a/frontend/src/components/templates/DataTable.tsx b/frontend/src/components/templates/DataTable.tsx new file mode 100644 index 0000000..215351c --- /dev/null +++ b/frontend/src/components/templates/DataTable.tsx @@ -0,0 +1,238 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Typography, + Skeleton, + InputAdornment, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import InboxIcon from '@mui/icons-material/Inbox'; + +export interface Column { + key: string; + label: string; + align?: 'left' | 'center' | 'right'; + width?: number | string; + render?: (row: T) => React.ReactNode; + sortable?: boolean; + searchable?: boolean; +} + +export interface DataTableProps { + columns: Column[]; + data: T[]; + isLoading?: boolean; + skeletonRows?: number; + emptyMessage?: string; + emptyIcon?: React.ReactNode; + onRowClick?: (row: T) => void; + rowKey: (row: T) => string | number; + searchPlaceholder?: string; + searchEnabled?: boolean; + paginationEnabled?: boolean; + defaultRowsPerPage?: number; + rowsPerPageOptions?: number[]; + filters?: React.ReactNode; + actions?: React.ReactNode; + title?: string; + stickyHeader?: boolean; + maxHeight?: number | string; + size?: 'small' | 'medium'; + dense?: boolean; +} + +/** Universal data table with search, sorting, and pagination. */ +export function DataTable({ + columns, + data, + isLoading = false, + skeletonRows = 5, + emptyMessage = 'Keine Einträge', + emptyIcon, + onRowClick, + rowKey, + searchPlaceholder = 'Suchen...', + searchEnabled = true, + paginationEnabled = true, + defaultRowsPerPage = 10, + rowsPerPageOptions = [5, 10, 25], + filters, + actions, + title, + stickyHeader = false, + maxHeight, + size = 'small', + dense = false, +}: DataTableProps) { + const [search, setSearch] = useState(''); + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage); + + const handleSort = useCallback((key: string) => { + if (sortKey === key) { + if (sortDir === 'asc') { + setSortDir('desc'); + } else { + setSortKey(null); + setSortDir('asc'); + } + } else { + setSortKey(key); + setSortDir('asc'); + } + }, [sortKey, sortDir]); + + const filteredData = useMemo(() => { + if (!search.trim()) return data; + const term = search.toLowerCase(); + const searchableCols = columns.filter((c) => c.searchable !== false); + return data.filter((row) => + searchableCols.some((col) => { + const val = (row as Record)[col.key]; + return val != null && String(val).toLowerCase().includes(term); + }) + ); + }, [data, search, columns]); + + const sortedData = useMemo(() => { + if (!sortKey) return filteredData; + return [...filteredData].sort((a, b) => { + const aVal = (a as Record)[sortKey]; + const bVal = (b as Record)[sortKey]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true }); + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [filteredData, sortKey, sortDir]); + + const paginatedData = paginationEnabled + ? sortedData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + : sortedData; + + const showToolbar = title || actions || searchEnabled; + + return ( + + {showToolbar && ( + + {title && {title}} + + {searchEnabled && ( + { setSearch(e.target.value); setPage(0); }} + sx={{ maxWidth: 300 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + )} + {actions} + + )} + {filters && {filters}} + + + + + {columns.map((col) => ( + col.sortable !== false && handleSort(col.key)} + > + + {col.label} + {sortKey === col.key && ( + sortDir === 'asc' + ? + : + )} + + + ))} + + + + {isLoading ? ( + Array.from({ length: skeletonRows }, (_, i) => ( + + {columns.map((col) => ( + + + + ))} + + )) + ) : paginatedData.length === 0 ? ( + + + + {emptyIcon ?? } + + {emptyMessage} + + + + + ) : ( + paginatedData.map((row) => ( + onRowClick?.(row)} + sx={{ cursor: onRowClick ? 'pointer' : 'default' }} + > + {columns.map((col) => ( + + {col.render ? col.render(row) : (row as Record)[col.key] as React.ReactNode} + + ))} + + )) + )} + +
+
+ {paginationEnabled && !isLoading && ( + setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }} + rowsPerPageOptions={rowsPerPageOptions} + labelRowsPerPage="Zeilen pro Seite:" + /> + )} +
+ ); +} diff --git a/frontend/src/components/templates/DetailLayout.tsx b/frontend/src/components/templates/DetailLayout.tsx new file mode 100644 index 0000000..2d7d871 --- /dev/null +++ b/frontend/src/components/templates/DetailLayout.tsx @@ -0,0 +1,74 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Tab, Tabs } from '@mui/material'; +import { useSearchParams } from 'react-router-dom'; +import { PageHeader } from './PageHeader'; +import { TabPanel } from './TabPanel'; +import type { BreadcrumbItem } from './PageHeader'; + +export interface TabDef { + label: React.ReactNode; + content: React.ReactNode; + icon?: React.ReactElement; + disabled?: boolean; +} + +export interface DetailLayoutProps { + title: string; + breadcrumbs?: BreadcrumbItem[]; + actions?: React.ReactNode; + tabs: TabDef[]; + backTo?: string; + isLoading?: boolean; + skeleton?: React.ReactNode; +} + +/** Detail page layout with PageHeader and tab navigation synced to URL. */ +export const DetailLayout: React.FC = ({ + title, + breadcrumbs, + actions, + tabs, + backTo, + isLoading = false, + skeleton, +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [tab, setTab] = useState(() => { + const t = parseInt(searchParams.get('tab') ?? '0', 10); + return isNaN(t) || t < 0 || t >= tabs.length ? 0 : t; + }); + + useEffect(() => { + const newParams = new URLSearchParams(searchParams); + if (tab === 0) { + newParams.delete('tab'); + } else { + newParams.set('tab', String(tab)); + } + setSearchParams(newParams, { replace: true }); + }, [tab]); + + return ( + + + {isLoading ? ( + skeleton + ) : ( + <> + + setTab(v)} variant="scrollable" scrollButtons="auto"> + {tabs.map((t, i) => ( + + ))} + + + {tabs.map((t, i) => ( + + {t.content} + + ))} + + )} + + ); +}; diff --git a/frontend/src/components/templates/FilterBar.tsx b/frontend/src/components/templates/FilterBar.tsx new file mode 100644 index 0000000..22fb292 --- /dev/null +++ b/frontend/src/components/templates/FilterBar.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Badge, + Box, + Button, + TextField, + Typography, + InputAdornment, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +export interface FilterBarProps { + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + children?: React.ReactNode; + activeFilterCount?: number; + onClearFilters?: () => void; + collapsible?: boolean; +} + +/** Search + filter controls bar. Supports collapsible accordion mode. */ +export const FilterBar: React.FC = ({ + searchValue, + onSearchChange, + searchPlaceholder = 'Suchen...', + children, + activeFilterCount = 0, + onClearFilters, + collapsible = false, +}) => { + const searchField = onSearchChange ? ( + onSearchChange(e.target.value)} + sx={{ minWidth: 200 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + ) : null; + + const clearButton = activeFilterCount > 0 && onClearFilters ? ( + + ) : null; + + if (collapsible) { + return ( + + }> + + + + + Filter + + + + + {searchField} + {children} + {clearButton} + + + + ); + } + + return ( + + {searchField} + {children} + + {clearButton} + + ); +}; diff --git a/frontend/src/components/templates/FormCard.tsx b/frontend/src/components/templates/FormCard.tsx new file mode 100644 index 0000000..387b313 --- /dev/null +++ b/frontend/src/components/templates/FormCard.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Box, Button, CircularProgress } from '@mui/material'; +import { WidgetCard } from './WidgetCard'; + +export interface FormCardProps { + title: string; + icon?: React.ReactNode; + isSubmitting?: boolean; + onSubmit?: (e: React.FormEvent) => void; + submitLabel?: string; + children: React.ReactNode; +} + +/** Quick-action form widget built on WidgetCard. */ +export const FormCard: React.FC = ({ + title, + icon, + isSubmitting = false, + onSubmit, + submitLabel = 'Erstellen', + children, +}) => { + return ( + + + {children} + + + + + + ); +}; diff --git a/frontend/src/components/templates/FormDialog.tsx b/frontend/src/components/templates/FormDialog.tsx new file mode 100644 index 0000000..cbdc980 --- /dev/null +++ b/frontend/src/components/templates/FormDialog.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; + +export interface FormDialogProps { + open: boolean; + onClose: () => void; + onSubmit: () => void; + title: string; + submitLabel?: string; + isSubmitting?: boolean; + maxWidth?: 'xs' | 'sm' | 'md'; + children: React.ReactNode; +} + +/** Dialog with form content and submit/cancel buttons. */ +export const FormDialog: React.FC = ({ + open, + onClose, + onSubmit, + title, + submitLabel = 'Speichern', + isSubmitting = false, + maxWidth = 'sm', + children, +}) => { + return ( + + {title} + + + {children} + + + + + + + + ); +}; diff --git a/frontend/src/components/templates/FormLayout.tsx b/frontend/src/components/templates/FormLayout.tsx new file mode 100644 index 0000000..4ac5541 --- /dev/null +++ b/frontend/src/components/templates/FormLayout.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Box, Paper, Typography } from '@mui/material'; + +export interface FormLayoutProps { + children: React.ReactNode; + onSubmit?: (e: React.FormEvent) => void; + actions?: React.ReactNode; + title?: string; +} + +/** Standard form page wrapper with Paper, optional title, and action slot. */ +export const FormLayout: React.FC = ({ + children, + onSubmit, + actions, + title, +}) => { + return ( + + {title && ( + + {title} + + )} + + {children} + {actions && ( + + {actions} + + )} + + + ); +}; diff --git a/frontend/src/components/templates/InfoGrid.tsx b/frontend/src/components/templates/InfoGrid.tsx new file mode 100644 index 0000000..d95e332 --- /dev/null +++ b/frontend/src/components/templates/InfoGrid.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import { InfoGridSkeleton } from './SkeletonPresets'; + +export interface InfoField { + label: string; + value: React.ReactNode; + fullWidth?: boolean; +} + +export interface InfoGridProps { + fields: InfoField[]; + columns?: 1 | 2; + isLoading?: boolean; +} + +/** Key-value display grid with golden-ratio label proportions. */ +export const InfoGrid: React.FC = ({ + fields, + columns = 1, + isLoading = false, +}) => { + if (isLoading) { + return ; + } + + const wrapper = columns === 2 + ? { display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0 16px' } + : {}; + + return ( + + {fields.map((field, i) => ( + + + {field.label} + + + {field.value} + + + ))} + + ); +}; diff --git a/frontend/src/components/templates/ListCard.tsx b/frontend/src/components/templates/ListCard.tsx new file mode 100644 index 0000000..3c210ac --- /dev/null +++ b/frontend/src/components/templates/ListCard.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Box, Divider } from '@mui/material'; +import { WidgetCard } from './WidgetCard'; +import { ItemListSkeleton } from './SkeletonPresets'; +import type { SxProps, Theme } from '@mui/material/styles'; + +export interface ListCardProps { + title: string; + icon?: React.ReactNode; + action?: React.ReactNode; + items: T[]; + renderItem: (item: T, index: number) => React.ReactNode; + isLoading?: boolean; + skeletonCount?: number; + skeletonItem?: React.ReactNode; + emptyMessage?: string; + maxItems?: number; + footer?: React.ReactNode; + onClick?: () => void; + sx?: SxProps; +} + +/** Card with a list of items, built on WidgetCard. */ +export function ListCard({ + title, + icon, + action, + items, + renderItem, + isLoading = false, + skeletonCount = 5, + skeletonItem, + emptyMessage = 'Keine Einträge', + maxItems, + footer, + onClick, + sx, +}: ListCardProps) { + const displayItems = maxItems ? items.slice(0, maxItems) : items; + + const skeletonContent = skeletonItem ? ( + + {Array.from({ length: skeletonCount }, (_, i) => ( + + {skeletonItem} + {i < skeletonCount - 1 && } + + ))} + + ) : ( + + ); + + return ( + + {displayItems.map((item, index) => ( + + + {renderItem(item, index)} + + {index < displayItems.length - 1 && } + + ))} + + ); +} diff --git a/frontend/src/components/templates/PageContainer.tsx b/frontend/src/components/templates/PageContainer.tsx new file mode 100644 index 0000000..7af619b --- /dev/null +++ b/frontend/src/components/templates/PageContainer.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Container } from '@mui/material'; + +export interface PageContainerProps { + children: React.ReactNode; + maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | false; +} + +/** Standard page layout wrapper with consistent padding and max width. */ +export const PageContainer: React.FC = ({ + children, + maxWidth = 'lg', +}) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/templates/PageHeader.tsx b/frontend/src/components/templates/PageHeader.tsx new file mode 100644 index 0000000..4cc4eae --- /dev/null +++ b/frontend/src/components/templates/PageHeader.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { + Box, + Breadcrumbs, + IconButton, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { Link, useNavigate } from 'react-router-dom'; + +export interface BreadcrumbItem { + label: string; + href?: string; +} + +export interface PageHeaderProps { + title: string; + subtitle?: string; + breadcrumbs?: BreadcrumbItem[]; + actions?: React.ReactNode; + backTo?: string; +} + +/** Page title bar with optional breadcrumbs, back button, and action slot. */ +export const PageHeader: React.FC = ({ + title, + subtitle, + breadcrumbs, + actions, + backTo, +}) => { + const navigate = useNavigate(); + + return ( + + {breadcrumbs && breadcrumbs.length > 0 && ( + + {breadcrumbs.map((item, i) => + item.href && i < breadcrumbs.length - 1 ? ( + + + {item.label} + + + ) : ( + + {item.label} + + ) + )} + + )} + + + {backTo && ( + navigate(backTo)} size="small"> + + + )} + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + {actions && {actions}} + + + ); +}; diff --git a/frontend/src/components/templates/SkeletonPresets.tsx b/frontend/src/components/templates/SkeletonPresets.tsx new file mode 100644 index 0000000..6c4f0b8 --- /dev/null +++ b/frontend/src/components/templates/SkeletonPresets.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { Box, Paper, Skeleton } from '@mui/material'; + +/** Skeleton for a stat card: label, large value, and circular icon. */ +export const StatSkeleton: React.FC = () => ( + + + + + + + + + +); + +/** Skeleton for a row of chips. */ +export const ChipListSkeleton: React.FC = () => ( + + + + + + +); + +export interface ItemListSkeletonProps { + count?: number; +} + +/** Skeleton for a list of items with avatar and two text lines. */ +export const ItemListSkeleton: React.FC = ({ count = 5 }) => ( + + {Array.from({ length: count }, (_, i) => ( + + + + + + ))} + +); + +/** Skeleton for a quick-action form. */ +export const FormSkeleton: React.FC = () => ( + + + + + +); + +export interface TableSkeletonProps { + rows?: number; + columns?: number; +} + +/** Skeleton for a data table with header and body rows. */ +export const TableSkeleton: React.FC = ({ rows = 5, columns = 4 }) => ( + + + {Array.from({ length: columns }, (_, i) => ( + + ))} + + {Array.from({ length: rows }, (_, r) => ( + + {Array.from({ length: columns }, (_, c) => ( + + ))} + + ))} + +); + +export interface InfoGridSkeletonProps { + rows?: number; +} + +/** Skeleton for a label-value info grid. */ +export const InfoGridSkeleton: React.FC = ({ rows = 4 }) => ( + + {Array.from({ length: rows }, (_, i) => ( + + + + + ))} + +); + +export interface SummaryCardsSkeletonProps { + count?: number; +} + +/** Skeleton for a grid of summary stat cards. */ +export const SummaryCardsSkeleton: React.FC = ({ count = 4 }) => ( + + {Array.from({ length: count }, (_, i) => ( + + + + + ))} + +); diff --git a/frontend/src/components/templates/StatCard.tsx b/frontend/src/components/templates/StatCard.tsx new file mode 100644 index 0000000..90cec9e --- /dev/null +++ b/frontend/src/components/templates/StatCard.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Box, Card, CardActionArea, CardContent, Typography } from '@mui/material'; +import { GOLDEN_RATIO } from '../../theme/theme'; +import { StatSkeleton } from './SkeletonPresets'; + +export interface StatCardProps { + title: string; + value: string | number; + icon: React.ReactNode; + color?: string; + trend?: { value: number; label?: string }; + isLoading?: boolean; + onClick?: () => void; +} + +/** Stat display card with golden-ratio proportioned layout. */ +export const StatCard: React.FC = ({ + title, + value, + icon, + color = 'primary.main', + trend, + isLoading = false, + onClick, +}) => { + const content = ( + + {isLoading ? ( + + ) : ( + + + + {title} + + + {value} + + {trend && ( + = 0 ? 'success.main' : 'error.main'} + > + {trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}% + {trend.label && ` ${trend.label}`} + + )} + + + + {icon} + + + + )} + + ); + + return ( + + {onClick ? ( + + {content} + + ) : ( + content + )} + + ); +}; diff --git a/frontend/src/components/templates/StatusChip.tsx b/frontend/src/components/templates/StatusChip.tsx new file mode 100644 index 0000000..8390bca --- /dev/null +++ b/frontend/src/components/templates/StatusChip.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Chip } from '@mui/material'; + +export type ChipColor = 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning'; + +export interface StatusChipProps { + status: string; + colorMap: Record; + labelMap: Record; + size?: 'small' | 'medium'; + variant?: 'filled' | 'outlined'; + icon?: React.ReactElement; +} + +/** Consistent status chip with configurable color and label maps. */ +export const StatusChip: React.FC = ({ + status, + colorMap, + labelMap, + size = 'small', + variant = 'filled', + icon, +}) => { + return ( + + ); +}; diff --git a/frontend/src/components/templates/SummaryCards.tsx b/frontend/src/components/templates/SummaryCards.tsx new file mode 100644 index 0000000..ddd057f --- /dev/null +++ b/frontend/src/components/templates/SummaryCards.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Box, Paper, Typography } from '@mui/material'; +import { SummaryCardsSkeleton } from './SkeletonPresets'; + +export interface SummaryStat { + label: string; + value: string | number; + color?: string; + onClick?: () => void; +} + +export interface SummaryCardsProps { + stats: SummaryStat[]; + isLoading?: boolean; +} + +/** Mini stat cards displayed in a responsive grid row. */ +export const SummaryCards: React.FC = ({ + stats, + isLoading = false, +}) => { + if (isLoading) { + return ; + } + + return ( + + {stats.map((stat, i) => ( + + + {stat.value} + + + {stat.label} + + + ))} + + ); +}; diff --git a/frontend/src/components/templates/TabPanel.tsx b/frontend/src/components/templates/TabPanel.tsx new file mode 100644 index 0000000..2d9cc62 --- /dev/null +++ b/frontend/src/components/templates/TabPanel.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Box } from '@mui/material'; + +export interface TabPanelProps { + children: React.ReactNode; + value: number; + index: number; +} + +/** Consistent tab content wrapper. Renders only when active. */ +export const TabPanel: React.FC = ({ children, value, index }) => { + if (value !== index) return null; + return {children}; +}; diff --git a/frontend/src/components/templates/WidgetCard.tsx b/frontend/src/components/templates/WidgetCard.tsx new file mode 100644 index 0000000..bba2b21 --- /dev/null +++ b/frontend/src/components/templates/WidgetCard.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { + Box, + Card, + CardActionArea, + CardContent, + Divider, + Skeleton, + Typography, + Alert, +} from '@mui/material'; +import InboxIcon from '@mui/icons-material/Inbox'; +import type { SxProps, Theme } from '@mui/material/styles'; + +export interface WidgetCardProps { + title: string; + icon?: React.ReactNode; + action?: React.ReactNode; + isLoading?: boolean; + isError?: boolean; + errorMessage?: string; + isEmpty?: boolean; + emptyMessage?: string; + emptyIcon?: React.ReactNode; + onClick?: () => void; + skeleton?: React.ReactNode; + children?: React.ReactNode; + noPadding?: boolean; + footer?: React.ReactNode; + sx?: SxProps; +} + +/** Universal dashboard widget wrapper with loading/error/empty states. */ +export const WidgetCard: React.FC = ({ + title, + icon, + action, + isLoading = false, + isError = false, + errorMessage = 'Fehler beim Laden', + isEmpty = false, + emptyMessage = 'Keine Einträge', + emptyIcon, + onClick, + skeleton, + children, + noPadding = false, + footer, + sx, +}) => { + const header = ( + + + + {icon} + + {title} + + + {action} + + + + ); + + const renderContent = () => { + if (isLoading) { + return skeleton ?? ( + + + + + + ); + } + + if (isError) { + return ( + + {errorMessage} + + ); + } + + if (isEmpty) { + return ( + + {emptyIcon ?? } + + {emptyMessage} + + + ); + } + + return children; + }; + + const cardContent = ( + <> + {header} + + {renderContent()} + + {footer && ( + <> + + + {footer} + + + )} + + ); + + const cardContentSx = noPadding + ? { p: 0, '&:last-child': { pb: 0 } } + : { p: 2.5, '&:last-child': { pb: 2.5 } }; + + return ( + + {onClick ? ( + + + {cardContent} + + + ) : ( + + {cardContent} + + )} + + ); +}; diff --git a/frontend/src/components/templates/index.ts b/frontend/src/components/templates/index.ts new file mode 100644 index 0000000..5d94e84 --- /dev/null +++ b/frontend/src/components/templates/index.ts @@ -0,0 +1,33 @@ +export { WidgetCard } from './WidgetCard'; +export type { WidgetCardProps } from './WidgetCard'; +export { StatCard } from './StatCard'; +export type { StatCardProps } from './StatCard'; +export { ListCard } from './ListCard'; +export type { ListCardProps } from './ListCard'; +export { FormCard } from './FormCard'; +export type { FormCardProps } from './FormCard'; +export { PageHeader } from './PageHeader'; +export type { PageHeaderProps, BreadcrumbItem } from './PageHeader'; +export { PageContainer } from './PageContainer'; +export type { PageContainerProps } from './PageContainer'; +export { FormLayout } from './FormLayout'; +export type { FormLayoutProps } from './FormLayout'; +export { DetailLayout } from './DetailLayout'; +export type { DetailLayoutProps, TabDef } from './DetailLayout'; +export { TabPanel } from './TabPanel'; +export type { TabPanelProps } from './TabPanel'; +export { DataTable } from './DataTable'; +export type { DataTableProps, Column } from './DataTable'; +export { FilterBar } from './FilterBar'; +export type { FilterBarProps } from './FilterBar'; +export { InfoGrid } from './InfoGrid'; +export type { InfoGridProps, InfoField } from './InfoGrid'; +export { SummaryCards } from './SummaryCards'; +export type { SummaryCardsProps, SummaryStat } from './SummaryCards'; +export { ConfirmDialog } from './ConfirmDialog'; +export type { ConfirmDialogProps } from './ConfirmDialog'; +export { FormDialog } from './FormDialog'; +export type { FormDialogProps } from './FormDialog'; +export { StatusChip } from './StatusChip'; +export type { StatusChipProps, ChipColor } from './StatusChip'; +export * from './SkeletonPresets'; diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx index 212e395..aeda6bb 100644 --- a/frontend/src/pages/AdminSettings.tsx +++ b/frontend/src/pages/AdminSettings.tsx @@ -195,14 +195,6 @@ function AdminSettings() { showError('Fehler beim Speichern der PDF-Einstellungen'); } }; - const handleLogoUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (ev) => setPdfLogo(ev.target?.result as string); - reader.readAsDataURL(file); - }; - // App logo mutation + handlers const appLogoMutation = useMutation({ mutationFn: (value: string) => settingsApi.update('app_logo', value), diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx index 91d5a34..03f5641 100644 --- a/frontend/src/pages/Atemschutz.tsx +++ b/frontend/src/pages/Atemschutz.tsx @@ -12,7 +12,6 @@ import { Dialog, DialogActions, DialogContent, - DialogContentText, DialogTitle, FormControl, FormControlLabel, @@ -21,12 +20,6 @@ import { InputLabel, MenuItem, Select, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TextField, Tooltip, Typography, @@ -41,6 +34,8 @@ import { } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; +import { DataTable } from '../components/templates'; +import type { Column } from '../components/templates'; import { atemschutzApi } from '../services/atemschutz'; import { membersService } from '../services/members'; import { useNotification } from '../contexts/NotificationContext'; @@ -54,6 +49,7 @@ import type { UntersuchungErgebnis, } from '../types/atemschutz.types'; import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; +import { ConfirmDialog } from '../components/templates'; import type { MemberListItem } from '../types/member.types'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -480,116 +476,86 @@ function Atemschutz() { )} {/* Table */} - {!loading && !error && filtered.length > 0 && ( - - - - - Name - Lehrgang - Untersuchung gültig bis - Leistungstest gültig bis - Status - {canWrite && Aktionen} - - - - {filtered.map((item) => { - const untersuchungColor = getValidityColor( - item.untersuchung_gueltig_bis, - item.untersuchung_tage_rest, - 90 - ); - const leistungstestColor = getValidityColor( - item.leistungstest_gueltig_bis, - item.leistungstest_tage_rest, - 30 - ); + {!loading && !error && filtered.length > 0 && (() => { + const columns: Column[] = [ + { key: 'user_name', label: 'Name', render: (item) => ( + {getDisplayName(item)} + )}, + { key: 'atemschutz_lehrgang', label: 'Lehrgang', align: 'center', render: (item) => ( + item.atemschutz_lehrgang ? ( + + + + ) : ( + + ) + )}, + { key: 'untersuchung_gueltig_bis', label: 'Untersuchung gültig bis', render: (item) => ( + + + {formatDate(item.untersuchung_gueltig_bis)} + + + )}, + { key: 'leistungstest_gueltig_bis', label: 'Leistungstest gültig bis', render: (item) => ( + + + {formatDate(item.leistungstest_gueltig_bis)} + + + )}, + { key: 'einsatzbereit', label: 'Status', align: 'center', render: (item) => ( + + )}, + ]; - return ( - - - - {getDisplayName(item)} - - - - {item.atemschutz_lehrgang ? ( - - - - ) : ( - - )} - - - - - {formatDate(item.untersuchung_gueltig_bis)} - - - - - - - {formatDate(item.leistungstest_gueltig_bis)} - - - - - - - {canWrite && ( - - - - - - - - - )} - - ); - })} - -
-
- )} + if (canWrite) { + columns.push({ + key: 'actions', label: 'Aktionen', align: 'right', sortable: false, searchable: false, render: (item) => ( + <> + + + + + + + + ), + }); + } + + return ( + item.id} + emptyMessage={traeger.length === 0 ? 'Keine Atemschutzträger vorhanden' : 'Keine Ergebnisse gefunden'} + searchEnabled={false} + paginationEnabled={false} + /> + ); + })()} {/* FAB to create */} {canWrite && ( @@ -808,29 +774,16 @@ function Atemschutz() { {/* ── Delete Confirmation Dialog ──────────────────────────────────── */} - setDeleteId(null)}> - Atemschutzträger löschen - - - Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann - nicht rückgängig gemacht werden. - - - - - - - + setDeleteId(null)} + onConfirm={handleDeleteConfirm} + title="Atemschutzträger löschen" + message="Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden." + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteLoading} + /> ); diff --git a/frontend/src/pages/Ausruestung.tsx b/frontend/src/pages/Ausruestung.tsx index 51879d6..7ad4357 100644 --- a/frontend/src/pages/Ausruestung.tsx +++ b/frontend/src/pages/Ausruestung.tsx @@ -9,11 +9,6 @@ import { Chip, CircularProgress, Container, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, FormControl, FormControlLabel, Grid, @@ -41,14 +36,12 @@ import { Add as AddIcon, Build, CheckCircle, - Close, Delete, Edit, Error as ErrorIcon, LinkRounded, PauseCircle, RemoveCircle, - Save, Search, Star, Warning, @@ -68,6 +61,7 @@ import { import { usePermissions } from '../hooks/usePermissions'; import { useNotification } from '../contexts/NotificationContext'; import ChatAwareFab from '../components/shared/ChatAwareFab'; +import { ConfirmDialog, FormDialog } from '../components/templates'; // ── Status chip config ──────────────────────────────────────────────────────── @@ -416,18 +410,18 @@ function AusruestungTypenSettings() { )} {/* Add/Edit dialog */} - - - {editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'} - - - + setFormName(e.target.value)} - sx={{ mt: 1, mb: 2 }} inputProps={{ maxLength: 100 }} /> setFormBeschreibung(e.target.value)} - sx={{ mb: 2 }} /> setFormIcon(e.target.value)} placeholder="z.B. Build, LocalFireDepartment" /> - - - - - - + {/* Delete confirmation dialog */} - !deleteMutation.isPending && setDeleteDialogOpen(false)}> - Typ löschen - - - Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen? - Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung. - - - - - - - + setDeleteDialogOpen(false)} + onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)} + title="Typ löschen" + message={<>Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.} + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteMutation.isPending} + />
); } diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index e89c6a7..97571e6 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -9,7 +9,6 @@ import { Dialog, DialogActions, DialogContent, - DialogContentText, DialogTitle, Divider, FormControl, @@ -20,14 +19,12 @@ import { Paper, Select, Stack, - Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - Tabs, TextField, Tooltip, Typography, @@ -44,14 +41,13 @@ import { MoreHoriz, PauseCircle, RemoveCircle, - Save, Star, Verified, Warning, } from '@mui/icons-material'; import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { DetailLayout, ConfirmDialog } from '../components/templates'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { equipmentApi } from '../services/equipment'; import { fromGermanDate } from '../utils/dateInput'; @@ -68,20 +64,6 @@ import { import { usePermissions } from '../hooks/usePermissions'; import { useNotification } from '../contexts/NotificationContext'; -// -- Tab Panel ---------------------------------------------------------------- - -interface TabPanelProps { - children?: React.ReactNode; - index: number; - value: number; -} - -const TabPanel: React.FC = ({ children, value, index }) => ( - -); - // -- Status config ------------------------------------------------------------ const STATUS_ICONS: Record = { @@ -202,7 +184,7 @@ interface UebersichtTabProps { canWrite: boolean; } -const UebersichtTab: React.FC = ({ equipment, onStatusUpdated, canChangeStatus, canWrite }) => { +const UebersichtTab: React.FC = ({ equipment, onStatusUpdated, canChangeStatus, canWrite: _canWrite }) => { const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [newStatus, setNewStatus] = useState(equipment.status); const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? ''); @@ -690,7 +672,6 @@ function AusruestungDetailPage() { const [equipment, setEquipment] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState(0); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); @@ -761,133 +742,89 @@ function AusruestungDetailPage() { }; const canWrite = canManageCategory(equipmentKategorie); - const subtitle = [ - equipment.kategorie_name, - equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null, - ].filter(Boolean).join(' · '); + const tabs = [ + { + label: 'Übersicht', + content: ( + + ), + }, + { + label: hasOverdue + ? ( + + Wartung + + ) + : 'Wartung', + content: ( + + ), + }, + ]; return ( - - - - - - - {equipment.bezeichnung} - - {subtitle && ( - - {subtitle} - - )} - - - - {canWrite && ( - - navigate(`/ausruestung/${equipment.id}/bearbeiten`)} - aria-label="Gerät bearbeiten" - > - - - - )} - {isAdmin && ( - - setDeleteDialogOpen(true)} - aria-label="Gerät löschen" - > - - - - )} - - - - - setActiveTab(v)} - aria-label="Ausrüstung Detailansicht" - variant="scrollable" - scrollButtons="auto" - > - - - Wartung - - : 'Wartung' - } - /> - -
- - - - - - - - + + + {canWrite && ( + + navigate(`/ausruestung/${equipment.id}/bearbeiten`)} + aria-label="Gerät bearbeiten" + > + + + + )} + {isAdmin && ( + + setDeleteDialogOpen(true)} + aria-label="Gerät löschen" + > + + + + )} +
+ } + /> {/* Delete confirmation dialog */} - !deleteLoading && setDeleteDialogOpen(false)}> - Gerät löschen - - - Möchten Sie '{equipment.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. - - - - - - - + !deleteLoading && setDeleteDialogOpen(false)} + onConfirm={handleDelete} + title="Gerät löschen" + message={`Möchten Sie '${equipment.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`} + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteLoading} + /> ); diff --git a/frontend/src/pages/AusruestungForm.tsx b/frontend/src/pages/AusruestungForm.tsx index 80504d5..1cf7242 100644 --- a/frontend/src/pages/AusruestungForm.tsx +++ b/frontend/src/pages/AusruestungForm.tsx @@ -16,9 +16,10 @@ import { TextField, Typography, } from '@mui/material'; -import { ArrowBack, Save } from '@mui/icons-material'; +import { Save } from '@mui/icons-material'; import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageHeader } from '../components/templates'; import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput'; import { equipmentApi } from '../services/equipment'; import { vehiclesApi } from '../services/vehicles'; @@ -288,7 +289,7 @@ function AusruestungForm() { {error} - @@ -301,18 +302,15 @@ function AusruestungForm() { return ( - - - - {isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'} - + {saveError && {saveError}} diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index 79e2da8..bbd4b6a 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -1,8 +1,8 @@ import { useState, useMemo, useEffect } from 'react'; import { - Box, Tab, Tabs, Typography, Grid, Button, Chip, + Box, Tab, Tabs, Typography, Grid, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, - TextField, IconButton, MenuItem, + TextField, MenuItem, } from '@mui/material'; import { Add as AddIcon } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; diff --git a/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx b/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx index 67b28be..cd2e98a 100644 --- a/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx @@ -335,7 +335,7 @@ export default function AusruestungsanfrageArtikelDetail() { const val = e.target.value ? Number(e.target.value) : ''; setMainKat(val); if (val) { - const subs = subKategorienOf(val as number); + subKategorienOf(val as number); setForm(f => ({ ...f, kategorie_id: val as number })); } else { setForm(f => ({ ...f, kategorie_id: null })); diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index 79b5335..875b9b0 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -14,10 +14,6 @@ import { TableRow, TextField, IconButton, - Dialog, - DialogTitle, - DialogContent, - DialogActions, Grid, Card, CardContent, @@ -32,7 +28,6 @@ import { Tooltip, } from '@mui/material'; import { - ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, @@ -58,6 +53,7 @@ import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types'; import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types'; +import { ConfirmDialog, StatusChip, PageHeader } from '../components/templates'; // ── Helpers ── @@ -674,40 +670,44 @@ export default function BestellungDetail() { return ( {/* ── Header ── */} - - navigate('/bestellungen')}> - - - {bestellung.bezeichnung} - {canExport && !editMode && ( - - - - - - - - )} - {canCreate && !editMode && ( - - )} - {editMode && ( - <> - - - - )} - - + + {canExport && !editMode && ( + + + + + + + + )} + {canCreate && !editMode && ( + + )} + {editMode && ( + <> + + + + )} + + + } + /> {/* ── Info Cards ── */} {editMode ? ( @@ -1344,73 +1344,68 @@ export default function BestellungDetail() { {/* ══════════════════════════════════════════════════════════════════════ */} {/* Status Confirmation */} - { setStatusConfirmTarget(null); setStatusForce(false); }}> - Status ändern{statusForce ? ' (manuell)' : ''} - - - Status von {BESTELLUNG_STATUS_LABELS[bestellung.status]} auf{' '} - {statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''} ändern? - - {statusForce && ( - - Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs. + { setStatusConfirmTarget(null); setStatusForce(false); }} + onConfirm={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })} + title={`Status ändern${statusForce ? ' (manuell)' : ''}`} + message={ + <> + + Status von {BESTELLUNG_STATUS_LABELS[bestellung.status]} auf{' '} + {statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''} ändern? - )} - {statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && ( - - Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern. - - )} - - - - - - + {statusForce && ( + + Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs. + + )} + {statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && ( + + Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern. + + )} + + } + confirmLabel="Bestätigen" + isLoading={updateStatus.isPending} + /> {/* Delete Item Confirmation */} - setDeleteItemTarget(null)}> - Position löschen - - Soll diese Position wirklich gelöscht werden? - - - - - - + setDeleteItemTarget(null)} + onConfirm={() => deleteItemTarget != null && deleteItem.mutate(deleteItemTarget)} + title="Position löschen" + message="Soll diese Position wirklich gelöscht werden?" + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteItem.isPending} + /> {/* Delete File Confirmation */} - setDeleteFileTarget(null)}> - Datei löschen - - Soll diese Datei wirklich gelöscht werden? - - - - - - + setDeleteFileTarget(null)} + onConfirm={() => deleteFileTarget != null && deleteFile.mutate(deleteFileTarget)} + title="Datei löschen" + message="Soll diese Datei wirklich gelöscht werden?" + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteFile.isPending} + /> {/* Delete Reminder Confirmation */} - setDeleteReminderTarget(null)}> - Erinnerung löschen - - Soll diese Erinnerung wirklich gelöscht werden? - - - - - - + setDeleteReminderTarget(null)} + onConfirm={() => deleteReminderTarget != null && deleteReminder.mutate(deleteReminderTarget)} + title="Erinnerung löschen" + message="Soll diese Erinnerung wirklich gelöscht werden?" + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteReminder.isPending} + /> ); } diff --git a/frontend/src/pages/BestellungNeu.tsx b/frontend/src/pages/BestellungNeu.tsx index 6c62a9a..de9c9fd 100644 --- a/frontend/src/pages/BestellungNeu.tsx +++ b/frontend/src/pages/BestellungNeu.tsx @@ -10,13 +10,13 @@ import { Tooltip, } from '@mui/material'; import { - ArrowBack, Add as AddIcon, RemoveCircleOutline as RemoveIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageHeader, FormLayout } from '../components/templates'; import { useNotification } from '../contexts/NotificationContext'; import { bestellungApi } from '../services/bestellung'; import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types'; @@ -75,16 +75,27 @@ export default function BestellungNeu() { return ( - {/* ── Header ── */} - - navigate('/bestellungen')}> - - - Neue Bestellung - + - {/* ── Form ── */} - + + + + } + > {/* ── Submit ── */} - - - - - + ); } diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index ac3d009..726b549 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -9,13 +9,6 @@ import { Tabs, Tooltip, Typography, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, Chip, Button, Checkbox, @@ -23,8 +16,6 @@ import { FormGroup, LinearProgress, Divider, - TextField, - MenuItem, } from '@mui/material'; import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; @@ -38,6 +29,8 @@ import { configApi } from '../services/config'; import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; import type { BestellungStatus, Bestellung } from '../types/bestellung.types'; +import { StatusChip, DataTable, SummaryCards } from '../components/templates'; +import type { SummaryStat } from '../components/templates'; // ── Helpers ── @@ -261,18 +254,16 @@ export default function Bestellungen() { {/* ── Tab 0: Orders ── */} {/* ── Summary Cards ── */} - - {[ - { label: 'Wartet auf Genehmigung', count: orders.filter(o => o.status === 'wartet_auf_genehmigung').length, color: 'warning.main' }, - { label: 'Bereit zur Bestellung', count: orders.filter(o => o.status === 'bereit_zur_bestellung').length, color: 'info.main' }, - { label: 'Bestellt', count: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' }, - { label: 'Lieferung prüfen', count: orders.filter(o => o.status === 'lieferung_pruefen').length, color: 'secondary.main' }, - ].map(({ label, count, color }) => ( - - {count} - {label} - - ))} + + o.status === 'wartet_auf_genehmigung').length, color: 'warning.main' }, + { label: 'Bereit zur Bestellung', value: orders.filter(o => o.status === 'bereit_zur_bestellung').length, color: 'info.main' }, + { label: 'Bestellt', value: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' }, + { label: 'Lieferung prüfen', value: orders.filter(o => o.status === 'lieferung_pruefen').length, color: 'secondary.main' }, + ] as SummaryStat[]} + isLoading={ordersLoading} + /> {/* ── Filter ── */} @@ -335,77 +326,39 @@ export default function Bestellungen() { - - - - - Kennung - Bezeichnung - Lieferant - Besteller - Status - Positionen - Gesamtpreis (brutto) - Lieferung - Erstellt am - - - - {ordersLoading ? ( - Laden... - ) : filteredOrders.length === 0 ? ( - Keine Bestellungen vorhanden - ) : ( - filteredOrders.map((o) => { - const brutto = calcBrutto(o); - const totalOrdered = o.total_ordered ?? 0; - const totalReceived = o.total_received ?? 0; - const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0; - return ( - navigate(`/bestellungen/${o.id}`)} - > - - {formatKennung(o)} - - {o.bezeichnung} - {o.lieferant_name || '–'} - {o.besteller_name || '–'} - - - - {o.items_count ?? 0} - {formatCurrency(brutto)} - - {totalOrdered > 0 ? ( - - = 100 ? 'success' : 'primary'} - sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} - /> - - {totalReceived}/{totalOrdered} - - - ) : '–'} - - {formatDate(o.erstellt_am)} - - ); - }) - )} - -
-
+ + columns={[ + { key: 'laufende_nummer', label: 'Kennung', width: 90, render: (o) => ( + {formatKennung(o)} + )}, + { key: 'bezeichnung', label: 'Bezeichnung' }, + { key: 'lieferant_name', label: 'Lieferant', render: (o) => o.lieferant_name || '–' }, + { key: 'besteller_name', label: 'Besteller', render: (o) => o.besteller_name || '–' }, + { key: 'status', label: 'Status', render: (o) => ( + + )}, + { key: 'items_count', label: 'Positionen', align: 'right', render: (o) => o.items_count ?? 0 }, + { key: 'total_cost', label: 'Gesamtpreis (brutto)', align: 'right', render: (o) => formatCurrency(calcBrutto(o)) }, + { key: 'total_received', label: 'Lieferung', render: (o) => { + const totalOrdered = o.total_ordered ?? 0; + const totalReceived = o.total_received ?? 0; + const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0; + return totalOrdered > 0 ? ( + + = 100 ? 'success' : 'primary'} sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} /> + {totalReceived}/{totalOrdered} + + ) : '–'; + }}, + { key: 'erstellt_am', label: 'Erstellt am', render: (o) => formatDate(o.erstellt_am) }, + ]} + data={filteredOrders} + rowKey={(o) => o.id} + onRowClick={(o) => navigate(`/bestellungen/${o.id}`)} + isLoading={ordersLoading} + emptyMessage="Keine Bestellungen vorhanden" + searchEnabled={false} + /> {hasPermission('bestellungen:create') && ( navigate('/bestellungen/neu')} aria-label="Neue Bestellung"> @@ -417,45 +370,22 @@ export default function Bestellungen() { {/* ── Tab 1: Vendors ── */} {canManageVendors && ( - - - - - Name - Kontakt - E-Mail - Telefon - Website - - - - {vendorsLoading ? ( - Laden... - ) : vendors.length === 0 ? ( - Keine Lieferanten vorhanden - ) : ( - vendors.map((v) => ( - navigate(`/bestellungen/lieferanten/${v.id}`)} - > - {v.name} - {v.kontakt_name || '–'} - {v.email ? e.stopPropagation()}>{v.email} : '–'} - {v.telefon || '–'} - - {v.website ? ( - e.stopPropagation()}>{v.website} - ) : '–'} - - - )) - )} - -
-
+ v.kontakt_name || '–' }, + { key: 'email', label: 'E-Mail', render: (v) => v.email ? e.stopPropagation()}>{v.email} : '–' }, + { key: 'telefon', label: 'Telefon', render: (v) => v.telefon || '–' }, + { key: 'website', label: 'Website', render: (v) => v.website ? ( + e.stopPropagation()}>{v.website} + ) : '–' }, + ]} + data={vendors} + rowKey={(v) => v.id} + onRowClick={(v) => navigate(`/bestellungen/lieferanten/${v.id}`)} + isLoading={vendorsLoading} + emptyMessage="Keine Lieferanten vorhanden" + /> navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen"> diff --git a/frontend/src/pages/BookingFormPage.tsx b/frontend/src/pages/BookingFormPage.tsx index 5528ae4..455fcaa 100644 --- a/frontend/src/pages/BookingFormPage.tsx +++ b/frontend/src/pages/BookingFormPage.tsx @@ -12,7 +12,6 @@ import { CircularProgress, Alert, Switch, - IconButton, Chip, Stack, Card, @@ -22,7 +21,6 @@ import { Grid, } from '@mui/material'; import { - ArrowBack, CheckCircle, Warning, Block, @@ -30,6 +28,7 @@ import { import { useQuery } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageHeader } from '../components/templates'; import ServiceModePage from '../components/shared/ServiceModePage'; import GermanDateField from '../components/shared/GermanDateField'; import { usePermissionContext } from '../contexts/PermissionContext'; @@ -243,15 +242,14 @@ function BookingFormPage() { ) : ( - {/* Page header */} - - navigate('/fahrzeugbuchungen')}> - - - - {isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'} - - + {error && ( diff --git a/frontend/src/pages/Buchhaltung.tsx b/frontend/src/pages/Buchhaltung.tsx index 365a07a..e225b8c 100644 --- a/frontend/src/pages/Buchhaltung.tsx +++ b/frontend/src/pages/Buchhaltung.tsx @@ -90,6 +90,8 @@ import { KONTO_ART_LABELS, } from '../types/buchhaltung.types'; +import { StatusChip } from '../components/templates'; + // ─── helpers ─────────────────────────────────────────────────────────────────── function fmtEur(val: number) { @@ -1392,7 +1394,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)} - + diff --git a/frontend/src/pages/BuchhaltungKontoManage.tsx b/frontend/src/pages/BuchhaltungKontoManage.tsx index 4ec7f3d..64d7552 100644 --- a/frontend/src/pages/BuchhaltungKontoManage.tsx +++ b/frontend/src/pages/BuchhaltungKontoManage.tsx @@ -3,8 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select, - FormControl, InputLabel, Alert, Dialog, DialogTitle, - DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid, + FormControl, InputLabel, Alert, Skeleton, Divider, LinearProgress, Grid, ToggleButton, ToggleButtonGroup, } from '@mui/material'; import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material'; @@ -12,6 +11,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout'; import { buchhaltungApi } from '../services/buchhaltung'; import { useNotification } from '../contexts/NotificationContext'; import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types'; +import { ConfirmDialog } from '../components/templates'; const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n); @@ -290,26 +290,21 @@ export default function BuchhaltungKontoManage() { - setDeleteOpen(false)}> - Konto löschen - + setDeleteOpen(false)} + onConfirm={() => { deleteMut.mutate(); setDeleteOpen(false); }} + title="Konto löschen" + message={ Möchten Sie das Konto {konto.kontonummer} — {konto.bezeichnung} wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. - - - - - - + } + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteMut.isPending} + />
); } diff --git a/frontend/src/pages/ChecklistAusfuehrung.tsx b/frontend/src/pages/ChecklistAusfuehrung.tsx index 0fca44f..2cf55e1 100644 --- a/frontend/src/pages/ChecklistAusfuehrung.tsx +++ b/frontend/src/pages/ChecklistAusfuehrung.tsx @@ -23,6 +23,7 @@ import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { checklistenApi } from '../services/checklisten'; import { CHECKLIST_STATUS_LABELS, CHECKLIST_STATUS_COLORS } from '../types/checklist.types'; +import { StatusChip } from '../components/templates'; import type { ChecklistAusfuehrungItem } from '../types/checklist.types'; // ── Helpers ── @@ -255,9 +256,11 @@ export default function ChecklistAusfuehrung() { {execution.fahrzeug_name ?? execution.ausruestung_name ?? '–'} · {formatDate(execution.ausgefuehrt_am ?? execution.created_at)} - diff --git a/frontend/src/pages/Checklisten.tsx b/frontend/src/pages/Checklisten.tsx index c843382..53c0cf1 100644 --- a/frontend/src/pages/Checklisten.tsx +++ b/frontend/src/pages/Checklisten.tsx @@ -52,6 +52,8 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { TabPanel, DataTable } from '../components/templates'; +import type { Column } from '../components/templates'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { checklistenApi } from '../services/checklisten'; @@ -124,14 +126,6 @@ function getDueLabel(nextDue?: string | null, intervall?: string | null): string return `in ${daysUntil}d fällig`; } -// ── Tab Panel ── - -interface TabPanelProps { children: React.ReactNode; index: number; value: number } -function TabPanel({ children, value, index }: TabPanelProps) { - if (value !== index) return null; - return {children}; -} - // ══════════════════════════════════════════════════════════════════════════════ // Component // ══════════════════════════════════════════════════════════════════════════════ @@ -783,6 +777,17 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) { if (loading) return ; + const columns: Column[] = [ + { key: 'fahrzeug_name', label: 'Fahrzeug / Ausrüstung', render: (e) => e.fahrzeug_name || e.ausruestung_name || '–' }, + { key: 'vorlage_name', label: 'Vorlage', render: (e) => e.vorlage_name ?? '–' }, + { key: 'ausgefuehrt_am', label: 'Datum', render: (e) => formatDate(e.ausgefuehrt_am ?? e.created_at) }, + { key: 'status', label: 'Status', render: (e) => ( + + )}, + { key: 'ausgefuehrt_von_name', label: 'Ausgeführt von', render: (e) => e.ausgefuehrt_von_name ?? '–' }, + { key: 'freigegeben_von_name', label: 'Freigegeben von', render: (e) => e.freigegeben_von_name ?? '–' }, + ]; + return ( @@ -804,38 +809,14 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) { - - - - - Fahrzeug / Ausrüstung - Vorlage - Datum - Status - Ausgeführt von - Freigegeben von - - - - {filtered.length === 0 ? ( - Keine Einträge - ) : ( - filtered.map((e) => ( - navigate(`/checklisten/ausfuehrung/${e.id}`)}> - {e.fahrzeug_name || e.ausruestung_name || '–'} - {e.vorlage_name ?? '–'} - {formatDate(e.ausgefuehrt_am ?? e.created_at)} - - - - {e.ausgefuehrt_von_name ?? '–'} - {e.freigegeben_von_name ?? '–'} - - )) - )} - -
-
+ e.id} + onRowClick={(e) => navigate(`/checklisten/ausfuehrung/${e.id}`)} + emptyMessage="Keine Einträge" + searchEnabled={false} + />
); } diff --git a/frontend/src/pages/Einsaetze.tsx b/frontend/src/pages/Einsaetze.tsx index c2a631c..6b98eb1 100644 --- a/frontend/src/pages/Einsaetze.tsx +++ b/frontend/src/pages/Einsaetze.tsx @@ -7,12 +7,6 @@ import { Button, Chip, Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TablePagination, TextField, Grid, @@ -36,6 +30,7 @@ import { import { format, parseISO } from 'date-fns'; import { de } from 'date-fns/locale'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { DataTable } from '../components/templates'; import { fromGermanDate } from '../utils/dateInput'; import IncidentStatsChart from '../components/incidents/IncidentStatsChart'; import { @@ -395,114 +390,62 @@ function Einsaetze() { )} {/* Incident table */} - - - - - - Datum / Uhrzeit - Nr. - Einsatzart - Stichwort - Ort - Hilfsfrist - Dauer - Status - Einsatzleiter - Kräfte - - - - {listLoading - ? Array.from({ length: rowsPerPage > 10 ? 10 : rowsPerPage }).map((_, i) => ( - - {Array.from({ length: 10 }).map((__, j) => ( - - - - ))} - - )) - : items.length === 0 - ? ( - - - - - - - Keine Einsätze gefunden - - - - ) - : items.map((row) => ( - handleRowClick(row.id)} - sx={{ cursor: 'pointer' }} - > - - {formatDE(row.alarm_time)} - - - {row.einsatz_nr} - - - - - - {row.einsatz_stichwort ?? '—'} - - - {[row.strasse, row.ort].filter(Boolean).join(', ') || '—'} - - - {durationLabel(row.hilfsfrist_min)} - - - {durationLabel(row.dauer_min)} - - - - - - {row.einsatzleiter_name ?? '—'} - - - {row.personal_count > 0 ? row.personal_count : '—'} - - - ))} - -
-
+ + columns={[ + { key: 'alarm_time', label: 'Datum / Uhrzeit', render: (row) => ( + {formatDE(row.alarm_time)} + )}, + { key: 'einsatz_nr', label: 'Nr.', render: (row) => ( + {row.einsatz_nr} + )}, + { key: 'einsatz_art', label: 'Einsatzart', render: (row) => ( + + )}, + { key: 'einsatz_stichwort', label: 'Stichwort', render: (row) => ( + {row.einsatz_stichwort ?? '—'} + )}, + { key: 'strasse', label: 'Ort', render: (row) => ( + {[row.strasse, row.ort].filter(Boolean).join(', ') || '—'} + )}, + { key: 'hilfsfrist_min', label: 'Hilfsfrist', align: 'right', render: (row) => ( + {durationLabel(row.hilfsfrist_min)} + )}, + { key: 'dauer_min', label: 'Dauer', align: 'right', render: (row) => ( + {durationLabel(row.dauer_min)} + )}, + { key: 'status', label: 'Status', render: (row) => ( + + )}, + { key: 'einsatzleiter_name', label: 'Einsatzleiter', render: (row) => ( + {row.einsatzleiter_name ?? '—'} + )}, + { key: 'personal_count', label: 'Kräfte', align: 'right', render: (row) => ( + {row.personal_count > 0 ? row.personal_count : '—'} + )}, + ]} + data={items} + rowKey={(row) => row.id} + onRowClick={(row) => handleRowClick(row.id)} + isLoading={listLoading} + emptyMessage="Keine Einsätze gefunden" + emptyIcon={} + searchEnabled={false} + paginationEnabled={false} + /> - - `${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}` - } - /> -
+ + `${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}` + } + /> {/* Create dialog */} - {/* Back + Actions */} - - - - - - - {canWrite && !editing ? ( - - ) : canWrite && editing ? ( - <> + /> + + + {canWrite && !editing ? ( - - ) : null} - - + ) : canWrite && editing ? ( + <> + + + + ) : null} + + } + /> - {/* HEADER */} - - - } - label={EINSATZ_ART_LABELS[einsatz.einsatz_art]} - color={ART_CHIP_COLOR[einsatz.einsatz_art]} - sx={{ fontWeight: 600 }} - /> - - {einsatz.einsatz_stichwort && ( - - {einsatz.einsatz_stichwort} - - )} - - - Einsatz {einsatz.einsatz_nr} + {einsatz.einsatz_stichwort && ( + + {einsatz.einsatz_stichwort} - {address && ( - - - - {address} - - - )} - + )} {/* LEFT COLUMN: Timeline + Vehicles */} diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index c7fd039..388256c 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -21,14 +21,12 @@ import { Paper, Select, Stack, - Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - Tabs, TextField, Tooltip, Typography, @@ -40,7 +38,6 @@ import { Build, CheckCircle, DeleteOutline, - DirectionsCar, Edit, Error as ErrorIcon, History, @@ -55,6 +52,7 @@ import { } from '@mui/icons-material'; import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { DetailLayout } from '../components/templates'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { vehiclesApi } from '../services/vehicles'; import GermanDateField from '../components/shared/GermanDateField'; @@ -81,20 +79,6 @@ import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import FahrzeugChecklistTab from '../components/fahrzeuge/FahrzeugChecklistTab'; -// ── Tab Panel ───────────────────────────────────────────────────────────────── - -interface TabPanelProps { - children?: React.ReactNode; - index: number; - value: number; -} - -const TabPanel: React.FC = ({ children, value, index }) => ( - -); - // ── Status config ───────────────────────────────────────────────────────────── const STATUS_ICONS: Record = { @@ -190,7 +174,7 @@ interface UebersichtTabProps { canEdit: boolean; } -const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit }) => { +const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit: _canEdit }) => { const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [newStatus, setNewStatus] = useState(vehicle.status); const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? ''); @@ -889,7 +873,6 @@ function FahrzeugDetail() { const [vehicle, setVehicle] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState(0); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); const [vehicleEquipment, setVehicleEquipment] = useState([]); @@ -958,130 +941,105 @@ function FahrzeugDetail() { (vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_tage_bis_faelligkeit < 0) || (vehicle.wartung_tage_bis_faelligkeit !== null && vehicle.wartung_tage_bis_faelligkeit < 0); + const titleText = vehicle.kurzname + ? `${vehicle.bezeichnung} ${vehicle.kurzname}` + : vehicle.bezeichnung; + + const tabs = [ + { + label: 'Übersicht', + content: ( + + ), + }, + { + label: hasOverdue + ? ( + + Wartung + + ) + : 'Wartung', + content: ( + + ), + }, + { + label: 'Einsätze', + content: ( + + + + Einsatzhistorie + + + Die Einsatzverknüpfung wird in Tier 2 (Einsatzverwaltung) implementiert. + + + ), + }, + { + label: `Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`, + content: , + }, + ...(hasPermission('checklisten:view') + ? [{ + label: 'Checklisten', + content: , + }] + : []), + ]; + return ( - - - - - - - {vehicle.bezeichnung} - {vehicle.kurzname && ( - - {vehicle.kurzname} - + + + {isAdmin && ( + + navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)} + aria-label="Fahrzeug bearbeiten" + > + + + )} - - {vehicle.amtliches_kennzeichen && ( - - {vehicle.amtliches_kennzeichen} - - )} - - - - {isAdmin && ( - - navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)} - aria-label="Fahrzeug bearbeiten" - > - - - - )} - {isAdmin && ( - - setDeleteDialogOpen(true)} - aria-label="Fahrzeug löschen" - > - - - - )} - - - - - setActiveTab(v)} - aria-label="Fahrzeug Detailansicht" - variant="scrollable" - scrollButtons="auto" - > - - - Wartung - - : 'Wartung' - } - /> - - 0 ? ` (${vehicleEquipment.length})` : ''}`} /> - {hasPermission('checklisten:view') && } - - - - - - - - - - - - - - - - Einsatzhistorie - - - Die Einsatzverknüpfung wird in Tier 2 (Einsatzverwaltung) implementiert. - - - - - - - - - {hasPermission('checklisten:view') && ( - - - - )} + {isAdmin && ( + + setDeleteDialogOpen(true)} + aria-label="Fahrzeug löschen" + > + + + + )} + + } + /> {/* Delete confirmation dialog */} !deleteLoading && setDeleteDialogOpen(false)}> diff --git a/frontend/src/pages/FahrzeugForm.tsx b/frontend/src/pages/FahrzeugForm.tsx index 24383cd..18c6398 100644 --- a/frontend/src/pages/FahrzeugForm.tsx +++ b/frontend/src/pages/FahrzeugForm.tsx @@ -12,9 +12,10 @@ import { TextField, Typography, } from '@mui/material'; -import { ArrowBack, Save } from '@mui/icons-material'; +import { Save } from '@mui/icons-material'; import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageHeader } from '../components/templates'; import GermanDateField from '../components/shared/GermanDateField'; import { vehiclesApi } from '../services/vehicles'; import { fahrzeugTypenApi } from '../services/fahrzeugTypen'; @@ -232,7 +233,7 @@ function FahrzeugForm() { {error} - @@ -243,18 +244,15 @@ function FahrzeugForm() { return ( - - - - {isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'} - + {saveError && {saveError}} diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index fc5d756..a90b928 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -10,10 +10,6 @@ import { Chip, CircularProgress, Container, - Dialog, - DialogActions, - DialogContent, - DialogTitle, Grid, IconButton, InputAdornment, @@ -63,6 +59,7 @@ import type { FahrzeugTyp } from '../types/checklist.types'; import { usePermissions } from '../hooks/usePermissions'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; +import { FormDialog } from '../components/templates'; // ── Status chip config ──────────────────────────────────────────────────────── @@ -447,11 +444,13 @@ function FahrzeugTypenSettings() { )} - setDialogOpen(false)} maxWidth="sm" fullWidth> - - {editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'} - - + setDialogOpen(false)} + onSubmit={handleSubmit} + title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'} + isSubmitting={isSaving} + > setForm((f) => ({ ...f, icon: e.target.value }))} placeholder="z.B. fire_truck" /> - - - - - - + ); } diff --git a/frontend/src/pages/IssueDetail.tsx b/frontend/src/pages/IssueDetail.tsx index 8254719..7839297 100644 --- a/frontend/src/pages/IssueDetail.tsx +++ b/frontend/src/pages/IssueDetail.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react'; import { - Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle, - DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, + Box, Typography, Paper, Chip, IconButton, Button, + TextField, MenuItem, Select, FormControl, InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent, List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction, } from '@mui/material'; @@ -20,6 +20,7 @@ import { usePermissionContext } from '../contexts/PermissionContext'; import { useAuth } from '../contexts/AuthContext'; import { issuesApi } from '../services/issues'; import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie, IssueDatei } from '../types/issue.types'; +import { ConfirmDialog, FormDialog, PageHeader } from '../components/templates'; // ── Helpers (copied from Issues.tsx) ── @@ -260,21 +261,16 @@ export default function IssueDetail() { return ( - {/* Header */} - - navigate('/issues')}> - - - - - {formatIssueId(issue)} — {issue.titel} - - - - + + } + /> {/* Info cards */} @@ -559,50 +555,38 @@ export default function IssueDetail() { {/* Reopen Dialog */} - setReopenOpen(false)} maxWidth="sm" fullWidth> - Issue wiedereröffnen - - setReopenComment(e.target.value)} - autoFocus - /> - - - - - - + setReopenOpen(false)} + onSubmit={handleReopen} + title="Issue wiedereröffnen" + submitLabel="Wiedereröffnen" + isSubmitting={updateMut.isPending} + maxWidth="sm" + > + setReopenComment(e.target.value)} + autoFocus + /> + {/* Delete Confirmation Dialog */} - setDeleteOpen(false)} maxWidth="xs" fullWidth> - Issue löschen - - Soll dieses Issue wirklich gelöscht werden? - - - - - - + setDeleteOpen(false)} + onConfirm={() => deleteMut.mutate()} + title="Issue löschen" + message="Soll dieses Issue wirklich gelöscht werden?" + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteMut.isPending} + /> ); } diff --git a/frontend/src/pages/IssueNeu.tsx b/frontend/src/pages/IssueNeu.tsx index 97d6417..4a2ad9e 100644 --- a/frontend/src/pages/IssueNeu.tsx +++ b/frontend/src/pages/IssueNeu.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { - Box, Typography, Paper, Button, TextField, MenuItem, Select, FormControl, - InputLabel, IconButton, Grid, Collapse, + Button, TextField, MenuItem, Select, FormControl, + InputLabel, Grid, Collapse, } from '@mui/material'; -import { ArrowBack, Add as AddIcon } from '@mui/icons-material'; +import { Add as AddIcon } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageHeader, FormLayout } from '../components/templates'; import { useNotification } from '../contexts/NotificationContext'; import { issuesApi } from '../services/issues'; import type { CreateIssuePayload } from '../types/issue.types'; @@ -52,99 +53,98 @@ export default function IssueNeu() { return ( - {/* Header */} - - navigate('/issues')}> - - - Neues Issue - + - - + + + + } + > + setForm({ ...form, titel: e.target.value })} + autoFocus + /> + + setForm({ ...form, titel: e.target.value })} - autoFocus + value={form.beschreibung || ''} + onChange={(e) => setForm({ ...form, beschreibung: e.target.value })} /> + + {!showDescription && ( + + )} - - setForm({ ...form, beschreibung: e.target.value })} - /> - - {!showDescription && ( - - )} - - - - - Typ - - - - - - Prioritaet - - - - - setForm({ ...form, faellig_am: e.target.value || null })} - InputLabelProps={{ shrink: true }} - /> - + + + + Typ + + - - - - - - - + + + Prioritaet + + + + + setForm({ ...form, faellig_am: e.target.value || null })} + InputLabelProps={{ shrink: true }} + /> + + +
); } diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx index 06370ff..c6d33ca 100644 --- a/frontend/src/pages/Issues.tsx +++ b/frontend/src/pages/Issues.tsx @@ -1,11 +1,11 @@ import { useState, useMemo } from 'react'; import { Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer, - TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle, - DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, + TableHead, TableRow, Paper, Chip, IconButton, Button, TextField, MenuItem, Select, FormControl, InputLabel, CircularProgress, FormControlLabel, Switch, Autocomplete, ToggleButtonGroup, ToggleButton, } from '@mui/material'; +// Note: Table/TableBody/etc still needed for IssueSettings tables import { Add as AddIcon, Delete as DeleteIcon, BugReport, FiberNew, HelpOutline, @@ -17,6 +17,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useSearchParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; +import { DataTable, FormDialog } from '../components/templates'; +import type { Column } from '../components/templates'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useAuth } from '../contexts/AuthContext'; @@ -91,68 +93,45 @@ function IssueTable({ }) { const navigate = useNavigate(); - if (issues.length === 0) { - return ( - - Keine Issues vorhanden - - ); - } + const columns: Column[] = [ + { key: 'id', label: 'ID', width: 80, render: (row) => formatIssueId(row) }, + { + key: 'titel', label: 'Titel', render: (row) => ( + + {getTypIcon(row.typ_icon, row.typ_farbe)} + {row.titel} + + ), + }, + { key: 'typ_name', label: 'Typ', render: (row) => }, + { + key: 'prioritaet', label: 'Priorität', render: (row) => ( + + + {getPrioLabel(priorities, row.prioritaet)} + + ), + }, + { + key: 'status', label: 'Status', render: (row) => ( + + ), + }, + { key: 'erstellt_von_name', label: 'Erstellt von', render: (row) => row.erstellt_von_name || '-' }, + { key: 'zugewiesen_an_name', label: 'Zugewiesen an', render: (row) => row.zugewiesen_an_name || '-' }, + { key: 'created_at', label: 'Erstellt am', render: (row) => formatDate(row.created_at) }, + ]; return ( - - - - - ID - Titel - Typ - Priorität - Status - Erstellt von - Zugewiesen an - Erstellt am - - - - {issues.map((issue) => ( - navigate(`/issues/${issue.id}`)} - > - {formatIssueId(issue)} - - - {getTypIcon(issue.typ_icon, issue.typ_farbe)} - {issue.titel} - - - - - - - - - {getPrioLabel(priorities, issue.prioritaet)} - - - - - - {issue.erstellt_von_name || '-'} - {issue.zugewiesen_an_name || '-'} - {formatDate(issue.created_at)} - - ))} - -
-
+ row.id} + onRowClick={(row) => navigate(`/issues/${row.id}`)} + emptyMessage="Keine Issues vorhanden" + searchEnabled={false} + paginationEnabled={false} + /> ); } @@ -509,9 +488,14 @@ function IssueSettings() { {/* ──── Create Status Dialog ──── */} - setStatusCreateOpen(false)} maxWidth="sm" fullWidth> - Neuer Status - + setStatusCreateOpen(false)} + onSubmit={() => createStatusMut.mutate(statusCreateData)} + title="Neuer Status" + submitLabel="Erstellen" + isSubmitting={createStatusMut.isPending} + > setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus /> setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} /> Farbe setStatusCreateData({ ...statusCreateData, farbe: v })} /> @@ -519,35 +503,39 @@ function IssueSettings() { setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" /> setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" /> setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" /> - - - + {/* ──── Create Priority Dialog ──── */} - setPrioCreateOpen(false)} maxWidth="sm" fullWidth> - Neue Priorität - + setPrioCreateOpen(false)} + onSubmit={() => createPrioMut.mutate(prioCreateData)} + title="Neue Priorität" + submitLabel="Erstellen" + isSubmitting={createPrioMut.isPending} + > setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus /> setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} /> Farbe setPrioCreateData({ ...prioCreateData, farbe: v })} /> setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} /> - - - + {/* ──── Create Kategorie Dialog ──── */} - setTypeCreateOpen(false)} maxWidth="sm" fullWidth> - Neue Kategorie - + setTypeCreateOpen(false)} + onSubmit={() => createTypeMut.mutate(typeCreateData)} + title="Neue Kategorie" + submitLabel="Erstellen" + isSubmitting={createTypeMut.isPending} + > setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus /> Übergeordnete Kategorie Icon Farbe setTypeCreateData({ ...typeCreateData, farbe: v })} /> setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" /> setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} /> - - - + ); diff --git a/frontend/src/pages/LieferantDetail.tsx b/frontend/src/pages/LieferantDetail.tsx index 7e2b42b..c18d21a 100644 --- a/frontend/src/pages/LieferantDetail.tsx +++ b/frontend/src/pages/LieferantDetail.tsx @@ -5,17 +5,9 @@ import { Paper, Button, TextField, - IconButton, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Grid, - Card, - CardContent, Skeleton, } from '@mui/material'; -import { ArrowBack, Edit as EditIcon, Delete as DeleteIcon, Save as SaveIcon, Close as CloseIcon } from '@mui/icons-material'; +import { Edit as EditIcon, Delete as DeleteIcon, Save as SaveIcon, Close as CloseIcon } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; @@ -23,6 +15,7 @@ import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { bestellungApi } from '../services/bestellung'; import type { LieferantFormData } from '../types/bestellung.types'; +import { ConfirmDialog, PageHeader, InfoGrid } from '../components/templates'; const emptyForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' }; @@ -151,11 +144,9 @@ export default function LieferantDetail() { } return ( - - navigate('/bestellungen?tab=1')}> - - - + + + @@ -169,39 +160,44 @@ export default function LieferantDetail() { return ( {/* ── Header ── */} - - navigate('/bestellungen?tab=1')}> - - - - {isNew ? 'Neuer Lieferant' : vendor!.name} - - {!isNew && canManage && !editMode && ( + - - + {!isNew && canManage && !editMode && ( + <> + + + + )} + {editMode && ( + <> + + + + )} - )} - {editMode && ( - <> - - - - )} - + } + /> {/* ── Content ── */} {editMode ? ( @@ -249,73 +245,31 @@ export default function LieferantDetail() { ) : ( - - - - Name - {vendor!.name} - - - - - Kontakt - {vendor!.kontakt_name || '–'} - - - - - E-Mail - - {vendor!.email ? {vendor!.email} : '–'} - - - - - - Telefon - {vendor!.telefon || '–'} - - - - - Website - - {vendor!.website ? {vendor!.website} : '–'} - - - - - - Adresse - {vendor!.adresse || '–'} - - - {vendor!.notizen && ( - - - Notizen - {vendor!.notizen} - - - )} - + {vendor!.email} : '–' }, + { label: 'Telefon', value: vendor!.telefon || '–' }, + { label: 'Website', value: vendor!.website ? {vendor!.website} : '–' }, + { label: 'Adresse', value: vendor!.adresse || '–' }, + ...(vendor!.notizen ? [{ label: 'Notizen', value: {vendor!.notizen}, fullWidth: true }] : []), + ]} + /> )} {/* ── Delete Dialog ── */} - setDeleteDialogOpen(false)}> - Lieferant löschen - - - Soll der Lieferant {vendor?.name} wirklich gelöscht werden? - - - - - - - + setDeleteDialogOpen(false)} + onConfirm={() => deleteVendor.mutate()} + title="Lieferant löschen" + message={Soll der Lieferant {vendor?.name} wirklich gelöscht werden?} + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteVendor.isPending} + /> ); } diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index 06d52c1..98f8014 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -61,6 +61,7 @@ import { UpdateMemberProfileData, } from '../types/member.types'; import type { Befoerderung, Untersuchung, Fahrgenehmigung, Ausbildung } from '../types/member.types'; +import { StatusChip, TabPanel, PageHeader } from '../components/templates'; import type { AtemschutzUebersicht } from '../types/atemschutz.types'; import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; @@ -77,23 +78,6 @@ function useCurrentUserId(): string | undefined { return (user as any)?.id; } -// ---------------------------------------------------------------- -// Tab panel helper -// ---------------------------------------------------------------- -interface TabPanelProps { - children?: React.ReactNode; - value: number; - index: number; -} - -function TabPanel({ children, value, index }: TabPanelProps) { - return ( - - ); -} - // ---------------------------------------------------------------- // Rank history timeline component // ---------------------------------------------------------------- @@ -432,14 +416,10 @@ function MitgliedDetail() { return ( - {/* Back button */} - + {/* Header card */} @@ -459,10 +439,10 @@ function MitgliedDetail() { {displayName} {profile?.status && ( - )} @@ -694,7 +674,7 @@ function MitgliedDetail() { /> + ? : null } /> - - - - - Foto - Name - Stundenbuchnr. - Dienstgrad - Funktion - Status - Eintrittsdatum - Telefon - - - - {loading ? ( - - - - - - ) : members.length === 0 ? ( - - - - - - Keine Mitglieder gefunden. - - - - - ) : ( - members.map((member) => { - const displayName = getMemberDisplayName(member); - const initials = [member.given_name?.[0], member.family_name?.[0]] - .filter(Boolean) - .join('') - .toUpperCase() || member.email[0].toUpperCase(); - - return ( - handleRowClick(member.id)} - sx={{ cursor: 'pointer' }} - aria-label={`Mitglied ${displayName} öffnen`} - > - {/* Avatar */} - - - {initials} - - - - {/* Name + email */} - - - {displayName} - - - {member.email} - - - - {/* Stundenbuchnr */} - - - {member.fdisk_standesbuch_nr ?? '—'} - - - - {/* Dienstgrad */} - - {member.dienstgrad ? ( - - ) : ( - - )} - - - {/* Funktion(en) */} - - - {Array.isArray(member.funktion) && member.funktion.length > 0 - ? member.funktion.map((f) => ( - - )) - : - } - - - - {/* Status */} - - {member.status ? ( - - ) : ( - - )} - - - {/* Eintrittsdatum */} - - - {member.eintrittsdatum - ? new Date(member.eintrittsdatum).toLocaleDateString('de-AT') - : '—'} - - - - {/* Telefon */} - - - {formatPhone(member.telefon_mobil)} - - - - ); - }) - )} - -
-
+ + columns={[ + { key: 'profile_picture_url', label: 'Foto', width: 56, sortable: false, searchable: false, render: (member) => { + const displayName = getMemberDisplayName(member); + const initials = [member.given_name?.[0], member.family_name?.[0]] + .filter(Boolean).join('').toUpperCase() || member.email[0].toUpperCase(); + return ( + + {initials} + + ); + }}, + { key: 'family_name', label: 'Name', render: (member) => { + const displayName = getMemberDisplayName(member); + return ( + + {displayName} + {member.email} + + ); + }}, + { key: 'fdisk_standesbuch_nr', label: 'Stundenbuchnr.', render: (member) => member.fdisk_standesbuch_nr ?? '—' }, + { key: 'dienstgrad', label: 'Dienstgrad', render: (member) => member.dienstgrad + ? + : + }, + { key: 'funktion', label: 'Funktion', sortable: false, render: (member) => ( + + {Array.isArray(member.funktion) && member.funktion.length > 0 + ? member.funktion.map((f) => ) + : + } + + )}, + { key: 'status', label: 'Status', render: (member) => member.status + ? + : + }, + { key: 'eintrittsdatum', label: 'Eintrittsdatum', render: (member) => member.eintrittsdatum + ? new Date(member.eintrittsdatum).toLocaleDateString('de-AT') + : '—' + }, + { key: 'telefon_mobil', label: 'Telefon', render: (member) => formatPhone(member.telefon_mobil) }, + ]} + data={members} + rowKey={(member) => member.id} + onRowClick={(member) => handleRowClick(member.id)} + isLoading={loading} + emptyMessage="Keine Mitglieder gefunden." + emptyIcon={} + searchEnabled={false} + paginationEnabled={false} + stickyHeader + />