new features, bookstack
This commit is contained in:
138
frontend/src/components/dashboard/BookStackRecentWidget.tsx
Normal file
138
frontend/src/components/dashboard/BookStackRecentWidget.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import { MenuBook } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { bookstackApi } from '../../services/bookstack';
|
||||
import type { BookStackPage } from '../../types/bookstack.types';
|
||||
|
||||
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
|
||||
page,
|
||||
showDivider,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
window.open(page.url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const relativeTime = page.updated_at
|
||||
? formatDistanceToNow(new Date(page.updated_at), { addSuffix: true, locale: de })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
py: 1.5,
|
||||
px: 1,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" noWrap>
|
||||
{page.name}
|
||||
</Typography>
|
||||
{page.book && (
|
||||
<Typography variant="body2" color="text.secondary" noWrap sx={{ mt: 0.25 }}>
|
||||
{page.book.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{relativeTime && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
|
||||
>
|
||||
{relativeTime}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{showDivider && <Divider />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BookStackRecentWidget: React.FC = () => {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['bookstack-recent'],
|
||||
queryFn: () => bookstackApi.getRecent(),
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const configured = data?.configured ?? true;
|
||||
const pages = data?.data ?? [];
|
||||
|
||||
if (!configured) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<MenuBook color="primary" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
BookStack — Neueste Seiten
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading && (
|
||||
<Box>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<Box key={n} sx={{ mb: 1.5 }}>
|
||||
<Skeleton variant="text" width="70%" height={22} />
|
||||
<Skeleton variant="text" width="50%" height={18} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
BookStack nicht erreichbar
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && pages.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Keine Seiten gefunden
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && pages.length > 0 && (
|
||||
<Box>
|
||||
{pages.map((page, index) => (
|
||||
<PageRow
|
||||
key={page.id}
|
||||
page={page}
|
||||
showDivider={index < pages.length - 1}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookStackRecentWidget;
|
||||
152
frontend/src/components/dashboard/BookStackSearchWidget.tsx
Normal file
152
frontend/src/components/dashboard/BookStackSearchWidget.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { Search, MenuBook } from '@mui/icons-material';
|
||||
import { bookstackApi } from '../../services/bookstack';
|
||||
import type { BookStackSearchResult } from '../../types/bookstack.types';
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({
|
||||
result,
|
||||
showDivider,
|
||||
}) => {
|
||||
const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 1,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" noWrap>
|
||||
{result.name}
|
||||
</Typography>
|
||||
{preview && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{preview}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{showDivider && <Divider />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BookStackSearchWidget: React.FC = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<BookStackSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [configured, setConfigured] = useState(true);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (!query.trim()) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const response = await bookstackApi.search(query.trim());
|
||||
setConfigured(response.configured);
|
||||
setResults(response.data);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
if (!configured) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<MenuBook color="primary" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
BookStack — Suche
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Suchbegriff eingeben..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{loading ? <CircularProgress size={16} /> : <Search fontSize="small" />}
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{!loading && query.trim() && results.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Keine Ergebnisse für „{query}"
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{results.map((result, index) => (
|
||||
<ResultRow
|
||||
key={result.id}
|
||||
result={result}
|
||||
showDivider={index < results.length - 1}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookStackSearchWidget;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { incidentsApi, EINSATZ_ARTEN, EINSATZ_ART_LABELS, CreateEinsatzPayload } from '../../services/incidents';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { toGermanDateTime, fromGermanDateTime } from '../../utils/dateInput';
|
||||
|
||||
interface CreateEinsatzDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,16 +22,16 @@ interface CreateEinsatzDialogProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// Default alarm_time = now (rounded to minute)
|
||||
function nowISO(): string {
|
||||
// Default alarm_time = now (rounded to minute) in DD.MM.YYYY HH:MM format
|
||||
function nowGerman(): string {
|
||||
const d = new Date();
|
||||
d.setSeconds(0, 0);
|
||||
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
|
||||
return toGermanDateTime(d.toISOString());
|
||||
}
|
||||
|
||||
const INITIAL_FORM: CreateEinsatzPayload & { alarm_time_local: string } = {
|
||||
alarm_time: '',
|
||||
alarm_time_local: nowISO(),
|
||||
alarm_time_local: nowGerman(),
|
||||
einsatz_art: 'Brand',
|
||||
einsatz_stichwort: '',
|
||||
strasse: '',
|
||||
@@ -47,7 +48,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
|
||||
onSuccess,
|
||||
}) => {
|
||||
const notification = useNotification();
|
||||
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowISO() });
|
||||
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -74,8 +75,9 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
// Convert local datetime string to UTC ISO string
|
||||
const isoLocal = fromGermanDateTime(form.alarm_time_local);
|
||||
const payload: CreateEinsatzPayload = {
|
||||
alarm_time: new Date(form.alarm_time_local).toISOString(),
|
||||
alarm_time: isoLocal ? new Date(isoLocal).toISOString() : new Date().toISOString(),
|
||||
einsatz_art: form.einsatz_art,
|
||||
einsatz_stichwort: form.einsatz_stichwort || null,
|
||||
strasse: form.strasse || null,
|
||||
@@ -88,7 +90,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
|
||||
|
||||
await incidentsApi.create(payload);
|
||||
notification.showSuccess('Einsatz erfolgreich angelegt');
|
||||
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
|
||||
setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Fehler beim Anlegen des Einsatzes';
|
||||
@@ -101,7 +103,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
|
||||
const handleClose = () => {
|
||||
if (loading) return;
|
||||
setError(null);
|
||||
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
|
||||
setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -132,7 +134,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
|
||||
<TextField
|
||||
label="Alarmzeit *"
|
||||
name="alarm_time_local"
|
||||
type="datetime-local"
|
||||
placeholder="TT.MM.JJJJ HH:MM"
|
||||
value={form.alarm_time_local}
|
||||
onChange={handleChange}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
@@ -141,7 +143,6 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
|
||||
helperText="DD.MM.YYYY HH:mm"
|
||||
inputProps={{
|
||||
'aria-label': 'Alarmzeit',
|
||||
// HTML datetime-local uses YYYY-MM-DDTHH:mm format
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
Reference in New Issue
Block a user