resolve issues with new features
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Tabs, Tab, Typography } from '@mui/material';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ServiceManagerTab from '../components/admin/ServiceManagerTab';
|
||||
import SystemHealthTab from '../components/admin/SystemHealthTab';
|
||||
@@ -21,8 +21,19 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
const ADMIN_TAB_COUNT = 6;
|
||||
|
||||
function AdminDashboard() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState(() => {
|
||||
const t = Number(searchParams.get('tab'));
|
||||
return t >= 0 && t < ADMIN_TAB_COUNT ? t : 0;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const t = Number(searchParams.get('tab'));
|
||||
if (t >= 0 && t < ADMIN_TAB_COUNT) setTab(t);
|
||||
}, [searchParams]);
|
||||
const { user } = useAuth();
|
||||
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Timer,
|
||||
Info,
|
||||
ExpandMore,
|
||||
PictureAsPdf as PdfIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
@@ -75,6 +76,10 @@ function AdminSettings() {
|
||||
adminServices: 15,
|
||||
});
|
||||
|
||||
// State for PDF header/footer
|
||||
const [pdfHeader, setPdfHeader] = useState('');
|
||||
const [pdfFooter, setPdfFooter] = useState('');
|
||||
|
||||
// Fetch all settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
@@ -103,6 +108,11 @@ function AdminSettings() {
|
||||
adminServices: intervalsSetting.value.adminServices ?? 15,
|
||||
});
|
||||
}
|
||||
|
||||
const pdfHeaderSetting = settings.find((s) => s.key === 'pdf_header');
|
||||
if (pdfHeaderSetting?.value != null) setPdfHeader(pdfHeaderSetting.value);
|
||||
const pdfFooterSetting = settings.find((s) => s.key === 'pdf_footer');
|
||||
if (pdfFooterSetting?.value != null) setPdfFooter(pdfFooterSetting.value);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@@ -131,6 +141,33 @@ function AdminSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation for saving PDF settings
|
||||
const pdfHeaderMutation = useMutation({
|
||||
mutationFn: (value: string) => settingsApi.update('pdf_header', value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['pdf-settings'] });
|
||||
},
|
||||
});
|
||||
const pdfFooterMutation = useMutation({
|
||||
mutationFn: (value: string) => settingsApi.update('pdf_footer', value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['pdf-settings'] });
|
||||
},
|
||||
});
|
||||
const handleSavePdfSettings = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
pdfHeaderMutation.mutateAsync(pdfHeader),
|
||||
pdfFooterMutation.mutateAsync(pdfFooter),
|
||||
]);
|
||||
showSuccess('PDF-Einstellungen gespeichert');
|
||||
} catch {
|
||||
showError('Fehler beim Speichern der PDF-Einstellungen');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
@@ -373,7 +410,53 @@ function AdminSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Info */}
|
||||
{/* Section 3: PDF Settings */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<PdfIcon color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">PDF-Einstellungen</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Kopf- und Fußzeile für Kalender-PDF-Exporte. Zeilenumbrüche werden übernommen, <strong>**fett**</strong> erzeugt fettgedruckten Text.
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="PDF Kopfzeile"
|
||||
value={pdfHeader}
|
||||
onChange={(e) => setPdfHeader(e.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
label="PDF Fußzeile"
|
||||
value={pdfFooter}
|
||||
onChange={(e) => setPdfFooter(e.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<Box>
|
||||
<Button
|
||||
onClick={handleSavePdfSettings}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={pdfHeaderMutation.isPending || pdfFooterMutation.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 4: Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { ArrowBack, Save } from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
|
||||
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
|
||||
import { equipmentApi } from '../services/equipment';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import {
|
||||
@@ -190,6 +190,12 @@ function AusruestungForm() {
|
||||
errors.pruef_intervall_monate = 'Prüfintervall muss zwischen 1 und 120 Monaten liegen.';
|
||||
}
|
||||
}
|
||||
if (form.letzte_pruefung_am && !isValidGermanDate(form.letzte_pruefung_am)) {
|
||||
errors.letzte_pruefung_am = 'Ungültiges Datum. Format: TT.MM.JJJJ';
|
||||
}
|
||||
if (form.naechste_pruefung_am && !isValidGermanDate(form.naechste_pruefung_am)) {
|
||||
errors.naechste_pruefung_am = 'Ungültiges Datum. Format: TT.MM.JJJJ';
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
@@ -466,6 +472,15 @@ function AusruestungForm() {
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={form.letzte_pruefung_am}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))}
|
||||
onBlur={() => {
|
||||
if (form.letzte_pruefung_am && !isValidGermanDate(form.letzte_pruefung_am)) {
|
||||
setFieldErrors((prev) => ({ ...prev, letzte_pruefung_am: 'Ungültiges Datum. Format: TT.MM.JJJJ' }));
|
||||
} else {
|
||||
setFieldErrors((prev) => ({ ...prev, letzte_pruefung_am: undefined }));
|
||||
}
|
||||
}}
|
||||
error={Boolean(fieldErrors.letzte_pruefung_am)}
|
||||
helperText={fieldErrors.letzte_pruefung_am}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
@@ -476,6 +491,15 @@ function AusruestungForm() {
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={form.naechste_pruefung_am}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))}
|
||||
onBlur={() => {
|
||||
if (form.naechste_pruefung_am && !isValidGermanDate(form.naechste_pruefung_am)) {
|
||||
setFieldErrors((prev) => ({ ...prev, naechste_pruefung_am: 'Ungültiges Datum. Format: TT.MM.JJJJ' }));
|
||||
} else {
|
||||
setFieldErrors((prev) => ({ ...prev, naechste_pruefung_am: undefined }));
|
||||
}
|
||||
}}
|
||||
error={Boolean(fieldErrors.naechste_pruefung_am)}
|
||||
helperText={fieldErrors.naechste_pruefung_am}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -26,6 +26,7 @@ import LinksWidget from '../components/dashboard/LinksWidget';
|
||||
import BannerWidget from '../components/dashboard/BannerWidget';
|
||||
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
||||
import { preferencesApi } from '../services/settings';
|
||||
import { configApi } from '../services/config';
|
||||
import { WidgetKey } from '../constants/widgets';
|
||||
|
||||
function Dashboard() {
|
||||
@@ -44,6 +45,16 @@ function Dashboard() {
|
||||
queryFn: preferencesApi.get,
|
||||
});
|
||||
|
||||
const { data: externalLinks } = useQuery({
|
||||
queryKey: ['external-links'],
|
||||
queryFn: () => configApi.getExternalLinks(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
const linkCollections = (externalLinks?.linkCollections ?? []).filter(
|
||||
(c) => c.links.length > 0
|
||||
);
|
||||
|
||||
const widgetVisible = (key: WidgetKey) => {
|
||||
return preferences?.widgets?.[key] !== false;
|
||||
};
|
||||
@@ -187,15 +198,15 @@ function Dashboard() {
|
||||
|
||||
{/* Information Group */}
|
||||
<WidgetGroup title="Information" gridColumn="1 / -1">
|
||||
{widgetVisible('links') && (
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
|
||||
{widgetVisible('links') && linkCollections.map((collection, idx) => (
|
||||
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + idx * 40}ms` }}>
|
||||
<Box>
|
||||
<LinksWidget />
|
||||
<LinksWidget collection={collection} />
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
))}
|
||||
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '790ms' }}>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + linkCollections.length * 40}ms` }}>
|
||||
<Box>
|
||||
<BannerWidget />
|
||||
</Box>
|
||||
|
||||
@@ -408,7 +408,7 @@ function FahrzeugBuchungen() {
|
||||
align="center"
|
||||
sx={{
|
||||
fontWeight: isToday(day) ? 700 : 400,
|
||||
color: isToday(day) ? 'primary.main' : 'text.secondary',
|
||||
color: isToday(day) ? 'primary.main' : 'text.primary',
|
||||
bgcolor: isToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -68,7 +68,7 @@ import {
|
||||
ViewWeek as ViewWeekIcon,
|
||||
Warning,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDate, isValidGermanDateTime } from '../utils/dateInput';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -76,6 +76,7 @@ import { useNotification } from '../contexts/NotificationContext';
|
||||
import { trainingApi } from '../services/training';
|
||||
import { eventsApi } from '../services/events';
|
||||
import { bookingApi, fetchVehicles } from '../services/bookings';
|
||||
import { configApi, type PdfSettings } from '../services/config';
|
||||
import type {
|
||||
UebungListItem,
|
||||
UebungTyp,
|
||||
@@ -622,6 +623,62 @@ function DayPopover({
|
||||
// PDF Export helper
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render text with basic markdown (**bold**) and line breaks into a jsPDF doc.
|
||||
* Returns the final Y position after rendering.
|
||||
*/
|
||||
function renderMarkdownText(
|
||||
doc: import('jspdf').jsPDF,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
options?: { fontSize?: number; maxWidth?: number },
|
||||
): number {
|
||||
const fontSize = options?.fontSize ?? 9;
|
||||
const lineHeight = fontSize * 0.5; // ~mm per line
|
||||
doc.setFontSize(fontSize);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
|
||||
const lines = text.split('\n');
|
||||
let curY = y;
|
||||
|
||||
for (const line of lines) {
|
||||
// Split by ** to alternate normal/bold
|
||||
const segments = line.split('**');
|
||||
let curX = x;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (!seg) continue;
|
||||
const isBold = i % 2 === 1;
|
||||
doc.setFont('helvetica', isBold ? 'bold' : 'normal');
|
||||
doc.text(seg, curX, curY);
|
||||
curX += doc.getTextWidth(seg);
|
||||
}
|
||||
curY += lineHeight;
|
||||
}
|
||||
|
||||
// Reset font
|
||||
doc.setFont('helvetica', 'normal');
|
||||
return curY;
|
||||
}
|
||||
|
||||
let _pdfSettingsCache: PdfSettings | null = null;
|
||||
let _pdfSettingsCacheTime = 0;
|
||||
|
||||
async function fetchPdfSettings(): Promise<PdfSettings> {
|
||||
// Cache for 30 seconds to avoid fetching on every export click
|
||||
if (_pdfSettingsCache && Date.now() - _pdfSettingsCacheTime < 30_000) {
|
||||
return _pdfSettingsCache;
|
||||
}
|
||||
try {
|
||||
_pdfSettingsCache = await configApi.getPdfSettings();
|
||||
_pdfSettingsCacheTime = Date.now();
|
||||
return _pdfSettingsCache;
|
||||
} catch {
|
||||
return { pdf_header: '', pdf_footer: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePdf(
|
||||
year: number,
|
||||
month: number,
|
||||
@@ -635,6 +692,8 @@ async function generatePdf(
|
||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||
const monthLabel = MONTH_LABELS[month];
|
||||
|
||||
const pdfSettings = await fetchPdfSettings();
|
||||
|
||||
// Header bar
|
||||
doc.setFillColor(183, 28, 28); // fire-red
|
||||
doc.rect(0, 0, 297, 18, 'F');
|
||||
@@ -646,6 +705,12 @@ async function generatePdf(
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Feuerwehr Rems', 250, 12);
|
||||
|
||||
// Custom header text
|
||||
let tableStartY = 22;
|
||||
if (pdfSettings.pdf_header) {
|
||||
tableStartY = renderMarkdownText(doc, pdfSettings.pdf_header, 10, 24, { fontSize: 9 }) + 2;
|
||||
}
|
||||
|
||||
// Build combined list (same logic as CombinedListView)
|
||||
type ListEntry =
|
||||
| { kind: 'training'; item: UebungListItem }
|
||||
@@ -681,7 +746,7 @@ async function generatePdf(
|
||||
autoTable(doc, {
|
||||
head: [['Datum', 'Uhrzeit', 'Titel', 'Kategorie/Typ', 'Ort']],
|
||||
body: rows,
|
||||
startY: 22,
|
||||
startY: tableStartY,
|
||||
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
|
||||
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||
margin: { left: 10, right: 10 },
|
||||
@@ -693,6 +758,11 @@ async function generatePdf(
|
||||
3: { cellWidth: 40 },
|
||||
4: { cellWidth: 60 },
|
||||
},
|
||||
didDrawPage: pdfSettings.pdf_footer
|
||||
? () => {
|
||||
renderMarkdownText(doc, pdfSettings.pdf_footer, 10, doc.internal.pageSize.height - 12, { fontSize: 8 });
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`;
|
||||
@@ -717,6 +787,8 @@ async function generateBookingsPdf(
|
||||
const endLabel = fnsFormat(weekEnd, 'dd.MM.yyyy');
|
||||
const kwLabel = `KW ${fnsFormat(weekStart, 'w')}`;
|
||||
|
||||
const pdfSettings = await fetchPdfSettings();
|
||||
|
||||
// Header bar
|
||||
doc.setFillColor(183, 28, 28); // fire-red
|
||||
doc.rect(0, 0, 297, 18, 'F');
|
||||
@@ -728,6 +800,12 @@ async function generateBookingsPdf(
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Feuerwehr Rems', 250, 12);
|
||||
|
||||
// Custom header text
|
||||
let tableStartY = 22;
|
||||
if (pdfSettings.pdf_header) {
|
||||
tableStartY = renderMarkdownText(doc, pdfSettings.pdf_header, 10, 24, { fontSize: 9 }) + 2;
|
||||
}
|
||||
|
||||
const formatDt = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return fnsFormat(d, 'dd.MM.yyyy HH:mm');
|
||||
@@ -745,7 +823,7 @@ async function generateBookingsPdf(
|
||||
autoTable(doc, {
|
||||
head: [['Fahrzeug', 'Titel', 'Beginn', 'Ende', 'Art']],
|
||||
body: rows,
|
||||
startY: 22,
|
||||
startY: tableStartY,
|
||||
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
|
||||
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||
margin: { left: 10, right: 10 },
|
||||
@@ -757,6 +835,11 @@ async function generateBookingsPdf(
|
||||
3: { cellWidth: 38 },
|
||||
4: { cellWidth: 35 },
|
||||
},
|
||||
didDrawPage: pdfSettings.pdf_footer
|
||||
? () => {
|
||||
renderMarkdownText(doc, pdfSettings.pdf_footer, 10, doc.internal.pageSize.height - 12, { fontSize: 8 });
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const filename = `fahrzeugbuchungen_${fnsFormat(weekStart, 'yyyy-MM-dd')}.pdf`;
|
||||
@@ -1557,6 +1640,7 @@ function VeranstaltungFormDialog({
|
||||
|
||||
export default function Kalender() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
const notification = useNotification();
|
||||
const theme = useTheme();
|
||||
@@ -1569,7 +1653,15 @@ export default function Kalender() {
|
||||
const canCreateBookings = !!user;
|
||||
|
||||
// ── Tab ─────────────────────────────────────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
const t = Number(searchParams.get('tab'));
|
||||
return t >= 0 && t < 2 ? t : 0;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const t = Number(searchParams.get('tab'));
|
||||
if (t >= 0 && t < 2) setActiveTab(t);
|
||||
}, [searchParams]);
|
||||
|
||||
// ── Calendar tab state ───────────────────────────────────────────────────────
|
||||
const today = new Date();
|
||||
|
||||
Reference in New Issue
Block a user