This commit is contained in:
Matthias Hochmeister
2026-03-13 15:42:15 +01:00
parent 3dda069611
commit 75c919c063
13 changed files with 926 additions and 30 deletions

View File

@@ -120,6 +120,29 @@ class AtemschutzController {
}
}
async getByUserId(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
if (!isValidUUID(userId)) {
res.status(400).json({ success: false, message: 'Ungültige Benutzer-ID' });
return;
}
const callerId = getUserId(req);
const callerGroups: string[] = (req.user as any)?.groups ?? [];
const privileged = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const isPrivileged = callerGroups.some((g) => privileged.includes(g));
if (userId !== callerId && !isPrivileged) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
const record = await atemschutzService.getByUserId(userId);
res.status(200).json({ success: true, data: record ?? null });
} catch (error) {
logger.error('Atemschutz getByUserId error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Atemschutzstatus konnte nicht geladen werden' });
}
}
async getMyStatus(req: Request, res: Response): Promise<void> {
try {
const userId = getUserId(req);

View File

@@ -16,7 +16,9 @@ import {
type AppRole = 'admin' | 'kommandant' | 'mitglied';
function getRole(req: Request): AppRole {
return (req.user as any)?.role ?? 'mitglied';
// req.userRole is set by requirePermission() for non-owner paths.
// Fall back to req.user.role (JWT claim) and finally to 'mitglied'.
return (req as any).userRole ?? (req.user as any)?.role ?? 'mitglied';
}
function canWrite(req: Request): boolean {
@@ -209,14 +211,16 @@ class MemberController {
return;
}
const profile = await memberService.updateMemberProfile(
await memberService.updateMemberProfile(
userId,
parseResult.data as any,
updaterId
);
// Return full MemberWithProfile so the frontend state stays consistent
const fullMember = await memberService.getMemberById(userId);
logger.info('updateMember', { userId, updatedBy: updaterId });
res.status(200).json({ success: true, data: profile });
res.status(200).json({ success: true, data: fullMember });
} catch (error: any) {
if (error?.message === 'Mitgliedsprofil nicht gefunden.') {
res.status(404).json({ success: false, message: error.message });

View File

@@ -145,7 +145,7 @@ class NextcloudController {
return;
}
const token = req.params.token as string;
const { message } = req.body;
const { message, replyTo } = req.body;
if (!token || !message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ success: false, message: 'Token und Nachricht erforderlich' });
return;
@@ -154,7 +154,8 @@ class NextcloudController {
res.status(400).json({ success: false, message: 'Nachricht zu lang' });
return;
}
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword);
const replyToNum = (typeof replyTo === 'number' && replyTo > 0) ? replyTo : undefined;
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword, replyToNum);
res.status(200).json({ success: true, data: null });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
@@ -302,6 +303,118 @@ class NextcloudController {
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
}
}
async searchUsers(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(200).json({ success: true, data: [] });
return;
}
const query = (req.query.search as string) ?? '';
const results = await nextcloudService.searchUsers(query, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: results });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: [] });
return;
}
logger.error('searchUsers error', { error });
res.status(500).json({ success: false, message: 'Benutzersuche fehlgeschlagen' });
}
}
async createRoom(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
const { roomType, invite, roomName } = req.body;
if (typeof roomType !== 'number' || !invite || typeof invite !== 'string') {
res.status(400).json({ success: false, message: 'roomType und invite erforderlich' });
return;
}
const result = await nextcloudService.createRoom(roomType, invite, roomName, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: result });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
logger.error('createRoom error', { error });
res.status(500).json({ success: false, message: 'Raum konnte nicht erstellt werden' });
}
}
async addReaction(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token;
const messageId = parseInt(req.params.messageId, 10);
const { reaction } = req.body;
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
await nextcloudService.addReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: null });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
logger.error('addReaction error', { error });
res.status(500).json({ success: false, message: 'Reaktion konnte nicht hinzugefügt werden' });
}
}
async removeReaction(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token;
const messageId = parseInt(req.params.messageId, 10);
const reaction = req.query.reaction as string;
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
await nextcloudService.removeReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: null });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
logger.error('removeReaction error', { error });
res.status(500).json({ success: false, message: 'Reaktion konnte nicht entfernt werden' });
}
}
async getReactions(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token;
const messageId = parseInt(req.params.messageId, 10);
if (!token || isNaN(messageId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
const data = await nextcloudService.getReactions(token, messageId, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
logger.error('getReactions error', { error });
res.status(500).json({ success: false, message: 'Reaktionen konnten nicht geladen werden' });
}
}
async getPoll(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token;
const pollId = parseInt(req.params.pollId, 10);
if (!token || isNaN(pollId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
const data = await nextcloudService.getPollDetails(token, pollId, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
logger.error('getPoll error', { error });
res.status(500).json({ success: false, message: 'Abstimmung konnte nicht geladen werden' });
}
}
}
export default new NextcloudController();

View File

@@ -10,6 +10,7 @@ const router = Router();
router.get('/', authenticate, atemschutzController.list.bind(atemschutzController));
router.get('/stats', authenticate, atemschutzController.getStats.bind(atemschutzController));
router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController));
router.get('/user/:userId', authenticate, atemschutzController.getByUserId.bind(atemschutzController));
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
// ── Write — gruppenfuehrer+ ─────────────────────────────────────────────────

View File

@@ -139,6 +139,8 @@ interface NextcloudChatMessage {
messageType: string;
systemMessage: string;
messageParameters: Record<string, any>;
reactions: Record<string, any>;
reactionsSelf: string[];
}
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
@@ -255,6 +257,8 @@ async function getMessages(token: string, loginName: string, appPassword: string
messageType: m.messageType ?? '',
systemMessage: m.systemMessage ?? '',
messageParameters: m.messageParameters ?? {},
reactions: m.reactions ?? {},
reactionsSelf: m.reactionsSelf ?? [],
}));
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
@@ -274,7 +278,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
}
}
async function sendMessage(token: string, message: string, loginName: string, appPassword: string): Promise<void> {
async function sendMessage(token: string, message: string, loginName: string, appPassword: string, replyTo?: number): Promise<void> {
const baseUrl = environment.nextcloudUrl;
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
@@ -283,7 +287,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap
try {
await httpClient.post(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
{ message },
{ message, ...(replyTo !== undefined ? { replyTo } : {}) },
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
@@ -560,5 +564,224 @@ async function getFilePreview(
}
}
async function searchUsers(query: string, loginName: string, appPassword: string): Promise<any[]> {
const baseUrl = environment.nextcloudUrl;
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get(
`${baseUrl}/ocs/v2.php/core/autocomplete/get?search=${encodeURIComponent(query)}&limit=20&itemType=&itemId=&shareTypes[]=0&shareTypes[]=1&shareTypes[]=7&format=json`,
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
'OCS-APIRequest': 'true',
'Accept': 'application/json',
},
},
);
return (response.data?.ocs?.data ?? []).map((u: any) => ({ id: u.id, label: u.label, source: u.source }));
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const err = new Error('Nextcloud authentication invalid');
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
throw err;
}
if (axios.isAxiosError(error)) {
logger.error('NextcloudService.searchUsers failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
}
logger.error('NextcloudService.searchUsers failed', { error });
throw new Error('Failed to search users');
}
}
async function createRoom(roomType: number, invite: string, roomName: string | undefined, loginName: string, appPassword: string): Promise<{ token: string }> {
const baseUrl = environment.nextcloudUrl;
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.post(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room`,
{ roomType, invite, ...(roomName ? { roomName } : {}) },
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
'OCS-APIRequest': 'true',
'Accept': 'application/json',
'Content-Type': 'application/json',
},
},
);
return { token: response.data?.ocs?.data?.token as string };
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const err = new Error('Nextcloud authentication invalid');
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
throw err;
}
if (axios.isAxiosError(error)) {
logger.error('NextcloudService.createRoom failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
}
logger.error('NextcloudService.createRoom failed', { error });
throw new Error('Failed to create room');
}
}
async function addReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
const baseUrl = environment.nextcloudUrl;
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
try {
await httpClient.post(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/reaction/${encodeURIComponent(token)}/${messageId}`,
{ reaction },
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
'OCS-APIRequest': 'true',
'Accept': 'application/json',
'Content-Type': 'application/json',
},
},
);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const err = new Error('Nextcloud authentication invalid');
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
throw err;
}
if (axios.isAxiosError(error)) {
logger.error('NextcloudService.addReaction failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
}
logger.error('NextcloudService.addReaction failed', { error });
throw new Error('Failed to add reaction');
}
}
async function removeReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
const baseUrl = environment.nextcloudUrl;
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
try {
await httpClient.delete(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/reaction/${encodeURIComponent(token)}/${messageId}`,
{
params: { reaction },
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
'OCS-APIRequest': 'true',
'Accept': 'application/json',
},
},
);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const err = new Error('Nextcloud authentication invalid');
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
throw err;
}
if (axios.isAxiosError(error)) {
logger.error('NextcloudService.removeReaction failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
}
logger.error('NextcloudService.removeReaction failed', { error });
throw new Error('Failed to remove reaction');
}
}
async function getReactions(token: string, messageId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
const baseUrl = environment.nextcloudUrl;
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/reaction/${encodeURIComponent(token)}/${messageId}`,
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
'OCS-APIRequest': 'true',
'Accept': 'application/json',
},
},
);
return response.data?.ocs?.data ?? {};
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const err = new Error('Nextcloud authentication invalid');
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
throw err;
}
if (axios.isAxiosError(error)) {
logger.error('NextcloudService.getReactions failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
}
logger.error('NextcloudService.getReactions failed', { error });
throw new Error('Failed to get reactions');
}
}
async function getPollDetails(token: string, pollId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
const baseUrl = environment.nextcloudUrl;
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/poll/${encodeURIComponent(token)}/${pollId}`,
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
'OCS-APIRequest': 'true',
'Accept': 'application/json',
},
},
);
return response.data?.ocs?.data ?? {};
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const err = new Error('Nextcloud authentication invalid');
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
throw err;
}
if (axios.isAxiosError(error)) {
logger.error('NextcloudService.getPollDetails failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
}
logger.error('NextcloudService.getPollDetails failed', { error });
throw new Error('Failed to get poll details');
}
}
export type { NextcloudChatMessage, GetMessagesOptions };
export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead, uploadFileToTalk, downloadFile, getFilePreview };
export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead, uploadFileToTalk, downloadFile, getFilePreview, searchUsers, createRoom, addReaction, removeReaction, getReactions, getPollDetails };

View File

@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Popover from '@mui/material/Popover';
import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
import { nextcloudApi } from '../../services/nextcloud';
const QUICK_REACTIONS = ['\u{1F44D}', '\u2764\uFE0F', '\u{1F602}', '\u{1F62E}', '\u{1F622}', '\u{1F389}'];
interface MessageReactionsProps {
token: string;
messageId: number;
reactions: Record<string, number>;
reactionsSelf: string[];
onReactionToggled: () => void;
}
const MessageReactions: React.FC<MessageReactionsProps> = ({
token,
messageId,
reactions,
reactionsSelf,
onReactionToggled,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [loading, setLoading] = useState<string | null>(null);
const handleToggle = async (emoji: string) => {
setLoading(emoji);
try {
if (reactionsSelf.includes(emoji)) {
await nextcloudApi.removeReaction(token, messageId, emoji);
} else {
await nextcloudApi.addReaction(token, messageId, emoji);
}
onReactionToggled();
} catch {
// ignore
} finally {
setLoading(null);
}
};
const hasReactions = Object.keys(reactions).length > 0;
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.25, mt: 0.25, alignItems: 'center' }}>
{hasReactions && Object.entries(reactions).map(([emoji, count]) => (
<Chip
key={emoji}
label={`${emoji} ${count}`}
size="small"
onClick={() => handleToggle(emoji)}
disabled={loading === emoji}
sx={{
height: 20,
fontSize: '0.75rem',
cursor: 'pointer',
border: '1px solid',
borderColor: reactionsSelf.includes(emoji) ? 'primary.main' : 'transparent',
bgcolor: reactionsSelf.includes(emoji) ? 'primary.light' : 'action.hover',
'& .MuiChip-label': { px: 0.5 },
}}
/>
))}
<Tooltip title="Reaktion hinzuf\u00FCgen">
<IconButton
size="small"
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{ width: 20, height: 20, opacity: 0.6, '&:hover': { opacity: 1 } }}
>
<AddReactionOutlinedIcon sx={{ fontSize: '0.85rem' }} />
</IconButton>
</Tooltip>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Box sx={{ display: 'flex', p: 0.5, gap: 0.25 }}>
{QUICK_REACTIONS.map((emoji) => (
<IconButton
key={emoji}
size="small"
onClick={() => { handleToggle(emoji); setAnchorEl(null); }}
sx={{ fontSize: '1rem', minWidth: 32, minHeight: 32 }}
>
{emoji}
</IconButton>
))}
</Box>
</Popover>
</Box>
);
};
export default MessageReactions;

View File

@@ -0,0 +1,97 @@
import React, { useState } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import TextField from '@mui/material/TextField';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemText from '@mui/material/ListItemText';
import Avatar from '@mui/material/Avatar';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useQuery } from '@tanstack/react-query';
import { nextcloudApi } from '../../services/nextcloud';
interface NewChatDialogProps {
open: boolean;
onClose: () => void;
onRoomCreated: (token: string) => void;
}
const NewChatDialog: React.FC<NewChatDialogProps> = ({ open, onClose, onRoomCreated }) => {
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const { data: users, isLoading } = useQuery({
queryKey: ['nextcloud', 'users', search],
queryFn: () => nextcloudApi.searchUsers(search),
enabled: open && search.length >= 2,
staleTime: 30_000,
});
const handleSelect = async (userId: string, displayName: string) => {
setCreating(true);
try {
const { token } = await nextcloudApi.createRoom(1, userId, displayName);
onRoomCreated(token);
onClose();
} catch {
// ignore
} finally {
setCreating(false);
}
};
const handleClose = () => {
setSearch('');
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogTitle>Neues Gespr\u00E4ch</DialogTitle>
<DialogContent sx={{ pb: 1 }}>
<TextField
autoFocus
fullWidth
size="small"
placeholder="Benutzer suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ mb: 1 }}
/>
{(isLoading || creating) && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1 }}>
<CircularProgress size={24} />
</Box>
)}
{!isLoading && !creating && search.length >= 2 && (!users || users.length === 0) && (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, textAlign: 'center' }}>
Keine Benutzer gefunden
</Typography>
)}
{!creating && users && users.length > 0 && (
<List disablePadding>
{users.map((user) => (
<ListItem key={user.id} disablePadding>
<ListItemButton onClick={() => handleSelect(user.id, user.label)}>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem' }}>
{user.label.substring(0, 2).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText primary={user.label} secondary={user.id} />
</ListItemButton>
</ListItem>
))}
</List>
)}
</DialogContent>
</Dialog>
);
};
export default NewChatDialog;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import HowToVoteIcon from '@mui/icons-material/HowToVote';
import { useQuery } from '@tanstack/react-query';
import { nextcloudApi } from '../../services/nextcloud';
interface PollMessageContentProps {
roomToken: string;
pollId: number;
pollName: string;
isOwnMessage: boolean;
}
const PollMessageContent: React.FC<PollMessageContentProps> = ({ roomToken, pollId, pollName, isOwnMessage }) => {
const { data: poll } = useQuery({
queryKey: ['nextcloud', 'poll', roomToken, pollId],
queryFn: () => nextcloudApi.getPollDetails(roomToken, pollId),
staleTime: 60_000,
});
return (
<Box
sx={{
mt: 0.5,
p: 1,
borderRadius: 1,
border: '1px solid',
borderColor: isOwnMessage ? 'rgba(255,255,255,0.3)' : 'divider',
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.1)' : 'action.hover',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<HowToVoteIcon sx={{ fontSize: '1rem', opacity: 0.7 }} />
<Typography variant="caption" sx={{ opacity: 0.7 }}>Abstimmung</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{poll?.question ?? pollName}
</Typography>
{poll?.options && poll.options.slice(0, 4).map((opt, idx) => (
<Typography key={idx} variant="caption" sx={{ display: 'block', opacity: 0.8 }}>
{opt}
</Typography>
))}
{poll?.options && poll.options.length > 4 && (
<Typography variant="caption" sx={{ opacity: 0.6 }}>
+{poll.options.length - 4} weitere Optionen
</Typography>
)}
</Box>
);
};
export default PollMessageContent;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
interface RichMessageTextProps {
message: string;
messageParameters?: Record<string, any>;
isOwnMessage?: boolean;
}
const RichMessageText: React.FC<RichMessageTextProps> = ({ message, messageParameters, isOwnMessage }) => {
const parts = message.split(/(\{[^}]+\})/);
const elements = parts.map((part, i) => {
const match = part.match(/^\{([^}]+)\}$/);
if (!match) {
return part ? <span key={i}>{part}</span> : null;
}
const key = match[1];
const param = messageParameters?.[key];
if (!param) return <span key={i}>{part}</span>;
const { type, name, id } = param;
if (type === 'user' || type === 'guest' || type === 'call' || type === 'user-group') {
return (
<Chip
key={i}
label={`@${name ?? id}`}
size="small"
sx={{
height: 18,
fontSize: '0.75rem',
mx: 0.25,
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : 'action.hover',
color: 'inherit',
'& .MuiChip-label': { px: 0.75 },
verticalAlign: 'middle',
}}
/>
);
}
if (type === 'highlight' || key === 'actor') {
return <strong key={i}>{name ?? id}</strong>;
}
return <span key={i}>{name ?? id ?? key}</span>;
});
return (
<Typography
variant="body2"
component="span"
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', display: 'block' }}
>
{elements}
</Typography>
);
};
export default RichMessageText;

View File

@@ -32,11 +32,14 @@ import {
Security as SecurityIcon,
History as HistoryIcon,
DriveEta as DriveEtaIcon,
CheckCircle as CheckCircleIcon,
HighlightOff as HighlightOffIcon,
} from '@mui/icons-material';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { membersService } from '../services/members';
import { atemschutzApi } from '../services/atemschutz';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import {
MemberWithProfile,
@@ -54,6 +57,8 @@ import {
formatPhone,
UpdateMemberProfileData,
} from '../types/member.types';
import type { AtemschutzUebersicht } from '../types/atemschutz.types';
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
// ----------------------------------------------------------------
// Role helpers
@@ -188,6 +193,27 @@ function FieldRow({ label, value }: { label: string; value: React.ReactNode }) {
);
}
// ----------------------------------------------------------------
// Validity chip — green / yellow (< 90 days) / red (expired)
// ----------------------------------------------------------------
function ValidityChip({
date,
gueltig,
tageRest,
}: {
date: string | null;
gueltig: boolean;
tageRest: number | null;
}) {
if (!date) return <span></span>;
const formatted = new Date(date).toLocaleDateString('de-AT');
const color: 'success' | 'warning' | 'error' =
!gueltig ? 'error' : tageRest !== null && tageRest < 90 ? 'warning' : 'success';
const suffix =
!gueltig ? ' (abgelaufen)' : tageRest !== null && tageRest < 90 ? ` (${tageRest} Tage)` : '';
return <Chip label={`${formatted}${suffix}`} color={color} size="small" variant="outlined" />;
}
// ----------------------------------------------------------------
// Main component
// ----------------------------------------------------------------
@@ -208,6 +234,10 @@ function MitgliedDetail() {
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState(0);
// Atemschutz data for Qualifikationen tab
const [atemschutz, setAtemschutz] = useState<AtemschutzUebersicht | null>(null);
const [atemschutzLoading, setAtemschutzLoading] = useState(false);
// Edit form state — only the fields the user is allowed to change
const [formData, setFormData] = useState<UpdateMemberProfileData>({});
@@ -232,6 +262,16 @@ function MitgliedDetail() {
loadMember();
}, [loadMember]);
// Load atemschutz data alongside the member
useEffect(() => {
if (!userId) return;
setAtemschutzLoading(true);
atemschutzApi.getByUserId(userId)
.then((data) => setAtemschutz(data))
.catch(() => setAtemschutz(null))
.finally(() => setAtemschutzLoading(false));
}, [userId]);
// Populate form from current profile
useEffect(() => {
if (member?.profile) {
@@ -434,8 +474,8 @@ function MitgliedDetail() {
)}
</Box>
{/* Edit controls */}
{canEdit && (canWrite || !!profile) && (
{/* Edit controls — only shown when profile exists */}
{canEdit && !!profile && (
<Box>
{editMode ? (
<Box sx={{ display: 'flex', gap: 1 }}>
@@ -875,22 +915,128 @@ function MitgliedDetail() {
</Grid>
</TabPanel>
{/* ---- Tab 1: Qualifikationen (placeholder) ---- */}
{/* ---- Tab 1: Qualifikationen ---- */}
<TabPanel value={activeTab} index={1}>
{atemschutzLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<CircularProgress />
</Box>
) : atemschutz ? (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<SecurityIcon color="primary" />}
title="Atemschutz"
action={
<Chip
icon={atemschutz.einsatzbereit ? <CheckCircleIcon /> : <HighlightOffIcon />}
label={atemschutz.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={atemschutz.einsatzbereit ? 'success' : 'error'}
size="small"
/>
}
/>
<CardContent>
<FieldRow
label="Lehrgang"
value={
atemschutz.atemschutz_lehrgang
? `Ja${atemschutz.lehrgang_datum ? ` (${new Date(atemschutz.lehrgang_datum).toLocaleDateString('de-AT')})` : ''}`
: 'Nein'
}
/>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
G26.3 Untersuchung
</Typography>
<FieldRow
label="Datum"
value={atemschutz.untersuchung_datum
? new Date(atemschutz.untersuchung_datum).toLocaleDateString('de-AT')
: null}
/>
<FieldRow
label="Gültig bis"
value={
<ValidityChip
date={atemschutz.untersuchung_gueltig_bis}
gueltig={atemschutz.untersuchung_gueltig}
tageRest={atemschutz.untersuchung_tage_rest}
/>
}
/>
<FieldRow
label="Ergebnis"
value={
atemschutz.untersuchung_ergebnis
? UntersuchungErgebnisLabel[atemschutz.untersuchung_ergebnis]
: null
}
/>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Leistungstest (Finnentest)
</Typography>
<FieldRow
label="Datum"
value={atemschutz.leistungstest_datum
? new Date(atemschutz.leistungstest_datum).toLocaleDateString('de-AT')
: null}
/>
<FieldRow
label="Gültig bis"
value={
<ValidityChip
date={atemschutz.leistungstest_gueltig_bis}
gueltig={atemschutz.leistungstest_gueltig}
tageRest={atemschutz.leistungstest_tage_rest}
/>
}
/>
<FieldRow
label="Bestanden"
value={
atemschutz.leistungstest_bestanden === true
? 'Ja'
: atemschutz.leistungstest_bestanden === false
? 'Nein'
: null
}
/>
{atemschutz.bemerkung && (
<>
<Divider sx={{ my: 1.5 }} />
<FieldRow label="Bemerkung" value={atemschutz.bemerkung} />
</>
)}
</CardContent>
</Card>
</Grid>
</Grid>
) : (
<Card>
<CardContent>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 2 }}>
<SecurityIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
<Typography variant="h6" color="text.secondary">
Qualifikationen & Lehrgänge
Kein Atemschutz-Eintrag
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
Diese Funktion wird in einer zukünftigen Version verfügbar sein.
Geplant: Atemschutz, G26-Untersuchungen, Absolvierte Kurse, Gültigkeitsdaten.
Für dieses Mitglied ist kein Atemschutz-Datensatz vorhanden.
</Typography>
{canWrite && (
<Button variant="outlined" onClick={() => navigate('/atemschutz')}>
Zum Atemschutzmodul
</Button>
)}
</Box>
</CardContent>
</Card>
)}
</TabPanel>
{/* ---- Tab 2: Einsätze (placeholder) ---- */}

View File

@@ -24,6 +24,13 @@ export const atemschutzApi = {
);
},
async getByUserId(userId: string): Promise<AtemschutzUebersicht | null> {
const response = await api.get<{ success: boolean; data: AtemschutzUebersicht | null }>(
`/api/atemschutz/user/${userId}`
);
return response.data?.data ?? null;
},
async getMyStatus(): Promise<AtemschutzUebersicht | null> {
const response = await api.get<{ success: boolean; data: AtemschutzUebersicht | null }>(
'/api/atemschutz/my-status'

View File

@@ -1,5 +1,5 @@
import { api } from './api';
import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData, NextcloudMessage, NextcloudRoomListData } from '../types/nextcloud.types';
import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData, NextcloudMessage, NextcloudRoomListData, NextcloudUser, NextcloudReaction, NextcloudPoll } from '../types/nextcloud.types';
interface ApiResponse<T> {
success: boolean;
@@ -57,9 +57,9 @@ export const nextcloudApi = {
.then((r) => r.data.data);
},
sendMessage(token: string, message: string): Promise<void> {
sendMessage(token: string, message: string, replyTo?: number): Promise<void> {
return api
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`, { message })
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`, { message, ...(replyTo !== undefined ? { replyTo } : {}) })
.then(() => undefined);
},
@@ -96,4 +96,40 @@ export const nextcloudApi = {
getFilePreviewUrl(fileId: number | string, w = 400, h = 400): string {
return `/api/nextcloud/talk/files/${fileId}/preview?w=${w}&h=${h}`;
},
searchUsers(query: string): Promise<NextcloudUser[]> {
return api
.get<ApiResponse<NextcloudUser[]>>(`/api/nextcloud/talk/users`, { params: { search: query } })
.then((r) => r.data.data);
},
createRoom(roomType: number, invite: string, roomName?: string): Promise<{ token: string }> {
return api
.post<ApiResponse<{ token: string }>>('/api/nextcloud/talk/rooms', { roomType, invite, ...(roomName ? { roomName } : {}) })
.then((r) => r.data.data);
},
addReaction(token: string, messageId: number, reaction: string): Promise<void> {
return api
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages/${messageId}/reactions`, { reaction })
.then(() => undefined);
},
removeReaction(token: string, messageId: number, reaction: string): Promise<void> {
return api
.delete(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages/${messageId}/reactions`, { params: { reaction } })
.then(() => undefined);
},
getReactions(token: string, messageId: number): Promise<Record<string, NextcloudReaction[]>> {
return api
.get<ApiResponse<Record<string, NextcloudReaction[]>>>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages/${messageId}/reactions`)
.then((r) => r.data.data);
},
getPollDetails(token: string, pollId: number): Promise<NextcloudPoll> {
return api
.get<ApiResponse<NextcloudPoll>>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/polls/${pollId}`)
.then((r) => r.data.data);
},
};

View File

@@ -39,6 +39,9 @@ export interface NextcloudMessage {
messageType: string;
systemMessage: string;
messageParameters?: Record<string, any>;
parent?: NextcloudMessage;
reactions?: Record<string, number>;
reactionsSelf?: string[];
}
export interface NextcloudRoomListData {
@@ -46,3 +49,28 @@ export interface NextcloudRoomListData {
rooms: NextcloudConversation[];
loginName?: string;
}
export interface NextcloudUser {
id: string;
label: string;
source: string;
}
export interface NextcloudReaction {
actorDisplayName: string;
actorId: string;
actorType: string;
timestamp: number;
}
export interface NextcloudPoll {
id: number;
question: string;
options: string[];
votes: Record<string, number[]>;
numVoters: number;
resultMode: number;
maxVotes: number;
votedSelf?: number[];
status: number;
}