486 lines
20 KiB
TypeScript
486 lines
20 KiB
TypeScript
/**
|
|
* TeamsBot Modules View
|
|
*
|
|
* CRUD list of MeetingModules with expandable session lists per module.
|
|
*/
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
|
import type { MeetingModule, TeamsbotSession, MediaFileInfo } from '../../../api/teamsbotApi';
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
import { useFileContext } from '../../../contexts/FileContext';
|
|
import { FaSpinner } from 'react-icons/fa';
|
|
import styles from './Teamsbot.module.css';
|
|
|
|
const SERIES_TYPE_LABELS: Record<string, string> = {
|
|
weekly: 'Wöchentlich',
|
|
biweekly: 'Zweiwöchentlich',
|
|
monthly: 'Monatlich',
|
|
adhoc: 'Adhoc',
|
|
project: 'Projekt',
|
|
};
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
active: 'Aktiv',
|
|
archived: 'Archiviert',
|
|
completed: 'Abgeschlossen',
|
|
};
|
|
|
|
export const TeamsbotModulesView: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const { instance, mandateId } = useCurrentInstance();
|
|
const instanceId = instance?.id || '';
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const focusModuleId = searchParams.get('moduleId') || '';
|
|
const moduleRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
|
|
const [modules, setModules] = useState<MeetingModule[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [moduleSessions, setModuleSessions] = useState<Record<string, TeamsbotSession[]>>({});
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
const [editingModule, setEditingModule] = useState<MeetingModule | null>(null);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [createTitle, setCreateTitle] = useState('');
|
|
const [createSeriesType, setCreateSeriesType] = useState<string>('adhoc');
|
|
const [createDefaultLink, setCreateDefaultLink] = useState('');
|
|
const [createDefaultBotName, setCreateDefaultBotName] = useState('');
|
|
const [createGoals, setCreateGoals] = useState('');
|
|
const [createDefaultAvatarFileId, setCreateDefaultAvatarFileId] = useState('');
|
|
const [createSaving, setCreateSaving] = useState(false);
|
|
|
|
const [mediaFiles, setMediaFiles] = useState<MediaFileInfo[]>([]);
|
|
const fileCtx = useFileContext();
|
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
|
const [avatarTarget, setAvatarTarget] = useState<'create' | 'edit'>('create');
|
|
|
|
const _refreshMediaFiles = useCallback(async () => {
|
|
const result = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
|
|
setMediaFiles(result);
|
|
}, []);
|
|
|
|
const _handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file || !fileCtx?.handleFileUpload) return;
|
|
setAvatarUploading(true);
|
|
try {
|
|
const result = await fileCtx.handleFileUpload(file);
|
|
if (result?.success) {
|
|
const data: any = (result.fileData as any)?.file || result.fileData;
|
|
const id = data?.id || (result.fileData as any)?.id;
|
|
if (id) {
|
|
if (avatarTarget === 'create') {
|
|
setCreateDefaultAvatarFileId(id);
|
|
} else if (editingModule) {
|
|
setEditingModule({ ...editingModule, defaultAvatarFileId: id });
|
|
}
|
|
const refreshed = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
|
|
setMediaFiles(refreshed);
|
|
}
|
|
}
|
|
} catch {
|
|
// upload error handled by FileContext
|
|
} finally {
|
|
setAvatarUploading(false);
|
|
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const _loadModules = useCallback(async () => {
|
|
if (!instanceId) return;
|
|
setLoading(true);
|
|
try {
|
|
const result = await teamsbotApi.listModules(instanceId);
|
|
setModules(result || []);
|
|
} catch (err) {
|
|
console.error('Failed to load modules:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [instanceId]);
|
|
|
|
useEffect(() => { _loadModules(); }, [_loadModules]);
|
|
|
|
useEffect(() => {
|
|
teamsbotApi.listMediaFiles().then(setMediaFiles).catch(() => {});
|
|
}, []);
|
|
|
|
const _loadModuleSessions = useCallback(async (moduleId: string) => {
|
|
if (!instanceId) return;
|
|
try {
|
|
const detail = await teamsbotApi.getModuleDetail(instanceId, moduleId);
|
|
setModuleSessions(prev => ({ ...prev, [moduleId]: detail?.sessions || [] }));
|
|
} catch (err) {
|
|
console.error('Failed to load module sessions:', err);
|
|
}
|
|
}, [instanceId]);
|
|
|
|
useEffect(() => {
|
|
if (!focusModuleId || modules.length === 0) return;
|
|
if (!modules.some((m) => m.id === focusModuleId)) return;
|
|
setExpandedId(focusModuleId);
|
|
_loadModuleSessions(focusModuleId);
|
|
}, [focusModuleId, modules, _loadModuleSessions]);
|
|
|
|
useEffect(() => {
|
|
if (!focusModuleId || expandedId !== focusModuleId) return;
|
|
requestAnimationFrame(() => {
|
|
moduleRowRefs.current[focusModuleId]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
});
|
|
}, [focusModuleId, expandedId]);
|
|
|
|
const _toggleExpand = (moduleId: string) => {
|
|
if (expandedId === moduleId) {
|
|
setExpandedId(null);
|
|
} else {
|
|
setExpandedId(moduleId);
|
|
if (!moduleSessions[moduleId]) _loadModuleSessions(moduleId);
|
|
}
|
|
};
|
|
|
|
const _handleDelete = async (moduleId: string) => {
|
|
try {
|
|
await teamsbotApi.deleteModule(instanceId, moduleId);
|
|
setDeleteConfirm(null);
|
|
_loadModules();
|
|
} catch (err) {
|
|
console.error('Delete failed:', err);
|
|
}
|
|
};
|
|
|
|
const _handleUpdate = async (moduleId: string, updates: Partial<MeetingModule>) => {
|
|
try {
|
|
await teamsbotApi.updateModule(instanceId, moduleId, updates);
|
|
setEditingModule(null);
|
|
_loadModules();
|
|
} catch (err) {
|
|
console.error('Update failed:', err);
|
|
}
|
|
};
|
|
|
|
const _handleCreateModule = async () => {
|
|
if (!createTitle.trim()) return;
|
|
setCreateSaving(true);
|
|
try {
|
|
await teamsbotApi.createModule(instanceId, {
|
|
title: createTitle.trim(),
|
|
seriesType: createSeriesType,
|
|
defaultMeetingLink: createDefaultLink.trim() || undefined,
|
|
defaultBotName: createDefaultBotName.trim() || undefined,
|
|
defaultAvatarFileId: createDefaultAvatarFileId || undefined,
|
|
goals: createGoals.trim() || undefined,
|
|
});
|
|
setCreateOpen(false);
|
|
setCreateTitle('');
|
|
setCreateSeriesType('adhoc');
|
|
setCreateDefaultLink('');
|
|
setCreateDefaultBotName('');
|
|
setCreateDefaultAvatarFileId('');
|
|
setCreateGoals('');
|
|
_loadModules();
|
|
} catch (err) {
|
|
console.error('Create module failed:', err);
|
|
} finally {
|
|
setCreateSaving(false);
|
|
}
|
|
};
|
|
|
|
const _formatSessionDateTime = (ts?: string | number): string => {
|
|
if (ts == null) return '-';
|
|
const ms = typeof ts === 'number' ? ts * 1000 : Date.parse(String(ts));
|
|
if (Number.isNaN(ms)) return '-';
|
|
const d = new Date(ms);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
};
|
|
|
|
const _calcDurationMin = (startedAt?: string | number, endedAt?: string | number): string => {
|
|
if (!startedAt) return '-';
|
|
const startMs = typeof startedAt === 'number' ? startedAt * 1000 : Date.parse(String(startedAt));
|
|
if (Number.isNaN(startMs)) return '-';
|
|
const endMs = endedAt
|
|
? (typeof endedAt === 'number' ? endedAt * 1000 : Date.parse(String(endedAt)))
|
|
: Date.now();
|
|
if (Number.isNaN(endMs)) return '-';
|
|
const mins = Math.round((endMs - startMs) / 60000);
|
|
return `${mins} min`;
|
|
};
|
|
|
|
const _sortedSessions = (sessions: TeamsbotSession[]): TeamsbotSession[] =>
|
|
[...sessions].sort((a, b) => {
|
|
const ta = a.startedAt ? (typeof a.startedAt === 'number' ? a.startedAt * 1000 : Date.parse(String(a.startedAt))) : 0;
|
|
const tb = b.startedAt ? (typeof b.startedAt === 'number' ? b.startedAt * 1000 : Date.parse(String(b.startedAt))) : 0;
|
|
return tb - ta;
|
|
});
|
|
|
|
return (
|
|
<div className={styles.modulesContainer}>
|
|
<div className={styles.modulesHeader}>
|
|
<h2>{t('Meeting-Module')}</h2>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
<button
|
|
type="button"
|
|
className={styles.btnSecondary}
|
|
onClick={() => setCreateOpen(true)}
|
|
>
|
|
{t('Modul anlegen')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.btnPrimary}
|
|
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
|
>
|
|
{t('Meeting starten')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
|
|
|
|
<div className={styles.modulesList}>
|
|
{modules.map(mod => (
|
|
<div
|
|
key={mod.id}
|
|
ref={(el) => { moduleRowRefs.current[mod.id] = el; }}
|
|
className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''} ${focusModuleId === mod.id ? styles.moduleRowFocused : ''}`}
|
|
>
|
|
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
|
|
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
|
|
<span className={styles.moduleTitle}>{mod.title}</span>
|
|
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status || 'Aktiv')}</span>
|
|
<div className={styles.moduleActions}>
|
|
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
|
|
e.stopPropagation();
|
|
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant?moduleId=${mod.id}`);
|
|
}}>{t('Meeting starten')}</button>
|
|
<button className={styles.btnSmall} onClick={e => { e.stopPropagation(); setEditingModule(mod); }}>{t('Bearbeiten')}</button>
|
|
<button className={styles.btnSmallDanger} onClick={e => { e.stopPropagation(); setDeleteConfirm(mod.id); }}>{t('Löschen')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
{expandedId === mod.id && (
|
|
<div className={styles.moduleSessionsList}>
|
|
{(moduleSessions[mod.id] || []).length === 0 ? (
|
|
<p className={styles.noSessions}>{t('Keine Sitzungen')}</p>
|
|
) : (
|
|
<table className={styles.sessionTable}>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 32 }}></th>
|
|
<th>{t('Datum')}</th>
|
|
<th>{t('Dauer')}</th>
|
|
<th>{t('Status')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{_sortedSessions(moduleSessions[mod.id] || []).map((sess) => (
|
|
<tr
|
|
key={sess.id}
|
|
className={styles.sessionTableRow}
|
|
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)}
|
|
>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
className={styles.sessionDeleteBtn}
|
|
title={t('Sitzung loeschen')}
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await teamsbotApi.deleteSession(instanceId, sess.id);
|
|
setModuleSessions((prev) => ({
|
|
...prev,
|
|
[mod.id]: (prev[mod.id] || []).filter((s) => s.id !== sess.id),
|
|
}));
|
|
} catch (err) {
|
|
console.error('Delete session failed:', err);
|
|
}
|
|
}}
|
|
>
|
|
x
|
|
</button>
|
|
</td>
|
|
<td>{_formatSessionDateTime(sess.startedAt)}</td>
|
|
<td>{_calcDurationMin(sess.startedAt, sess.endedAt)}</td>
|
|
<td><span className={styles.sessionStatus}>{sess.status}</span></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{createOpen && (
|
|
<div className={styles.confirmOverlay}>
|
|
<div className={styles.editDialog}>
|
|
<h3>{t('Modul anlegen')}</h3>
|
|
<label className={styles.label}>{t('Titel')}</label>
|
|
<input
|
|
type="text"
|
|
className={styles.wizardInput}
|
|
value={createTitle}
|
|
onChange={e => setCreateTitle(e.target.value)}
|
|
placeholder={t('z.B. Weekly Standup')}
|
|
autoFocus
|
|
/>
|
|
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Serientyp')}</label>
|
|
<select
|
|
className={styles.wizardSelect}
|
|
value={createSeriesType}
|
|
onChange={e => setCreateSeriesType(e.target.value)}
|
|
>
|
|
{Object.entries(SERIES_TYPE_LABELS).map(([code, lab]) => (
|
|
<option key={code} value={code}>{lab}</option>
|
|
))}
|
|
</select>
|
|
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
|
|
<input
|
|
type="text"
|
|
className={styles.wizardInput}
|
|
value={createDefaultLink}
|
|
onChange={e => setCreateDefaultLink(e.target.value)}
|
|
placeholder="https://teams.microsoft.com/..."
|
|
/>
|
|
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
|
|
<input
|
|
type="text"
|
|
className={styles.wizardInput}
|
|
value={createDefaultBotName}
|
|
onChange={e => setCreateDefaultBotName(e.target.value)}
|
|
/>
|
|
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Avatar-Bild / Video')}</label>
|
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
<select
|
|
className={styles.wizardSelect}
|
|
style={{ flex: 1 }}
|
|
value={createDefaultAvatarFileId}
|
|
onChange={e => setCreateDefaultAvatarFileId(e.target.value)}
|
|
onFocus={_refreshMediaFiles}
|
|
>
|
|
<option value="">{t('Standard (aus Bot-Einstellungen)')}</option>
|
|
{mediaFiles.map(f => (
|
|
<option key={f.id} value={f.id}>{f.fileName} ({f.mimeType})</option>
|
|
))}
|
|
</select>
|
|
<button type="button" className={styles.btnSmall} disabled={avatarUploading} onClick={() => { setAvatarTarget('create'); avatarInputRef.current?.click(); }} style={{ whiteSpace: 'nowrap' }}>
|
|
{avatarUploading && avatarTarget === 'create' ? <FaSpinner className={styles.spinner} /> : null}
|
|
{t('Hochladen')}
|
|
</button>
|
|
</div>
|
|
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Ziele')}</label>
|
|
<textarea
|
|
className={styles.wizardTextarea}
|
|
rows={2}
|
|
value={createGoals}
|
|
onChange={e => setCreateGoals(e.target.value)}
|
|
/>
|
|
<div className={styles.confirmActions}>
|
|
<button type="button" className={styles.btnSecondary} onClick={() => setCreateOpen(false)}>{t('Abbrechen')}</button>
|
|
<button
|
|
type="button"
|
|
className={styles.btnPrimary}
|
|
onClick={_handleCreateModule}
|
|
disabled={!createTitle.trim() || createSaving}
|
|
>
|
|
{createSaving ? t('Speichern…') : t('Anlegen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{deleteConfirm && (
|
|
<div className={styles.confirmOverlay}>
|
|
<div className={styles.confirmDialog}>
|
|
<p>{t('Modul wirklich löschen? Sessions werden dem Modul entkoppelt.')}</p>
|
|
<div className={styles.confirmActions}>
|
|
<button className={styles.btnSecondary} onClick={() => setDeleteConfirm(null)}>{t('Abbrechen')}</button>
|
|
<button className={styles.btnDanger} onClick={() => _handleDelete(deleteConfirm)}>{t('Löschen')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editingModule && (
|
|
<div className={styles.confirmOverlay}>
|
|
<div className={styles.editDialog}>
|
|
<h3>{t('Modul bearbeiten')}</h3>
|
|
<input
|
|
type="text"
|
|
defaultValue={editingModule.title}
|
|
className={styles.wizardInput}
|
|
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
|
|
/>
|
|
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
|
|
<input
|
|
type="text"
|
|
defaultValue={editingModule.defaultMeetingLink || ''}
|
|
className={styles.wizardInput}
|
|
placeholder="https://teams.microsoft.com/l/meetup-join/..."
|
|
onBlur={e => setEditingModule({ ...editingModule, defaultMeetingLink: e.target.value })}
|
|
/>
|
|
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
|
|
<input
|
|
type="text"
|
|
defaultValue={editingModule.defaultBotName || ''}
|
|
className={styles.wizardInput}
|
|
placeholder={t('z.B. AI Assistant')}
|
|
onBlur={e => setEditingModule({ ...editingModule, defaultBotName: e.target.value })}
|
|
/>
|
|
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Avatar-Bild / Video')}</label>
|
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
<select
|
|
className={styles.wizardSelect}
|
|
style={{ flex: 1 }}
|
|
value={editingModule.defaultAvatarFileId || ''}
|
|
onChange={e => setEditingModule({ ...editingModule, defaultAvatarFileId: e.target.value || undefined })}
|
|
onFocus={_refreshMediaFiles}
|
|
>
|
|
<option value="">{t('Standard (aus Bot-Einstellungen)')}</option>
|
|
{mediaFiles.map(f => (
|
|
<option key={f.id} value={f.id}>{f.fileName} ({f.mimeType})</option>
|
|
))}
|
|
</select>
|
|
<button type="button" className={styles.btnSmall} disabled={avatarUploading} onClick={() => { setAvatarTarget('edit'); avatarInputRef.current?.click(); }} style={{ whiteSpace: 'nowrap' }}>
|
|
{avatarUploading && avatarTarget === 'edit' ? <FaSpinner className={styles.spinner} /> : null}
|
|
{t('Hochladen')}
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
defaultValue={editingModule.goals || ''}
|
|
className={styles.wizardTextarea}
|
|
placeholder={t('Ziele')}
|
|
rows={3}
|
|
onBlur={e => setEditingModule({ ...editingModule, goals: e.target.value })}
|
|
/>
|
|
<div className={styles.confirmActions}>
|
|
<button className={styles.btnSecondary} onClick={() => setEditingModule(null)}>{t('Abbrechen')}</button>
|
|
<button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, {
|
|
title: editingModule.title,
|
|
goals: editingModule.goals,
|
|
defaultMeetingLink: (editingModule.defaultMeetingLink || '').trim(),
|
|
defaultBotName: (editingModule.defaultBotName || '').trim(),
|
|
defaultAvatarFileId: (editingModule.defaultAvatarFileId || '').trim() || undefined,
|
|
})}>{t('Speichern')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<input
|
|
ref={avatarInputRef}
|
|
type="file"
|
|
accept="image/*,video/*"
|
|
style={{ display: 'none' }}
|
|
onChange={_handleAvatarUpload}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|