update
This commit is contained in:
101
frontend/src/components/chat/MessageReactions.tsx
Normal file
101
frontend/src/components/chat/MessageReactions.tsx
Normal 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;
|
||||
97
frontend/src/components/chat/NewChatDialog.tsx
Normal file
97
frontend/src/components/chat/NewChatDialog.tsx
Normal 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;
|
||||
54
frontend/src/components/chat/PollMessageContent.tsx
Normal file
54
frontend/src/components/chat/PollMessageContent.tsx
Normal 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;
|
||||
63
frontend/src/components/chat/RichMessageText.tsx
Normal file
63
frontend/src/components/chat/RichMessageText.tsx
Normal 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;
|
||||
@@ -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}>
|
||||
<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
|
||||
</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.
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{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">
|
||||
Kein Atemschutz-Eintrag
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
|
||||
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) ---- */}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user