ui-nyla/src/pages/views/teamsbot/TeamsbotModulesView.tsx
2026-05-12 22:39:45 +02:00

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>
);
};