diff --git a/backend/src/controllers/atemschutz.controller.ts b/backend/src/controllers/atemschutz.controller.ts index 72e61ea..95ae057 100644 --- a/backend/src/controllers/atemschutz.controller.ts +++ b/backend/src/controllers/atemschutz.controller.ts @@ -120,6 +120,29 @@ class AtemschutzController { } } + async getByUserId(req: Request, res: Response): Promise { + try { + const { userId } = req.params as Record; + 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 { try { const userId = getUserId(req); diff --git a/backend/src/controllers/member.controller.ts b/backend/src/controllers/member.controller.ts index 7cd3a25..c91c9db 100644 --- a/backend/src/controllers/member.controller.ts +++ b/backend/src/controllers/member.controller.ts @@ -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 }); diff --git a/backend/src/controllers/nextcloud.controller.ts b/backend/src/controllers/nextcloud.controller.ts index 4acfecf..71fc6b2 100644 --- a/backend/src/controllers/nextcloud.controller.ts +++ b/backend/src/controllers/nextcloud.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/backend/src/routes/atemschutz.routes.ts b/backend/src/routes/atemschutz.routes.ts index 12fe777..29ac6f1 100644 --- a/backend/src/routes/atemschutz.routes.ts +++ b/backend/src/routes/atemschutz.routes.ts @@ -9,8 +9,9 @@ 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('/:id', authenticate, atemschutzController.getOne.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+ ───────────────────────────────────────────────── diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts index ad63505..f7ef775 100644 --- a/backend/src/services/nextcloud.service.ts +++ b/backend/src/services/nextcloud.service.ts @@ -139,6 +139,8 @@ interface NextcloudChatMessage { messageType: string; systemMessage: string; messageParameters: Record; + reactions: Record; + reactionsSelf: string[]; } async function getAllConversations(loginName: string, appPassword: string): Promise { @@ -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 { +async function sendMessage(token: string, message: string, loginName: string, appPassword: string, replyTo?: number): Promise { 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 { + 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 { + 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 { + 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> { + 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> { + 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 }; diff --git a/frontend/src/components/chat/MessageReactions.tsx b/frontend/src/components/chat/MessageReactions.tsx new file mode 100644 index 0000000..973f86b --- /dev/null +++ b/frontend/src/components/chat/MessageReactions.tsx @@ -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; + reactionsSelf: string[]; + onReactionToggled: () => void; +} + +const MessageReactions: React.FC = ({ + token, + messageId, + reactions, + reactionsSelf, + onReactionToggled, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const [loading, setLoading] = useState(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 ( + + {hasReactions && Object.entries(reactions).map(([emoji, count]) => ( + 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 }, + }} + /> + ))} + + setAnchorEl(e.currentTarget)} + sx={{ width: 20, height: 20, opacity: 0.6, '&:hover': { opacity: 1 } }} + > + + + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + + {QUICK_REACTIONS.map((emoji) => ( + { handleToggle(emoji); setAnchorEl(null); }} + sx={{ fontSize: '1rem', minWidth: 32, minHeight: 32 }} + > + {emoji} + + ))} + + + + ); +}; + +export default MessageReactions; diff --git a/frontend/src/components/chat/NewChatDialog.tsx b/frontend/src/components/chat/NewChatDialog.tsx new file mode 100644 index 0000000..93e8059 --- /dev/null +++ b/frontend/src/components/chat/NewChatDialog.tsx @@ -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 = ({ 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 ( + + Neues Gespr\u00E4ch + + setSearch(e.target.value)} + sx={{ mb: 1 }} + /> + {(isLoading || creating) && ( + + + + )} + {!isLoading && !creating && search.length >= 2 && (!users || users.length === 0) && ( + + Keine Benutzer gefunden + + )} + {!creating && users && users.length > 0 && ( + + {users.map((user) => ( + + handleSelect(user.id, user.label)}> + + + {user.label.substring(0, 2).toUpperCase()} + + + + + + ))} + + )} + + + ); +}; + +export default NewChatDialog; diff --git a/frontend/src/components/chat/PollMessageContent.tsx b/frontend/src/components/chat/PollMessageContent.tsx new file mode 100644 index 0000000..4ff75da --- /dev/null +++ b/frontend/src/components/chat/PollMessageContent.tsx @@ -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 = ({ roomToken, pollId, pollName, isOwnMessage }) => { + const { data: poll } = useQuery({ + queryKey: ['nextcloud', 'poll', roomToken, pollId], + queryFn: () => nextcloudApi.getPollDetails(roomToken, pollId), + staleTime: 60_000, + }); + + return ( + + + + Abstimmung + + + {poll?.question ?? pollName} + + {poll?.options && poll.options.slice(0, 4).map((opt, idx) => ( + + • {opt} + + ))} + {poll?.options && poll.options.length > 4 && ( + + +{poll.options.length - 4} weitere Optionen + + )} + + ); +}; + +export default PollMessageContent; diff --git a/frontend/src/components/chat/RichMessageText.tsx b/frontend/src/components/chat/RichMessageText.tsx new file mode 100644 index 0000000..7fa1ad7 --- /dev/null +++ b/frontend/src/components/chat/RichMessageText.tsx @@ -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; + isOwnMessage?: boolean; +} + +const RichMessageText: React.FC = ({ message, messageParameters, isOwnMessage }) => { + const parts = message.split(/(\{[^}]+\})/); + + const elements = parts.map((part, i) => { + const match = part.match(/^\{([^}]+)\}$/); + if (!match) { + return part ? {part} : null; + } + + const key = match[1]; + const param = messageParameters?.[key]; + if (!param) return {part}; + + const { type, name, id } = param; + + if (type === 'user' || type === 'guest' || type === 'call' || type === 'user-group') { + return ( + + ); + } + + if (type === 'highlight' || key === 'actor') { + return {name ?? id}; + } + + return {name ?? id ?? key}; + }); + + return ( + + {elements} + + ); +}; + +export default RichMessageText; diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index 263fd60..5c66a77 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -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 ; + 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 ; +} + // ---------------------------------------------------------------- // 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(null); + const [atemschutzLoading, setAtemschutzLoading] = useState(false); + // Edit form state — only the fields the user is allowed to change const [formData, setFormData] = useState({}); @@ -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() { )} - {/* Edit controls */} - {canEdit && (canWrite || !!profile) && ( + {/* Edit controls — only shown when profile exists */} + {canEdit && !!profile && ( {editMode ? ( @@ -875,22 +915,128 @@ function MitgliedDetail() { - {/* ---- Tab 1: Qualifikationen (placeholder) ---- */} + {/* ---- Tab 1: Qualifikationen ---- */} - - - - - - Qualifikationen & Lehrgänge - - - Diese Funktion wird in einer zukünftigen Version verfügbar sein. - Geplant: Atemschutz, G26-Untersuchungen, Absolvierte Kurse, Gültigkeitsdaten. - - - - + {atemschutzLoading ? ( + + + + ) : atemschutz ? ( + + + + } + title="Atemschutz" + action={ + : } + label={atemschutz.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'} + color={atemschutz.einsatzbereit ? 'success' : 'error'} + size="small" + /> + } + /> + + + + + + G26.3 Untersuchung + + + + } + /> + + + + + Leistungstest (Finnentest) + + + + } + /> + + + {atemschutz.bemerkung && ( + <> + + + + )} + + + + + ) : ( + + + + + + Kein Atemschutz-Eintrag + + + Für dieses Mitglied ist kein Atemschutz-Datensatz vorhanden. + + {canWrite && ( + + )} + + + + )} {/* ---- Tab 2: Einsätze (placeholder) ---- */} diff --git a/frontend/src/services/atemschutz.ts b/frontend/src/services/atemschutz.ts index d8f84ee..9900176 100644 --- a/frontend/src/services/atemschutz.ts +++ b/frontend/src/services/atemschutz.ts @@ -24,6 +24,13 @@ export const atemschutzApi = { ); }, + async getByUserId(userId: string): Promise { + const response = await api.get<{ success: boolean; data: AtemschutzUebersicht | null }>( + `/api/atemschutz/user/${userId}` + ); + return response.data?.data ?? null; + }, + async getMyStatus(): Promise { const response = await api.get<{ success: boolean; data: AtemschutzUebersicht | null }>( '/api/atemschutz/my-status' diff --git a/frontend/src/services/nextcloud.ts b/frontend/src/services/nextcloud.ts index 5594fd2..e8e94ec 100644 --- a/frontend/src/services/nextcloud.ts +++ b/frontend/src/services/nextcloud.ts @@ -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 { success: boolean; @@ -57,9 +57,9 @@ export const nextcloudApi = { .then((r) => r.data.data); }, - sendMessage(token: string, message: string): Promise { + sendMessage(token: string, message: string, replyTo?: number): Promise { 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 { + return api + .get>(`/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>('/api/nextcloud/talk/rooms', { roomType, invite, ...(roomName ? { roomName } : {}) }) + .then((r) => r.data.data); + }, + + addReaction(token: string, messageId: number, reaction: string): Promise { + return api + .post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages/${messageId}/reactions`, { reaction }) + .then(() => undefined); + }, + + removeReaction(token: string, messageId: number, reaction: string): Promise { + return api + .delete(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages/${messageId}/reactions`, { params: { reaction } }) + .then(() => undefined); + }, + + getReactions(token: string, messageId: number): Promise> { + return api + .get>>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages/${messageId}/reactions`) + .then((r) => r.data.data); + }, + + getPollDetails(token: string, pollId: number): Promise { + return api + .get>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/polls/${pollId}`) + .then((r) => r.data.data); + }, }; diff --git a/frontend/src/types/nextcloud.types.ts b/frontend/src/types/nextcloud.types.ts index 32f4a91..ab2fba4 100644 --- a/frontend/src/types/nextcloud.types.ts +++ b/frontend/src/types/nextcloud.types.ts @@ -39,6 +39,9 @@ export interface NextcloudMessage { messageType: string; systemMessage: string; messageParameters?: Record; + parent?: NextcloudMessage; + reactions?: Record; + 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; + numVoters: number; + resultMode: number; + maxVotes: number; + votedSelf?: number[]; + status: number; +}