feat(geplante-nachrichten): scheduled message rule engine with bot delivery, admin UI, and manual trigger
This commit is contained in:
@@ -56,6 +56,10 @@ import Issues from './pages/Issues';
|
||||
import IssueDetail from './pages/IssueDetail';
|
||||
import IssueNeu from './pages/IssueNeu';
|
||||
import Chat from './pages/Chat';
|
||||
import GeplanteMachrichten from './pages/GeplanteMachrichten';
|
||||
import GeplanteMachrichtenDetail from './pages/GeplanteMachrichtenDetail';
|
||||
import GeplanteMachrichtenNeu from './pages/GeplanteMachrichtenNeu';
|
||||
import GeplanteMachrichtenBearbeiten from './pages/GeplanteMachrichtenBearbeiten';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AdminSettings from './pages/AdminSettings';
|
||||
import NotFound from './pages/NotFound';
|
||||
@@ -510,6 +514,38 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/geplante-nachrichten"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GeplanteMachrichten />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/geplante-nachrichten/neu"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GeplanteMachrichtenNeu />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/geplante-nachrichten/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GeplanteMachrichtenDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/geplante-nachrichten/:id/bearbeiten"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GeplanteMachrichtenBearbeiten />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
|
||||
@@ -136,8 +136,8 @@ export default function ModuleSettingsIssues() {
|
||||
});
|
||||
const deleteStatusMut = useMutation({
|
||||
mutationFn: (id: number) => issuesApi.deleteStatus(id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status deaktiviert'); },
|
||||
onError: () => showError('Fehler beim Deaktivieren'),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status gelöscht'); },
|
||||
onError: () => showError('Fehler beim Löschen'),
|
||||
});
|
||||
|
||||
// ── Priority mutations ──
|
||||
@@ -153,8 +153,8 @@ export default function ModuleSettingsIssues() {
|
||||
});
|
||||
const deletePrioMut = useMutation({
|
||||
mutationFn: (id: number) => issuesApi.deletePriority(id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität deaktiviert'); },
|
||||
onError: () => showError('Fehler beim Deaktivieren'),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität gelöscht'); },
|
||||
onError: () => showError('Fehler beim Löschen'),
|
||||
});
|
||||
|
||||
// ── Type mutations ──
|
||||
@@ -170,8 +170,8 @@ export default function ModuleSettingsIssues() {
|
||||
});
|
||||
const deleteTypeMut = useMutation({
|
||||
mutationFn: (id: number) => issuesApi.deleteType(id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie deaktiviert'); },
|
||||
onError: () => showError('Fehler beim Deaktivieren'),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie gelöscht'); },
|
||||
onError: () => showError('Fehler beim Löschen'),
|
||||
});
|
||||
|
||||
const flatTypes = useMemo(() => {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { toolConfigApi } from '../../services/toolConfig';
|
||||
import type { ToolConfig } from '../../types/toolConfig.types';
|
||||
import { scheduledMessagesApi } from '../../services/scheduledMessages';
|
||||
|
||||
export default function ToolSettingsNextcloud() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -28,11 +30,16 @@ export default function ToolSettingsNextcloud() {
|
||||
});
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [botUsername, setBotUsername] = useState('');
|
||||
const [botAppPassword, setBotAppPassword] = useState('');
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latencyMs: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setUrl(data.url ?? '');
|
||||
const d = data as unknown as Record<string, unknown>;
|
||||
setBotUsername((d.bot_username as string) ?? '');
|
||||
setBotAppPassword((d.bot_app_password as string) ?? '');
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
@@ -45,6 +52,19 @@ export default function ToolSettingsNextcloud() {
|
||||
onError: () => showError('Fehler beim Speichern der Konfiguration'),
|
||||
});
|
||||
|
||||
const saveBotMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolConfigApi.update('nextcloud', {
|
||||
bot_username: botUsername,
|
||||
bot_app_password: botAppPassword,
|
||||
} as unknown as Partial<ToolConfig>),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tool-config', 'nextcloud'] });
|
||||
showSuccess('Bot-Konfiguration gespeichert');
|
||||
},
|
||||
onError: () => showError('Fehler beim Speichern der Bot-Konfiguration'),
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolConfigApi.test('nextcloud', { url: url || undefined }),
|
||||
@@ -61,58 +81,108 @@ export default function ToolSettingsNextcloud() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Nextcloud</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Stack spacing={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Nextcloud</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{!isFeatureEnabled('nextcloud') && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Dieses Werkzeug befindet sich im Wartungsmodus.
|
||||
</Alert>
|
||||
)}
|
||||
{!isFeatureEnabled('nextcloud') && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Dieses Werkzeug befindet sich im Wartungsmodus.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="https://nextcloud.example.com"
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
size="small"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setTestResult(null);
|
||||
testMutation.mutate();
|
||||
}}
|
||||
disabled={testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Chip
|
||||
label={testResult.success ? `Verbunden (${testResult.latencyMs}ms)` : `Fehler: ${testResult.message}`}
|
||||
color={testResult.success ? 'success' : 'error'}
|
||||
fullWidth
|
||||
placeholder="https://nextcloud.example.com"
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setTestResult(null);
|
||||
testMutation.mutate();
|
||||
}}
|
||||
disabled={testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Chip
|
||||
label={testResult.success ? `Verbunden (${testResult.latencyMs}ms)` : `Fehler: ${testResult.message}`}
|
||||
color={testResult.success ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom>Bot-Konto</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Bot-Benutzername"
|
||||
value={botUsername}
|
||||
onChange={(e) => setBotUsername(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
label="Bot-App-Passwort"
|
||||
type="password"
|
||||
value={botAppPassword}
|
||||
onChange={(e) => setBotAppPassword(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => saveBotMutation.mutate()}
|
||||
disabled={saveBotMutation.isPending}
|
||||
>
|
||||
Bot speichern
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await scheduledMessagesApi.getRooms();
|
||||
if (result.configured) {
|
||||
showSuccess(`Verbindung erfolgreich (${result.data?.length ?? 0} Räume)`);
|
||||
} else {
|
||||
showError('Bot nicht konfiguriert');
|
||||
}
|
||||
} catch {
|
||||
showError('Verbindungstest fehlgeschlagen');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Verbindung testen
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
AssignmentTurnedIn,
|
||||
AccountBalance as AccountBalanceIcon,
|
||||
Checkroom as CheckroomIcon,
|
||||
Schedule,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -134,6 +135,12 @@ const baseNavigationItems: NavigationItem[] = [
|
||||
path: '/issues',
|
||||
permission: 'issues:view_own',
|
||||
},
|
||||
{
|
||||
text: 'Geplante Nachrichten',
|
||||
icon: <Schedule />,
|
||||
path: '/geplante-nachrichten',
|
||||
permission: 'scheduled_messages:view',
|
||||
},
|
||||
];
|
||||
|
||||
const adminItem: NavigationItem = {
|
||||
|
||||
@@ -3,7 +3,6 @@ export const WIDGETS = [
|
||||
{ key: 'equipment', label: 'Ausrüstung', defaultVisible: true },
|
||||
{ key: 'atemschutz', label: 'Atemschutz', defaultVisible: true },
|
||||
{ key: 'events', label: 'Termine', defaultVisible: true },
|
||||
{ key: 'nextcloudTalk', label: 'Nextcloud Talk', defaultVisible: true },
|
||||
{ key: 'bookstackRecent', label: 'Wissen — Neueste', defaultVisible: true },
|
||||
{ key: 'bookstackSearch', label: 'Wissen — Suche', defaultVisible: true },
|
||||
{ key: 'vikunjaTasks', label: 'Vikunja Aufgaben', defaultVisible: true },
|
||||
|
||||
217
frontend/src/pages/GeplanteMachrichten.tsx
Normal file
217
frontend/src/pages/GeplanteMachrichten.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
Switch,
|
||||
Alert,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon } from '@mui/icons-material';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { scheduledMessagesApi } from '../services/scheduledMessages';
|
||||
import type { MessageType, ScheduledMessageRule } from '../types/scheduledMessages.types';
|
||||
|
||||
const MESSAGE_TYPE_LABELS: Record<MessageType, string> = {
|
||||
event_summary: 'Terminübersicht',
|
||||
birthday_list: 'Geburtstagsliste',
|
||||
dienstjubilaeen: 'Dienstjubiläen',
|
||||
fahrzeug_status: 'Fahrzeugstatus',
|
||||
fahrzeug_event: 'Fahrzeug außer Dienst',
|
||||
bestellungen: 'Offene Bestellungen',
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
day_of_week: 'Wochentag',
|
||||
days_before_month_start: 'Vor Monatsbeginn',
|
||||
event: 'Ereignis',
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
||||
|
||||
function triggerSummary(rule: ScheduledMessageRule): string {
|
||||
if (rule.trigger_mode === 'event') return 'Bei Ereignis';
|
||||
if (rule.trigger_mode === 'day_of_week') {
|
||||
const day = rule.day_of_week != null ? WEEKDAY_LABELS[rule.day_of_week] : '?';
|
||||
const time = rule.send_time ?? '';
|
||||
return `${day} ${time}`.trim();
|
||||
}
|
||||
if (rule.trigger_mode === 'days_before_month_start') {
|
||||
return `${rule.days_before_month_start ?? '?'} Tage vor Monatsbeginn`;
|
||||
}
|
||||
return TRIGGER_LABELS[rule.trigger_mode] ?? rule.trigger_mode;
|
||||
}
|
||||
|
||||
export default function GeplanteMachrichten() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canEdit = hasPermission('scheduled_messages:edit');
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['scheduled-messages'],
|
||||
queryFn: scheduledMessagesApi.getAll,
|
||||
});
|
||||
|
||||
const rules = data?.data ?? [];
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const handleToggleActive = async (rule: ScheduledMessageRule) => {
|
||||
await scheduledMessagesApi.update(rule.id, { active: !rule.active });
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-messages'] });
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await scheduledMessagesApi.delete(id);
|
||||
setDeleteConfirmId(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-messages'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5">Geplante Nachrichten</Typography>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => navigate('/geplante-nachrichten/neu')}
|
||||
>
|
||||
Neue Regel
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Fehler beim Laden der Regeln.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Typ</TableCell>
|
||||
<TableCell>Trigger</TableCell>
|
||||
<TableCell>Zielraum</TableCell>
|
||||
<TableCell>Abonnierbar</TableCell>
|
||||
<TableCell>Aktiv</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<TableRow key={i}>
|
||||
{[1, 2, 3, 4, 5, 6, 7].map((j) => (
|
||||
<TableCell key={j}><Skeleton /></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : rules.length === 0 ? (
|
||||
<Typography sx={{ textAlign: 'center', py: 4 }} color="text.secondary">
|
||||
Keine Regeln konfiguriert
|
||||
</Typography>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Typ</TableCell>
|
||||
<TableCell>Trigger</TableCell>
|
||||
<TableCell>Zielraum</TableCell>
|
||||
<TableCell>Abonnierbar</TableCell>
|
||||
<TableCell>Aktiv</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rules.map((rule) => (
|
||||
<TableRow
|
||||
key={rule.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/geplante-nachrichten/${rule.id}`)}
|
||||
>
|
||||
<TableCell>{rule.name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={MESSAGE_TYPE_LABELS[rule.message_type] ?? rule.message_type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{triggerSummary(rule)}</TableCell>
|
||||
<TableCell>{rule.target_room_name ?? rule.target_room_token}</TableCell>
|
||||
<TableCell>{rule.subscribable ? 'Ja' : 'Nein'}</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={rule.active}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => handleToggleActive(rule)}
|
||||
size="small"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
|
||||
{canEdit && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/geplante-nachrichten/${rule.id}/bearbeiten`)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canEdit && (
|
||||
deleteConfirmId === rule.id ? (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
>
|
||||
Löschen?
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirmId(rule.id)}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
7
frontend/src/pages/GeplanteMachrichtenBearbeiten.tsx
Normal file
7
frontend/src/pages/GeplanteMachrichtenBearbeiten.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import GeplanteMachrichtenForm from './GeplanteMachrichtenForm';
|
||||
|
||||
export default function GeplanteMachrichtenBearbeiten() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <GeplanteMachrichtenForm ruleId={id} />;
|
||||
}
|
||||
252
frontend/src/pages/GeplanteMachrichtenDetail.tsx
Normal file
252
frontend/src/pages/GeplanteMachrichtenDetail.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Edit as EditIcon, Send as SendIcon } 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';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { scheduledMessagesApi } from '../services/scheduledMessages';
|
||||
import type { MessageType } from '../types/scheduledMessages.types';
|
||||
|
||||
const MESSAGE_TYPE_LABELS: Record<MessageType, string> = {
|
||||
event_summary: 'Terminübersicht',
|
||||
birthday_list: 'Geburtstagsliste',
|
||||
dienstjubilaeen: 'Dienstjubiläen',
|
||||
fahrzeug_status: 'Fahrzeugstatus',
|
||||
fahrzeug_event: 'Fahrzeug außer Dienst',
|
||||
bestellungen: 'Offene Bestellungen',
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
day_of_week: 'Wochentag + Uhrzeit',
|
||||
days_before_month_start: 'N Tage vor Monatsbeginn',
|
||||
event: 'Ereignis',
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
||||
|
||||
const WINDOW_LABELS: Record<string, string> = {
|
||||
rolling: 'Rollierend',
|
||||
calendar_month: 'Nächster Kalendermonat',
|
||||
};
|
||||
|
||||
export default function GeplanteMachrichtenDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const { user } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const canEdit = hasPermission('scheduled_messages:edit');
|
||||
const canSubscribe = hasPermission('scheduled_messages:subscribe');
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['scheduled-messages', id],
|
||||
queryFn: () => scheduledMessagesApi.getById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const rule = data?.data;
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: () => scheduledMessagesApi.trigger(id!),
|
||||
onSuccess: () => showSuccess('Nachricht wurde gesendet'),
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Fehler beim Senden';
|
||||
showError(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const canUserSubscribe =
|
||||
canSubscribe &&
|
||||
rule?.subscribable &&
|
||||
(rule.allowed_groups.length === 0 ||
|
||||
rule.allowed_groups.some((g) => user?.groups?.includes(g)));
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
if (!id) return;
|
||||
await scheduledMessagesApi.subscribe(id, '');
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-messages', id] });
|
||||
};
|
||||
|
||||
const handleUnsubscribe = async () => {
|
||||
if (!id) return;
|
||||
await scheduledMessagesApi.unsubscribe(id);
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-messages', id] });
|
||||
};
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return 'Noch nie gesendet';
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/geplante-nachrichten')}>
|
||||
Zurück
|
||||
</Button>
|
||||
{canEdit && rule && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={triggerMutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||
onClick={() => triggerMutation.mutate()}
|
||||
disabled={triggerMutation.isPending}
|
||||
>
|
||||
Jetzt senden
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => navigate(`/geplante-nachrichten/${id}/bearbeiten`)}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isError && (
|
||||
<Alert severity="error">Fehler beim Laden der Regel.</Alert>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{rule && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>{rule.name}</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 1.5, mb: 2 }}>
|
||||
<Typography color="text.secondary">Typ</Typography>
|
||||
<Chip
|
||||
label={MESSAGE_TYPE_LABELS[rule.message_type] ?? rule.message_type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<Typography color="text.secondary">Aktiv</Typography>
|
||||
<Chip
|
||||
label={rule.active ? 'Ja' : 'Nein'}
|
||||
size="small"
|
||||
color={rule.active ? 'success' : 'default'}
|
||||
/>
|
||||
|
||||
<Typography color="text.secondary">Trigger</Typography>
|
||||
<Typography>{TRIGGER_LABELS[rule.trigger_mode] ?? rule.trigger_mode}</Typography>
|
||||
|
||||
{rule.trigger_mode === 'day_of_week' && (
|
||||
<>
|
||||
<Typography color="text.secondary">Wochentag</Typography>
|
||||
<Typography>
|
||||
{rule.day_of_week != null ? WEEKDAY_LABELS[rule.day_of_week] : '-'}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Uhrzeit</Typography>
|
||||
<Typography>{rule.send_time ?? '-'}</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{rule.trigger_mode === 'days_before_month_start' && (
|
||||
<>
|
||||
<Typography color="text.secondary">Tage vor Monatsbeginn</Typography>
|
||||
<Typography>{rule.days_before_month_start ?? '-'}</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{rule.trigger_mode !== 'event' && (
|
||||
<>
|
||||
<Typography color="text.secondary">Zeitfenster</Typography>
|
||||
<Typography>
|
||||
{rule.window_mode ? WINDOW_LABELS[rule.window_mode] ?? rule.window_mode : '-'}
|
||||
{rule.window_mode === 'rolling' && rule.window_days != null
|
||||
? ` (${rule.window_days} Tage)`
|
||||
: ''}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography color="text.secondary">Zielraum</Typography>
|
||||
<Typography>{rule.target_room_name ?? rule.target_room_token}</Typography>
|
||||
|
||||
<Typography color="text.secondary">Abonnierbar</Typography>
|
||||
<Typography>{rule.subscribable ? 'Ja' : 'Nein'}</Typography>
|
||||
|
||||
{rule.allowed_groups.length > 0 && (
|
||||
<>
|
||||
<Typography color="text.secondary">Gruppen</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{rule.allowed_groups.map((g) => (
|
||||
<Chip key={g} label={g} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography color="text.secondary">Zuletzt gesendet</Typography>
|
||||
<Typography>{formatDate(rule.last_sent_at)}</Typography>
|
||||
|
||||
{canEdit && rule.subscriber_count != null && (
|
||||
<>
|
||||
<Typography color="text.secondary">Abonnenten</Typography>
|
||||
<Typography>{rule.subscriber_count}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography color="text.secondary" gutterBottom>Vorlage</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{rule.template}
|
||||
</Box>
|
||||
|
||||
{canUserSubscribe && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{rule.is_subscribed ? (
|
||||
<Button variant="outlined" color="error" onClick={handleUnsubscribe}>
|
||||
Abbestellen
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" onClick={handleSubscribe}>
|
||||
Abonnieren
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
426
frontend/src/pages/GeplanteMachrichtenForm.tsx
Normal file
426
frontend/src/pages/GeplanteMachrichtenForm.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
Switch,
|
||||
Chip,
|
||||
Paper,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { scheduledMessagesApi } from '../services/scheduledMessages';
|
||||
import type { MessageType, TriggerMode, WindowMode } from '../types/scheduledMessages.types';
|
||||
|
||||
const MESSAGE_TYPE_LABELS: Record<MessageType, string> = {
|
||||
event_summary: 'Terminübersicht',
|
||||
birthday_list: 'Geburtstagsliste',
|
||||
dienstjubilaeen: 'Dienstjubiläen',
|
||||
fahrzeug_status: 'Fahrzeugstatus',
|
||||
fahrzeug_event: 'Fahrzeug außer Dienst',
|
||||
bestellungen: 'Offene Bestellungen',
|
||||
};
|
||||
|
||||
const MESSAGE_TYPE_OPTIONS: MessageType[] = [
|
||||
'event_summary',
|
||||
'birthday_list',
|
||||
'dienstjubilaeen',
|
||||
'fahrzeug_status',
|
||||
'fahrzeug_event',
|
||||
'bestellungen',
|
||||
];
|
||||
|
||||
const WEEKDAY_OPTIONS = [
|
||||
{ value: 0, label: 'Sonntag' },
|
||||
{ value: 1, label: 'Montag' },
|
||||
{ value: 2, label: 'Dienstag' },
|
||||
{ value: 3, label: 'Mittwoch' },
|
||||
{ value: 4, label: 'Donnerstag' },
|
||||
{ value: 5, label: 'Freitag' },
|
||||
{ value: 6, label: 'Samstag' },
|
||||
];
|
||||
|
||||
const PLACEHOLDER_MAP: Record<MessageType, string[]> = {
|
||||
event_summary: ['{{items}}', '{{count}}', '{{date_range}}'],
|
||||
birthday_list: ['{{items}}', '{{count}}'],
|
||||
dienstjubilaeen: ['{{items}}', '{{count}}'],
|
||||
fahrzeug_status: ['{{items}}', '{{count}}'],
|
||||
fahrzeug_event: ['{{vehicle}}', '{{event}}', '{{date}}'],
|
||||
bestellungen: ['{{items}}', '{{count}}'],
|
||||
};
|
||||
|
||||
interface GeplanteMachrichtenFormProps {
|
||||
ruleId?: string;
|
||||
}
|
||||
|
||||
export default function GeplanteMachrichtenForm({ ruleId }: GeplanteMachrichtenFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [messageType, setMessageType] = useState<MessageType>('event_summary');
|
||||
const [triggerMode, setTriggerMode] = useState<TriggerMode>('day_of_week');
|
||||
const [dayOfWeek, setDayOfWeek] = useState<number>(1);
|
||||
const [sendTime, setSendTime] = useState('08:00');
|
||||
const [daysBeforeMonthStart, setDaysBeforeMonthStart] = useState<number>(3);
|
||||
const [windowMode, setWindowMode] = useState<WindowMode>('rolling');
|
||||
const [windowDays, setWindowDays] = useState<number>(7);
|
||||
const [targetRoomToken, setTargetRoomToken] = useState('');
|
||||
const [template, setTemplate] = useState('');
|
||||
const [minDaysOverdue, setMinDaysOverdue] = useState<number>(14);
|
||||
const [subscribable, setSubscribable] = useState(false);
|
||||
const [allowedGroups, setAllowedGroups] = useState<string[]>([]);
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
const templateRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: existingRule, isLoading: ruleLoading } = useQuery({
|
||||
queryKey: ['scheduled-messages', ruleId],
|
||||
queryFn: () => scheduledMessagesApi.getById(ruleId!),
|
||||
enabled: !!ruleId,
|
||||
});
|
||||
|
||||
const { data: roomsData, isLoading: roomsLoading } = useQuery({
|
||||
queryKey: ['scheduled-messages-rooms'],
|
||||
queryFn: scheduledMessagesApi.getRooms,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (existingRule?.data) {
|
||||
const r = existingRule.data;
|
||||
setName(r.name);
|
||||
setMessageType(r.message_type);
|
||||
setTriggerMode(r.trigger_mode);
|
||||
setDayOfWeek(r.day_of_week ?? 1);
|
||||
setSendTime(r.send_time ?? '08:00');
|
||||
setDaysBeforeMonthStart(r.days_before_month_start ?? 3);
|
||||
setWindowMode(r.window_mode ?? 'rolling');
|
||||
setWindowDays(r.window_days ?? 7);
|
||||
setTargetRoomToken(r.target_room_token);
|
||||
setTemplate(r.template);
|
||||
setMinDaysOverdue((r.extra_config?.min_days_overdue as number) ?? 14);
|
||||
setSubscribable(r.subscribable);
|
||||
setAllowedGroups(r.allowed_groups);
|
||||
setActive(r.active);
|
||||
}
|
||||
}, [existingRule]);
|
||||
|
||||
const insertPlaceholder = (placeholder: string) => {
|
||||
const el = templateRef.current?.querySelector('textarea') as HTMLTextAreaElement | null;
|
||||
if (!el) return;
|
||||
const start = el.selectionStart ?? template.length;
|
||||
const end = el.selectionEnd ?? template.length;
|
||||
const newVal = template.slice(0, start) + placeholder + template.slice(end);
|
||||
setTemplate(newVal);
|
||||
setTimeout(() => {
|
||||
el.setSelectionRange(start + placeholder.length, start + placeholder.length);
|
||||
el.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
message_type: messageType,
|
||||
trigger_mode: messageType === 'fahrzeug_event' ? 'event' : triggerMode,
|
||||
day_of_week: triggerMode === 'day_of_week' && messageType !== 'fahrzeug_event' ? dayOfWeek : null,
|
||||
send_time: triggerMode === 'day_of_week' && messageType !== 'fahrzeug_event' ? sendTime : null,
|
||||
days_before_month_start:
|
||||
triggerMode === 'days_before_month_start' && messageType !== 'fahrzeug_event'
|
||||
? daysBeforeMonthStart
|
||||
: null,
|
||||
window_mode: messageType === 'fahrzeug_event' ? null : windowMode,
|
||||
window_days: messageType === 'fahrzeug_event' ? null : windowMode === 'rolling' ? windowDays : null,
|
||||
target_room_token: targetRoomToken,
|
||||
template,
|
||||
extra_config: messageType === 'bestellungen' ? { min_days_overdue: minDaysOverdue } : null,
|
||||
subscribable,
|
||||
allowed_groups: allowedGroups,
|
||||
active,
|
||||
};
|
||||
if (ruleId) {
|
||||
return scheduledMessagesApi.update(ruleId, payload);
|
||||
}
|
||||
return scheduledMessagesApi.create(payload);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
showSuccess(ruleId ? 'Regel aktualisiert' : 'Regel erstellt');
|
||||
navigate(`/geplante-nachrichten/${result.data.id}`);
|
||||
},
|
||||
onError: () => showError('Fehler beim Speichern'),
|
||||
});
|
||||
|
||||
const isEventType = messageType === 'fahrzeug_event';
|
||||
const rooms = roomsData?.data ?? [];
|
||||
const roomsConfigured = roomsData?.configured ?? false;
|
||||
|
||||
if (ruleId && ruleLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ p: 3, maxWidth: 800 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{ruleId ? 'Regel bearbeiten' : 'Neue Regel'}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{/* Name */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
required
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Message Type */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<FormLabel sx={{ mb: 0.5 }}>Nachrichtentyp</FormLabel>
|
||||
<Select
|
||||
value={messageType}
|
||||
onChange={(e) => setMessageType(e.target.value as MessageType)}
|
||||
>
|
||||
{MESSAGE_TYPE_OPTIONS.map((t) => (
|
||||
<MenuItem key={t} value={t}>{MESSAGE_TYPE_LABELS[t]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Trigger */}
|
||||
{isEventType ? (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
Dieser Nachrichtentyp wird automatisch bei Ereignissen ausgelöst.
|
||||
</Alert>
|
||||
) : (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControl>
|
||||
<FormLabel>Auslöser</FormLabel>
|
||||
<RadioGroup
|
||||
value={triggerMode}
|
||||
onChange={(e) => setTriggerMode(e.target.value as TriggerMode)}
|
||||
>
|
||||
<FormControlLabel value="day_of_week" control={<Radio />} label="Wochentag + Uhrzeit" />
|
||||
<FormControlLabel value="days_before_month_start" control={<Radio />} label="N Tage vor Monatsbeginn" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{triggerMode === 'day_of_week' && (
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<Select
|
||||
value={dayOfWeek}
|
||||
onChange={(e) => setDayOfWeek(Number(e.target.value))}
|
||||
>
|
||||
{WEEKDAY_OPTIONS.map((w) => (
|
||||
<MenuItem key={w.value} value={w.value}>{w.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Uhrzeit"
|
||||
type="time"
|
||||
value={sendTime}
|
||||
onChange={(e) => setSendTime(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{triggerMode === 'days_before_month_start' && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Tage vor Monatsbeginn"
|
||||
type="number"
|
||||
value={daysBeforeMonthStart}
|
||||
onChange={(e) => setDaysBeforeMonthStart(Number(e.target.value))}
|
||||
size="small"
|
||||
inputProps={{ min: 0, max: 28 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Window */}
|
||||
{!isEventType && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControl>
|
||||
<FormLabel>Zeitfenster</FormLabel>
|
||||
<RadioGroup
|
||||
value={windowMode}
|
||||
onChange={(e) => setWindowMode(e.target.value as WindowMode)}
|
||||
>
|
||||
<FormControlLabel value="rolling" control={<Radio />} label="Rollierend (N Tage)" />
|
||||
<FormControlLabel value="calendar_month" control={<Radio />} label="Nächster Kalendermonat" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{windowMode === 'rolling' && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Tage"
|
||||
type="number"
|
||||
value={windowDays}
|
||||
onChange={(e) => setWindowDays(Number(e.target.value))}
|
||||
size="small"
|
||||
inputProps={{ min: 1, max: 365 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Target Room */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<FormLabel sx={{ mb: 0.5 }}>Zielraum</FormLabel>
|
||||
{roomsLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !roomsConfigured ? (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
Bot-Konto nicht konfiguriert. Bitte in den Admin-Einstellungen unter Nextcloud konfigurieren.
|
||||
</Alert>
|
||||
) : (
|
||||
<Select
|
||||
value={targetRoomToken}
|
||||
onChange={(e) => setTargetRoomToken(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>Raum auswählen...</MenuItem>
|
||||
{rooms.map((room) => (
|
||||
<MenuItem key={room.token} value={room.token}>
|
||||
{room.displayName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Template */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormLabel sx={{ mb: 0.5, display: 'block' }}>Vorlage</FormLabel>
|
||||
<TextField
|
||||
ref={templateRef}
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
multiline
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 1 }}>
|
||||
{(PLACEHOLDER_MAP[messageType] ?? []).map((ph) => (
|
||||
<Chip
|
||||
key={ph}
|
||||
label={ph}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => insertPlaceholder(ph)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Min days overdue (bestellungen only) */}
|
||||
{messageType === 'bestellungen' && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
label="Mindest-Alter (Tage überfällig)"
|
||||
type="number"
|
||||
value={minDaysOverdue}
|
||||
onChange={(e) => setMinDaysOverdue(Number(e.target.value))}
|
||||
size="small"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Subscribable */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={subscribable} onChange={(e) => setSubscribable(e.target.checked)} />
|
||||
}
|
||||
label="Abonnierbar"
|
||||
/>
|
||||
{subscribable && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<FormLabel sx={{ mb: 0.5, display: 'block' }}>Erlaubte Gruppen (leer = alle)</FormLabel>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
options={[]}
|
||||
value={allowedGroups}
|
||||
onChange={(_e, val) => setAllowedGroups(val)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return <Chip key={key} label={option} size="small" {...tagProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} size="small" placeholder="Gruppe eingeben + Enter" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Active */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={active} onChange={(e) => setActive(e.target.checked)} />}
|
||||
label="Aktiv"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !name || (!isEventType && !targetRoomToken)}
|
||||
>
|
||||
{saveMutation.isPending ? <CircularProgress size={20} sx={{ mr: 1 }} /> : null}
|
||||
{ruleId ? 'Speichern' : 'Erstellen'}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/geplante-nachrichten')}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
5
frontend/src/pages/GeplanteMachrichtenNeu.tsx
Normal file
5
frontend/src/pages/GeplanteMachrichtenNeu.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import GeplanteMachrichtenForm from './GeplanteMachrichtenForm';
|
||||
|
||||
export default function GeplanteMachrichtenNeu() {
|
||||
return <GeplanteMachrichtenForm />;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew, Sort, Restore, DragIndicator } from '@mui/icons-material';
|
||||
import {
|
||||
Schedule as ScheduleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -43,9 +46,20 @@ import { useThemeMode } from '../contexts/ThemeContext';
|
||||
import { preferencesApi } from '../services/settings';
|
||||
import { WIDGETS, WidgetKey } from '../constants/widgets';
|
||||
import { nextcloudApi } from '../services/nextcloud';
|
||||
import { scheduledMessagesApi } from '../services/scheduledMessages';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import type { MessageType } from '../types/scheduledMessages.types';
|
||||
|
||||
const MESSAGE_TYPE_LABELS: Record<MessageType, string> = {
|
||||
event_summary: 'Terminübersicht',
|
||||
birthday_list: 'Geburtstagsliste',
|
||||
dienstjubilaeen: 'Dienstjubiläen',
|
||||
fahrzeug_status: 'Fahrzeugstatus',
|
||||
fahrzeug_event: 'Fahrzeug außer Dienst',
|
||||
bestellungen: 'Offene Bestellungen',
|
||||
};
|
||||
|
||||
const POLL_INTERVAL = 2000;
|
||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||
@@ -58,12 +72,14 @@ const ORDERABLE_NAV_ITEMS = [
|
||||
{ text: 'Fahrzeugbuchungen', path: '/fahrzeugbuchungen', permission: 'fahrzeugbuchungen:view' },
|
||||
{ text: 'Fahrzeuge', path: '/fahrzeuge', permission: 'fahrzeuge:view' },
|
||||
{ text: 'Ausrüstung', path: '/ausruestung', permission: 'ausruestung:view' },
|
||||
{ text: 'Pers. Ausrüstung', path: '/persoenliche-ausruestung', permission: 'persoenliche_ausruestung:view' },
|
||||
{ text: 'Mitglieder', path: '/mitglieder', permission: 'mitglieder:view_own' },
|
||||
{ text: 'Atemschutz', path: '/atemschutz', permission: 'atemschutz:view' },
|
||||
{ text: 'Wissen', path: '/wissen', permission: 'wissen:view' },
|
||||
{ text: 'Bestellungen', path: '/bestellungen', permission: 'bestellungen:view' },
|
||||
{ text: 'Interne Bestellungen', path: '/ausruestungsanfrage', permission: 'ausruestungsanfrage:view' },
|
||||
{ text: 'Checklisten', path: '/checklisten', permission: 'checklisten:view' },
|
||||
{ text: 'Buchhaltung', path: '/buchhaltung', permission: 'buchhaltung:view' },
|
||||
{ text: 'Issues', path: '/issues', permission: 'issues:view_own' },
|
||||
];
|
||||
|
||||
@@ -94,6 +110,78 @@ function SortableNavItem({ id, text }: { id: string; text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionsCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['scheduled-messages'],
|
||||
queryFn: scheduledMessagesApi.getAll,
|
||||
});
|
||||
|
||||
const rules = (data?.data ?? []).filter(
|
||||
(rule) =>
|
||||
rule.subscribable &&
|
||||
(rule.allowed_groups.length === 0 ||
|
||||
rule.allowed_groups.some((g) => user?.groups?.includes(g))),
|
||||
);
|
||||
|
||||
const handleToggle = async (ruleId: string, isSubscribed: boolean) => {
|
||||
if (isSubscribed) {
|
||||
await scheduledMessagesApi.unsubscribe(ruleId);
|
||||
} else {
|
||||
await scheduledMessagesApi.subscribe(ruleId, '');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-messages'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<ScheduleIcon color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Nachrichten-Abonnements</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : rules.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine abonnierbaren Nachrichten verfügbar.
|
||||
</Typography>
|
||||
) : (
|
||||
<FormGroup>
|
||||
{rules.map((rule) => (
|
||||
<Box key={rule.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!!rule.is_subscribed}
|
||||
onChange={() => handleToggle(rule.id, !!rule.is_subscribed)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={rule.name}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Chip
|
||||
label={MESSAGE_TYPE_LABELS[rule.message_type] ?? rule.message_type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</FormGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
const { themeMode, setThemeMode } = useThemeMode();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -551,6 +639,11 @@ function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Nachrichten-Abonnements */}
|
||||
{hasPermission('scheduled_messages:subscribe') && (
|
||||
<SubscriptionsCard />
|
||||
)}
|
||||
</Grid>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
|
||||
36
frontend/src/services/scheduledMessages.ts
Normal file
36
frontend/src/services/scheduledMessages.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { api } from './api';
|
||||
import type {
|
||||
ScheduledMessageRule,
|
||||
ScheduledMessagesListResponse,
|
||||
ScheduledMessageDetailResponse,
|
||||
RoomsResponse,
|
||||
} from '../types/scheduledMessages.types';
|
||||
|
||||
export const scheduledMessagesApi = {
|
||||
getAll: () =>
|
||||
api.get<ScheduledMessagesListResponse>('/scheduled-messages').then(r => r.data),
|
||||
|
||||
getById: (id: string) =>
|
||||
api.get<ScheduledMessageDetailResponse>(`/scheduled-messages/${id}`).then(r => r.data),
|
||||
|
||||
getRooms: () =>
|
||||
api.get<RoomsResponse>('/scheduled-messages/rooms').then(r => r.data),
|
||||
|
||||
create: (data: Partial<ScheduledMessageRule>) =>
|
||||
api.post<ScheduledMessageDetailResponse>('/scheduled-messages', data).then(r => r.data),
|
||||
|
||||
update: (id: string, data: Partial<ScheduledMessageRule>) =>
|
||||
api.patch<ScheduledMessageDetailResponse>(`/scheduled-messages/${id}`, data).then(r => r.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/scheduled-messages/${id}`).then(r => r.data),
|
||||
|
||||
subscribe: (id: string, roomToken: string) =>
|
||||
api.post(`/scheduled-messages/${id}/subscribe`, { room_token: roomToken }).then(r => r.data),
|
||||
|
||||
unsubscribe: (id: string) =>
|
||||
api.delete(`/scheduled-messages/${id}/subscribe`).then(r => r.data),
|
||||
|
||||
trigger: (id: string) =>
|
||||
api.post(`/scheduled-messages/${id}/trigger`).then(r => r.data),
|
||||
};
|
||||
52
frontend/src/types/scheduledMessages.types.ts
Normal file
52
frontend/src/types/scheduledMessages.types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type MessageType =
|
||||
| 'event_summary'
|
||||
| 'birthday_list'
|
||||
| 'dienstjubilaeen'
|
||||
| 'fahrzeug_status'
|
||||
| 'fahrzeug_event'
|
||||
| 'bestellungen';
|
||||
|
||||
export type TriggerMode = 'day_of_week' | 'days_before_month_start' | 'event';
|
||||
export type WindowMode = 'rolling' | 'calendar_month';
|
||||
|
||||
export interface ScheduledMessageRule {
|
||||
id: string;
|
||||
name: string;
|
||||
message_type: MessageType;
|
||||
trigger_mode: TriggerMode;
|
||||
day_of_week: number | null;
|
||||
send_time: string | null;
|
||||
days_before_month_start: number | null;
|
||||
window_mode: WindowMode | null;
|
||||
window_days: number | null;
|
||||
target_room_token: string;
|
||||
target_room_name: string | null;
|
||||
template: string;
|
||||
extra_config: Record<string, unknown> | null;
|
||||
subscribable: boolean;
|
||||
allowed_groups: string[];
|
||||
last_sent_at: string | null;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
subscriber_count?: number;
|
||||
is_subscribed?: boolean;
|
||||
}
|
||||
|
||||
export interface NextcloudRoom {
|
||||
token: string;
|
||||
displayName: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export interface ScheduledMessagesListResponse {
|
||||
data: ScheduledMessageRule[];
|
||||
}
|
||||
|
||||
export interface ScheduledMessageDetailResponse {
|
||||
data: ScheduledMessageRule;
|
||||
}
|
||||
|
||||
export interface RoomsResponse {
|
||||
configured: boolean;
|
||||
data?: NextcloudRoom[];
|
||||
}
|
||||
Reference in New Issue
Block a user