fix(geplante-nachrichten): add /api prefix to all API paths, fix subscribe room token, unmask empty bot credentials, add Einzelnachrichten tab

This commit is contained in:
Matthias Hochmeister
2026-04-17 12:33:48 +02:00
parent fcca04cc39
commit d8afcc1f63
8 changed files with 429 additions and 138 deletions

View File

@@ -15,12 +15,22 @@ import {
TableRow,
Paper,
IconButton,
Tab,
Tabs,
TextField,
Select,
MenuItem,
FormControl,
FormLabel,
CircularProgress,
Divider,
} from '@mui/material';
import { Add as AddIcon, Edit as EditIcon } from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon, Send as SendIcon } from '@mui/icons-material';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { scheduledMessagesApi } from '../services/scheduledMessages';
import type { MessageType, ScheduledMessageRule } from '../types/scheduledMessages.types';
@@ -54,12 +64,211 @@ function triggerSummary(rule: ScheduledMessageRule): string {
return TRIGGER_LABELS[rule.trigger_mode] ?? rule.trigger_mode;
}
function formatSendAt(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function EinzelNachrichtenTab() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: messagesData, isLoading: messagesLoading, isError: messagesError } = useQuery({
queryKey: ['scheduled-one-time-messages'],
queryFn: scheduledMessagesApi.getOneTimeMessages,
});
const { data: roomsData, isLoading: roomsLoading } = useQuery({
queryKey: ['scheduled-messages-rooms'],
queryFn: scheduledMessagesApi.getRooms,
});
const [message, setMessage] = useState('');
const [roomToken, setRoomToken] = useState('');
const [sendAt, setSendAt] = useState('');
const rooms = roomsData?.data ?? [];
const roomsConfigured = roomsData?.configured ?? false;
const roomsErr = roomsData?.error;
const createMutation = useMutation({
mutationFn: () => {
const room = rooms.find(r => r.token === roomToken);
return scheduledMessagesApi.createOneTimeMessage({
message,
target_room_token: roomToken,
target_room_name: room?.displayName,
send_at: new Date(sendAt).toISOString(),
});
},
onSuccess: () => {
showSuccess('Einzelnachricht geplant');
setMessage('');
setRoomToken('');
setSendAt('');
queryClient.invalidateQueries({ queryKey: ['scheduled-one-time-messages'] });
},
onError: () => showError('Fehler beim Speichern'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => scheduledMessagesApi.deleteOneTimeMessage(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduled-one-time-messages'] });
},
onError: () => showError('Fehler beim Löschen'),
});
const canCreate = !!message.trim() && !!roomToken && !!sendAt;
return (
<Box>
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle1" gutterBottom fontWeight={500}>Neue Einzelnachricht</Typography>
<Divider sx={{ mb: 2 }} />
{/* Room picker */}
<Box sx={{ mb: 2 }}>
<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>
) : roomsErr ? (
<Alert severity="error" sx={{ mt: 1 }}>
Nextcloud nicht erreichbar Raumliste kann nicht geladen werden.
</Alert>
) : (
<Select
value={roomToken}
onChange={(e) => setRoomToken(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>
{/* Send time */}
<Box sx={{ mb: 2 }}>
<TextField
label="Sendezeitpunkt"
type="datetime-local"
value={sendAt}
onChange={(e) => setSendAt(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
/>
</Box>
{/* Message */}
<Box sx={{ mb: 2 }}>
<TextField
label="Nachricht"
value={message}
onChange={(e) => setMessage(e.target.value)}
multiline
minRows={3}
maxRows={8}
fullWidth
size="small"
/>
</Box>
<Button
variant="contained"
startIcon={createMutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
onClick={() => createMutation.mutate()}
disabled={!canCreate || createMutation.isPending || !roomsConfigured}
>
Planen
</Button>
</Paper>
{/* Pending messages list */}
<Typography variant="subtitle1" gutterBottom fontWeight={500}>Ausstehende Einzelnachrichten</Typography>
{messagesError && (
<Alert severity="error" sx={{ mb: 2 }}>Fehler beim Laden der Nachrichten.</Alert>
)}
{messagesLoading ? (
<TableContainer component={Paper}>
<Table size="small">
<TableBody>
{[1, 2].map((i) => (
<TableRow key={i}>
{[1, 2, 3, 4].map((j) => <TableCell key={j}><Skeleton /></TableCell>)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : !messagesData?.data?.length ? (
<Typography color="text.secondary" sx={{ py: 2 }}>
Keine ausstehenden Einzelnachrichten.
</Typography>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Nachricht</TableCell>
<TableCell>Zielraum</TableCell>
<TableCell>Sendezeitpunkt</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{messagesData.data.map((msg) => (
<TableRow key={msg.id}>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{msg.message}
</TableCell>
<TableCell>{msg.target_room_name ?? msg.target_room_token}</TableCell>
<TableCell>{formatSendAt(msg.send_at)}</TableCell>
<TableCell padding="checkbox">
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(msg.id)}
disabled={deleteMutation.isPending}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
}
export default function GeplanteMachrichten() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const canEdit = hasPermission('scheduled_messages:edit');
const [tab, setTab] = useState(0);
const { data, isLoading, isError } = useQuery({
queryKey: ['scheduled-messages'],
queryFn: scheduledMessagesApi.getAll,
@@ -85,7 +294,7 @@ export default function GeplanteMachrichten() {
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5">Geplante Nachrichten</Typography>
{canEdit && (
{canEdit && tab === 0 && (
<Button
variant="contained"
startIcon={<AddIcon />}
@@ -96,121 +305,132 @@ export default function GeplanteMachrichten() {
)}
</Box>
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Fehler beim Laden der Regeln.
</Alert>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Regeln" />
{canEdit && <Tab label="Einzelnachrichten" />}
</Tabs>
{tab === 0 && (
<>
{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>
)}
</>
)}
{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>
)}
{tab === 1 && canEdit && <EinzelNachrichtenTab />}
</Box>
</DashboardLayout>
);

View File

@@ -75,8 +75,13 @@ export default function GeplanteMachrichtenDetail() {
const handleSubscribe = async () => {
if (!id) return;
await scheduledMessagesApi.subscribe(id, '');
queryClient.invalidateQueries({ queryKey: ['scheduled-messages', id] });
try {
const roomResult = await scheduledMessagesApi.getMyBotRoom();
await scheduledMessagesApi.subscribe(id, roomResult.data.room_token);
queryClient.invalidateQueries({ queryKey: ['scheduled-messages', id] });
} catch {
showError('Abonnieren fehlgeschlagen. Bitte Nextcloud-Konto in den Einstellungen verbinden.');
}
};
const handleUnsubscribe = async () => {

View File

@@ -81,6 +81,7 @@ const ORDERABLE_NAV_ITEMS = [
{ text: 'Checklisten', path: '/checklisten', permission: 'checklisten:view' },
{ text: 'Buchhaltung', path: '/buchhaltung', permission: 'buchhaltung:view' },
{ text: 'Issues', path: '/issues', permission: 'issues:view_own' },
{ text: 'Geplante Nachrichten', path: '/geplante-nachrichten', permission: 'scheduled_messages:view' },
];
function SortableNavItem({ id, text }: { id: string; text: string }) {
@@ -130,7 +131,12 @@ function SubscriptionsCard() {
if (isSubscribed) {
await scheduledMessagesApi.unsubscribe(ruleId);
} else {
await scheduledMessagesApi.subscribe(ruleId, '');
try {
const roomResult = await scheduledMessagesApi.getMyBotRoom();
await scheduledMessagesApi.subscribe(ruleId, roomResult.data.room_token);
} catch {
return; // silently skip — user will see no change in toggle
}
}
queryClient.invalidateQueries({ queryKey: ['scheduled-messages'] });
};