int #6

Merged
p.motsch merged 6 commits from int into main 2026-06-12 12:08:34 +00:00
26 changed files with 358 additions and 204 deletions
Showing only changes of commit 766a767d59 - Show all commits

View file

@ -106,18 +106,27 @@ api.interceptors.request.use(
}
);
// Add a response interceptor to handle token expiration
// Silent refresh: attempt token renewal before forcing re-login
let _isRefreshing = false;
let _refreshSubscribers: Array<(success: boolean) => void> = [];
function _onRefreshDone(success: boolean) {
_refreshSubscribers.forEach(cb => cb(success));
_refreshSubscribers = [];
}
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Don't redirect to login if the request was to a login endpoint
const isLoginEndpoint = error.config?.url?.includes('/login') ||
error.config?.url?.includes('/api/local/login') ||
error.config?.url?.includes('/api/msft/auth/login') ||
error.config?.url?.includes('/api/google/auth/login');
const originalRequest = error.config;
if (error.response?.status === 401) {
const isAuthEndpoint = originalRequest?.url?.includes('/login') ||
originalRequest?.url?.includes('/api/local/login') ||
originalRequest?.url?.includes('/api/local/refresh') ||
originalRequest?.url?.includes('/api/msft/auth/login') ||
originalRequest?.url?.includes('/api/google/auth/login');
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
const pathname = window.location.pathname;
const isOnPublicAuthPage = pathname === '/login' ||
pathname.startsWith('/login') ||
@ -129,19 +138,52 @@ api.interceptors.response.use(
pathname.startsWith('/password-reset-request') ||
pathname.startsWith('/invite');
if (!isLoginEndpoint && !isOnPublicAuthPage) {
// Clear local auth data (httpOnly cookies are cleared by backend)
if (isAuthEndpoint || isOnPublicAuthPage) {
return Promise.reject(error);
}
// Attempt silent refresh (only once per request)
if (!originalRequest._retryAfterRefresh) {
originalRequest._retryAfterRefresh = true;
if (!_isRefreshing) {
_isRefreshing = true;
try {
await api.post('/api/local/refresh');
_isRefreshing = false;
_onRefreshDone(true);
return api(originalRequest);
} catch {
_isRefreshing = false;
_onRefreshDone(false);
sessionStorage.removeItem('auth_authority');
clearUserDataCache();
// Redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
} else {
// Another request is already refreshing; queue this one
return new Promise((resolve, reject) => {
_refreshSubscribers.push((success: boolean) => {
if (success) {
resolve(api(originalRequest));
} else {
reject(error);
}
});
});
}
}
// Handle rate limiting (429) - don't throw, just log and return error
// Refresh already failed for this request
sessionStorage.removeItem('auth_authority');
clearUserDataCache();
window.location.href = '/login';
}
// Handle rate limiting (429)
if (error.response?.status === 429) {
console.warn('Rate limit exceeded (429). Please wait before making more requests.');
// Don't cause cascading errors by throwing here
}
return Promise.reject(error);

View file

@ -3332,7 +3332,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
colSpan={(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length}
style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--text-muted, #64748b)' }}
>
{emptyMessage || t('Keine Daten verfügbar')}
{supportsBackendPagination && !hookDataProp?.pagination
? t('Lade Daten...')
: (emptyMessage || t('Keine Daten verfügbar'))}
</td>
</tr>
)}

View file

@ -9,7 +9,7 @@
* Tab D: Neutralization Mappings FormGeneratorTable + delete
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid,
@ -195,6 +195,10 @@ export const ComplianceAuditPage: React.FC = () => {
const selectedEntryId = searchParams.get('entryId');
const _lastAiLogParams = useRef<any>(undefined);
const _lastAuditLogParams = useRef<any>(undefined);
const _lastNeutParams = useRef<any>(undefined);
// ── Mandate loader ──
useEffect(() => {
@ -222,6 +226,8 @@ export const ComplianceAuditPage: React.FC = () => {
const _loadAiLog = useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return;
if (paginationParams) _lastAiLogParams.current = paginationParams;
else paginationParams = _lastAiLogParams.current;
setAiLoading(true);
try {
const page = paginationParams?.page ?? 1;
@ -254,6 +260,8 @@ export const ComplianceAuditPage: React.FC = () => {
const _loadAuditLog = useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return;
if (paginationParams) _lastAuditLogParams.current = paginationParams;
else paginationParams = _lastAuditLogParams.current;
setAuditLoading(true);
try {
const page = paginationParams?.page ?? 1;
@ -301,6 +309,8 @@ export const ComplianceAuditPage: React.FC = () => {
const _loadNeutMappings = useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return;
if (paginationParams) _lastNeutParams.current = paginationParams;
else paginationParams = _lastNeutParams.current;
setNeutLoading(true);
try {
const page = paginationParams?.page ?? 1;
@ -369,10 +379,7 @@ export const ComplianceAuditPage: React.FC = () => {
useEffect(() => {
if (!selectedMandateId) return;
if (activeTab === 'ai-log') void _loadAiLog();
else if (activeTab === 'audit-log') void _loadAuditLog();
else if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate });
else if (activeTab === 'neutralization') void _loadNeutMappings();
if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate });
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Content view handler (modal) ──
@ -866,6 +873,7 @@ export const ComplianceAuditPage: React.FC = () => {
filterable={true}
searchable={true}
selectable={false}
initialSort={[{ key: 'timestamp', direction: 'desc' }]}
emptyMessage={t('Keine Audit-Einträge vorhanden.')}
onRefresh={_loadAuditLog}
hookData={auditLogHookData}
@ -891,6 +899,7 @@ export const ComplianceAuditPage: React.FC = () => {
filterable={true}
searchable={true}
selectable={false}
initialSort={[{ key: 'timestamp', direction: 'desc' }]}
emptyMessage={t('Keine AI-Audit-Einträge vorhanden.')}
onRefresh={_loadAiLog}
hookData={aiLogHookData}

View file

@ -42,7 +42,8 @@ const _downloadJson = (data: unknown, fileName: string, mimeType = 'application/
export const GDPRPage: React.FC = () => {
const { t } = useLanguage();
const contactEmail = 'p.motsch@poweron.swiss';
const contactEmail = 'privacy@poweron.swiss';
const deleteWord = t('LOESCHEN');
const [consentInfo, setConsentInfo] = useState<ConsentInfo | null>(null);
const [isLoadingConsent, setIsLoadingConsent] = useState(true);
const [consentError, setConsentError] = useState<string | null>(null);
@ -123,8 +124,8 @@ export const GDPRPage: React.FC = () => {
const handleDeleteAccount = async () => {
setActionMessage(null);
if (deleteConfirmText !== 'LOESCHEN') {
setActionMessage({ type: 'error', text: t('Bitte geben Sie LOESCHEN ein, um die Löschung zu bestätigen.') });
if (deleteConfirmText !== deleteWord) {
setActionMessage({ type: 'error', text: t('Bitte geben Sie {word} ein, um die Löschung zu bestätigen.', { word: deleteWord }) });
return;
}
@ -227,14 +228,14 @@ export const GDPRPage: React.FC = () => {
<div className={styles.deleteConfirm}>
<p className={styles.deleteWarning}>
{t('Diese Aktion ist unwiderruflich. Geben Sie {word} ein, um zu bestätigen.', {
word: 'LOESCHEN',
word: deleteWord,
})}
</p>
<input
className={styles.deleteInput}
value={deleteConfirmText}
onChange={(event) => setDeleteConfirmText(event.target.value)}
placeholder="LOESCHEN"
placeholder={deleteWord}
disabled={isDeleting}
/>
<div className={styles.deleteActions}>
@ -251,7 +252,7 @@ export const GDPRPage: React.FC = () => {
<button
className={styles.dangerButton}
onClick={handleDeleteAccount}
disabled={isDeleting || deleteConfirmText !== 'LOESCHEN'}
disabled={isDeleting || deleteConfirmText !== deleteWord}
>
{isDeleting ? (
<span className={styles.buttonSpinner}>
@ -290,44 +291,34 @@ export const GDPRPage: React.FC = () => {
<div className={styles.infoBlock}>
<h3>{t('Gesammelte Daten')}</h3>
<ul>
{Object.entries(consentInfo.dataCollected || {}).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
<li><strong>{t('Profil')}:</strong> {t('Name, E-Mail, Benutzername, Spracheinstellungen')}</li>
<li><strong>{t('Authentifizierung')}:</strong> {t('Login-Zeitstempel, Authentifizierungsanbieter')}</li>
<li><strong>{t('Mitgliedschaften')}:</strong> {t('Mandanten- und Feature-Zugriffseinträge')}</li>
<li><strong>{t('Aktivität')}:</strong> {t('Audit-Logs für sicherheitsrelevante Aktionen')}</li>
</ul>
</div>
<div className={styles.infoBlock}>
<h3>{t('Verarbeitung')}</h3>
<ul>
{Object.entries(consentInfo.dataProcessing || {}).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
<li><strong>{t('Zweck')}:</strong> {t('Bereitstellung von Multi-Tenant-Plattformdiensten')}</li>
<li><strong>{t('Rechtsgrundlage')}:</strong> {t('Vertragserfüllung und berechtigtes Interesse')}</li>
<li><strong>{t('Aufbewahrung')}:</strong> {t('Daten werden aufbewahrt, solange das Konto aktiv ist, und bei Kontolöschung gelöscht')}</li>
</ul>
</div>
<div className={styles.infoBlock}>
<h3>{t('Ihre Rechte')}</h3>
<ul>
{Object.entries(consentInfo.userRights || {}).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
<li><strong>{t('Auskunft')}:</strong> {t('Datenexport (Artikel 15)')}</li>
<li><strong>{t('Datenübertragbarkeit')}:</strong> {t('Portabler Export (Artikel 20)')}</li>
<li><strong>{t('Löschung')}:</strong> {t('Kontolöschung (Artikel 17)')}</li>
<li><strong>{t('Berichtigung')}:</strong> {t('Profildaten bearbeiten (Artikel 16)')}</li>
</ul>
</div>
<div className={styles.infoBlock}>
<h3>{t('Kontakt')}</h3>
<ul>
{Object.entries({
...(consentInfo.contact || {}),
email: contactEmail,
}).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
<li><strong>{t('E-Mail')}:</strong> {contactEmail}</li>
<li><strong>{t('Hinweis')}:</strong> {t('Bei Datenschutzanfragen kontaktieren Sie uns bitte mit Ihrer Benutzer-ID.')}</li>
</ul>
</div>
</div>

View file

@ -14,7 +14,8 @@ import { useSearchParams } from 'react-router-dom';
import { useLanguage } from '../providers/language/LanguageContext';
import { useApiRequest } from '../hooks/useApi';
import { useConfirm } from '../hooks/useConfirm';
import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi';
import type { RagInventoryDto, RagConnectionDto, RagDataSourceDto, RagFeatureInstanceDto } from '../api/connectionApi';
import api from '../api';
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
@ -169,6 +170,15 @@ export const RagInventoryPage: React.FC = () => {
} catch {}
};
const _handleDsRagToggle = async (connectionId: string, ds: RagDataSourceDto) => {
const nodeKey = `ds|${connectionId}|${ds.sourceType}|${ds.path}`;
const newValue = !ds.ragIndexEnabled;
try {
await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/ragIndexEnabled`, { value: newValue });
_fetchInventory();
} catch {}
};
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
if (currentEnabled) {
const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') });
@ -309,6 +319,14 @@ export const RagInventoryPage: React.FC = () => {
{t('{f} Dateien · {c} Chunks', { f: conn.totalFiles, c: conn.totalChunks })}
</span>
)}
<button
className={styles.reindexBtn}
onClick={() => _openSettingsForConnection(conn)}
title={t('Einstellungen')}
style={{ marginLeft: 'auto' }}
>
<FaSlidersH size={14} />
</button>
<button
className={styles.consentToggle}
onClick={() => _handleConsentToggle(conn.id, conn.knowledgeIngestionEnabled)}
@ -425,7 +443,14 @@ export const RagInventoryPage: React.FC = () => {
>
{ds.fileCount} {t('Dateien')} · {ds.chunkCount} {t('Chunks')}
</span>
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
<button
className={styles.dsIndex}
onClick={() => _handleDsRagToggle(conn.id, ds)}
title={ds.ragIndexEnabled ? t('Indexierung deaktivieren') : t('Indexierung aktivieren')}
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: '2px 4px', fontSize: 'inherit' }}
>
{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}
</button>
</div>
))}
{conn.dataSources.length === 0 && (

View file

@ -63,8 +63,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
const [createFeatureCode, setCreateFeatureCode] = useState<string>('');
const [createLabel, setCreateLabel] = useState<string>(''); // Label field value
// Ref to track form data for featureCode detection
const formDataRef = useRef<Record<string, any>>({});
const _lastTableParams = useRef<any>(undefined);
// Load features, mandates, and attributes on mount
useEffect(() => {
@ -82,12 +82,22 @@ export const AdminFeatureAccessPage: React.FC = () => {
}).catch(() => setBackendAttributes([]));
}, [fetchFeatures, fetchMandates]);
// Load instances when mandate changes
const _refetchInstances = React.useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return;
if (paginationParams && typeof paginationParams === 'object') {
_lastTableParams.current = paginationParams;
} else {
paginationParams = _lastTableParams.current;
}
if (paginationParams) return fetchInstances(paginationParams);
return fetchInstances(selectedMandateId);
}, [selectedMandateId, fetchInstances]);
useEffect(() => {
if (selectedMandateId) {
fetchInstances(selectedMandateId);
_lastTableParams.current = undefined;
}
}, [selectedMandateId, fetchInstances]);
}, [selectedMandateId]);
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 },
@ -147,8 +157,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
setCreateFeatureCode('');
setCreateLabel('');
formDataRef.current = {};
fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache
_refetchInstances();
loadFeatures();
showSuccess(t('Feature-Instanz erstellt'), t('Die Instanz "{name}" wurde erfolgreich erstellt.', { name: createLabel }));
} else {
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Feature-Instanz'));
@ -185,8 +195,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
if (result.success) {
setShowEditModal(false);
setEditingInstance(null);
fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache
_refetchInstances();
loadFeatures();
showSuccess(t('Feature-Instanz aktualisiert'), t('Die Instanz "{name}" wurde erfolgreich aktualisiert.', { name: data.label }));
} else {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Feature-Instanz'));
@ -315,7 +325,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchInstances(selectedMandateId)}
onClick={() => _refetchInstances()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -426,7 +436,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
}
]}
hookData={{
refetch: fetchInstances,
refetch: _refetchInstances,
pagination: instancesPagination,
handleDelete: handleDeleteInstance,
}}

View file

@ -7,7 +7,7 @@
* Allows adding, removing, and updating user roles within feature instances.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useFeatureAccess, type FeatureAccessUser, type FeatureInstanceRole, type PaginationParams, type PaginationMetadata } from '../../hooks/useFeatureAccess';
import { useUserMandates } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
@ -70,6 +70,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
const [, setIsSubmitting] = useState(false);
const [usersLoading, setUsersLoading] = useState(false);
const [usersPagination, setUsersPagination] = useState<PaginationMetadata | null>(null);
const _lastTableParams = useRef<any>(undefined);
// Extract mandateId and instanceId from combined key
const selectedMandateId = useMemo(() => {
@ -137,21 +138,13 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
loadCombinedOptions();
}, [fetchFeatures, fetchMandates]);
// Load users and roles when instance changes
useEffect(() => {
if (selectedMandateId && selectedInstanceId) {
setUsersLoading(true);
Promise.all([
fetchInstanceUsers(selectedMandateId, selectedInstanceId),
fetchInstanceRoles(selectedMandateId, selectedInstanceId),
]).then(([users, roles]) => {
setInstanceUsers(users);
setInstanceRoles(roles);
}).finally(() => {
setUsersLoading(false);
});
_lastTableParams.current = undefined;
fetchInstanceRoles(selectedMandateId, selectedInstanceId)
.then(setInstanceRoles);
}
}, [selectedMandateId, selectedInstanceId, fetchInstanceUsers, fetchInstanceRoles]);
}, [selectedMandateId, selectedInstanceId, fetchInstanceRoles]);
// Load mandate members for the add modal (only users who are members of the selected mandate)
useEffect(() => {
@ -172,12 +165,15 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
}).catch(() => setAllUsers([]));
}, [selectedMandateId]);
// Refresh instance users with optional pagination
const refreshUsers = useCallback(async (paginationParams?: PaginationParams) => {
if (selectedMandateId && selectedInstanceId) {
if (paginationParams && Object.keys(paginationParams).length > 0) {
_lastTableParams.current = paginationParams;
} else {
paginationParams = _lastTableParams.current;
}
setUsersLoading(true);
try {
// Build query params
const params = new URLSearchParams();
if (paginationParams && Object.keys(paginationParams).length > 0) {
params.append('pagination', JSON.stringify(paginationParams));

View file

@ -12,7 +12,7 @@
* - Actions: Create feature role, edit description, manage AccessRules, delete role
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { AccessRulesEditor } from '../../components/AccessRules';
@ -66,6 +66,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
const [editingRole, setEditingRole] = useState<FeatureRole | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
const _lastTableParams = useRef<any>(undefined);
useEffect(() => {
fetchAttributes(request, 'Role')
@ -96,12 +97,13 @@ export const AdminFeatureRolesPage: React.FC = () => {
const [pagination, setPagination] = useState<any>(null);
// Load roles when feature changes
const fetchRoles = useCallback(async (params?: any) => {
if (!selectedFeatureCode) {
setRoles([]);
return;
}
if (params) _lastTableParams.current = params;
else params = _lastTableParams.current;
setLoading(true);
setError(null);

View file

@ -7,7 +7,7 @@
* Allows creating, viewing, and revoking invitations.
*/
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations';
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
@ -53,6 +53,7 @@ export const AdminInvitationsPage: React.FC = () => {
const [_isSubmitting, setIsSubmitting] = useState(false); // Prefixed with _ to suppress warning
const [copySuccess, setCopySuccess] = useState(false);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
const _lastTableParams = useRef<any>(undefined);
// Load mandates and attributes on mount
useEffect(() => {
@ -69,13 +70,24 @@ export const AdminInvitationsPage: React.FC = () => {
.catch(() => setBackendAttributes([]));
}, [fetchMandates, request]);
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
const _refetchInvitations = useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return;
if (paginationParams && typeof paginationParams === 'object' && !Array.isArray(paginationParams) && typeof paginationParams !== 'string') {
_lastTableParams.current = paginationParams;
} else {
paginationParams = _lastTableParams.current;
}
if (paginationParams) return fetchInvitations(paginationParams, { includeExpired: showExpired, includeUsed: showUsed });
return fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
}, [selectedMandateId, showExpired, showUsed, fetchInvitations]);
useEffect(() => {
if (selectedMandateId) {
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
_lastTableParams.current = undefined;
_refetchInvitations();
fetchRoles(selectedMandateId).then(setRoles);
}
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]);
}, [selectedMandateId, showExpired, showUsed, _refetchInvitations, fetchRoles]);
// Format timestamp (used by URL modal only).
const formatDate = (timestamp: number) => {
@ -165,7 +177,7 @@ export const AdminInvitationsPage: React.FC = () => {
if (result.success && result.data) {
setShowCreateModal(false);
setShowUrlModal(result.data);
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
_refetchInvitations();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Einladung'));
}
@ -276,7 +288,7 @@ export const AdminInvitationsPage: React.FC = () => {
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed })}
onClick={() => _refetchInvitations()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -332,7 +344,7 @@ export const AdminInvitationsPage: React.FC = () => {
]}
hookData={{
handleDelete: handleDeleteInvitation,
refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
refetch: _refetchInvitations,
pagination,
}}
emptyMessage={t('Keine Einladungen gefunden')}

View file

@ -65,6 +65,7 @@ export const AdminMandateRolesPage: React.FC = () => {
// Store current filter state for refetch
const currentScopeFilterRef = useRef(scopeFilter);
currentScopeFilterRef.current = scopeFilter;
const _lastTableParams = useRef<any>(undefined);
// Load mandates and attributes on mount
useEffect(() => {
@ -81,24 +82,25 @@ export const AdminMandateRolesPage: React.FC = () => {
.catch(() => setBackendAttributes([]));
}, [fetchMandates, request]);
// Load roles when mandate or scopeFilter changes
useEffect(() => {
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Refetch wrapper that accepts pagination params from FormGeneratorTable
// and includes the current mandateId and scopeFilter
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
if (!selectedMandateId) return;
// Merge pagination params with current filter state
if (paginationParams && Object.keys(paginationParams).length > 0) {
_lastTableParams.current = paginationParams;
} else {
paginationParams = _lastTableParams.current;
}
return fetchRoles(selectedMandateId, {
...paginationParams,
scopeFilter: currentScopeFilterRef.current
});
}, [selectedMandateId, fetchRoles]);
useEffect(() => {
if (selectedMandateId) {
refetchWithParams();
}
}, [selectedMandateId, scopeFilter, refetchWithParams]);
const getDescriptionText = (desc: any) => {
if (!desc) return '-';
if (typeof desc === 'string') return desc;
@ -183,7 +185,7 @@ export const AdminMandateRolesPage: React.FC = () => {
if (result.success) {
setShowCreateModal(false);
await fetchRoles(selectedMandateId, { scopeFilter });
await refetchWithParams();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle'));
}
@ -212,9 +214,7 @@ export const AdminMandateRolesPage: React.FC = () => {
if (result.success) {
setEditingRole(null);
if (selectedMandateId) {
await fetchRoles(selectedMandateId, { scopeFilter });
}
await refetchWithParams();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rolle'));
}
@ -235,8 +235,7 @@ export const AdminMandateRolesPage: React.FC = () => {
const result = await deleteRole(role.id);
if (result.success) {
// Refetch to update the list
await fetchRoles(selectedMandateId, { scopeFilter });
await refetchWithParams();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Löschen der Rolle'));
}
@ -332,7 +331,7 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles(selectedMandateId, { scopeFilter })}
onClick={() => refetchWithParams()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}

View file

@ -6,7 +6,7 @@
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi';
@ -60,6 +60,12 @@ export const AdminMandatesPage: React.FC = () => {
} = useMandateFormAttributes();
const [showCreateModal, setShowCreateModal] = useState(false);
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (paginationParams?: any) => {
if (paginationParams) _lastTableParams.current = paginationParams;
else paginationParams = _lastTableParams.current;
return refetch(paginationParams);
}, [refetch]);
/** Mandate row merged with billing fields for FormGenerator */
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
@ -192,7 +198,7 @@ export const AdminMandatesPage: React.FC = () => {
<p className={styles.errorMessage}>
{t('Fehler beim Laden der Mandanten')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<button className={styles.secondaryButton} onClick={() => _tableRefetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
@ -229,7 +235,7 @@ export const AdminMandatesPage: React.FC = () => {
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
onClick={() => _tableRefetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -282,7 +288,7 @@ export const AdminMandatesPage: React.FC = () => {
}] : []}
onDelete={handleDeleteMandate}
hookData={{
refetch,
refetch: _tableRefetch,
permissions,
pagination,
handleDelete,

View file

@ -45,6 +45,7 @@ export const AdminUserMandatesPage: React.FC = () => {
// Store current mandateId for refetch
const currentMandateIdRef = useRef<string>('');
const _lastTableParams = useRef<any>(undefined);
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
@ -72,23 +73,23 @@ export const AdminUserMandatesPage: React.FC = () => {
.catch(() => setBackendAttributes([]));
}, [fetchMandates, request]);
// Load users when mandate changes
// Load roles when mandate changes — table auto-reloads via filterScopeKey
useEffect(() => {
if (selectedMandateId) {
currentMandateIdRef.current = selectedMandateId;
fetchMandateUsers(selectedMandateId);
_lastTableParams.current = undefined;
fetchRoles(selectedMandateId).then(setRoles);
}
}, [selectedMandateId, fetchMandateUsers, fetchRoles]);
}, [selectedMandateId, fetchRoles]);
// Refetch wrapper that accepts pagination params from FormGeneratorTable
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
const mandateId = currentMandateIdRef.current;
if (!mandateId) return;
// If pagination params provided, pass them; otherwise just use mandateId
if (paginationParams && Object.keys(paginationParams).length > 0) {
_lastTableParams.current = paginationParams;
return fetchMandateUsers(paginationParams);
}
if (_lastTableParams.current) return fetchMandateUsers(_lastTableParams.current);
return fetchMandateUsers(mandateId);
}, [fetchMandateUsers]);
@ -214,7 +215,7 @@ export const AdminUserMandatesPage: React.FC = () => {
const result = await addUserToMandate(selectedMandateId, data);
if (result.success) {
setShowAddModal(false);
fetchMandateUsers(selectedMandateId);
refetchWithParams();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Hinzufügen des Benutzers'));
}
@ -231,7 +232,7 @@ export const AdminUserMandatesPage: React.FC = () => {
const result = await updateUserRoles(selectedMandateId, editingUser.userId, data.roleIds);
if (result.success) {
setEditingUser(null);
fetchMandateUsers(selectedMandateId);
refetchWithParams();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rollen'));
}
@ -309,7 +310,7 @@ export const AdminUserMandatesPage: React.FC = () => {
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchMandateUsers(selectedMandateId)}
onClick={() => refetchWithParams()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}

View file

@ -6,7 +6,7 @@
* Admin page for managing Users using FormGeneratorTable.
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOrgUsers, useUserOperations } from '../../hooks/useUsers';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
@ -61,6 +61,12 @@ export const AdminUsersPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (paginationParams?: any) => {
if (paginationParams) _lastTableParams.current = paginationParams;
else paginationParams = _lastTableParams.current;
return refetch(paginationParams);
}, [refetch]);
// Generate columns from attributes; types from backend via resolveColumnTypes
const columns = useMemo(() => {
@ -96,7 +102,7 @@ export const AdminUsersPage: React.FC = () => {
const result = await createUser(data as Omit<User, 'id'>);
if (result.success) {
setShowCreateModal(false);
refetch(); // Refresh the list
_tableRefetch();
}
};
@ -106,7 +112,7 @@ export const AdminUsersPage: React.FC = () => {
const result = await updateUser(editingUser.id, data);
if (result.success) {
setEditingUser(null);
refetch(); // Refresh the list
_tableRefetch();
}
};
@ -114,7 +120,7 @@ export const AdminUsersPage: React.FC = () => {
const handleDeleteUser = async (user: User) => {
const success = await deleteUser(user.id);
if (success) {
refetch(); // Refresh the list
_tableRefetch();
}
};
@ -169,7 +175,7 @@ export const AdminUsersPage: React.FC = () => {
<p className={styles.errorMessage}>
{t('Fehler beim Laden der Benutzer')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<button className={styles.secondaryButton} onClick={() => _tableRefetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
@ -207,7 +213,7 @@ export const AdminUsersPage: React.FC = () => {
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
onClick={() => _tableRefetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -257,7 +263,7 @@ export const AdminUsersPage: React.FC = () => {
] : []}
onDelete={handleDeleteUser}
hookData={{
refetch,
refetch: _tableRefetch,
permissions,
pagination,
handleDelete: deleteUser,

View file

@ -126,7 +126,10 @@ export const AdminInvitationWizardPage: React.FC = () => {
// ==========================================================================
useEffect(() => {
fetchMandates().then(setMandates);
fetchMandates().then((data: Mandate[]) => {
data.sort((a, b) => getMandateName(a).localeCompare(getMandateName(b), undefined, { sensitivity: 'base' }));
setMandates(data);
});
}, [fetchMandates]);
useEffect(() => {
@ -144,14 +147,18 @@ export const AdminInvitationWizardPage: React.FC = () => {
if (inviteType === 'mandate') {
fetchRoles(selectedMandate.id).then((r: Role[]) => {
const mandateRoles = r.filter((role: Role) => !role.featureInstanceId);
setRoles(mandateRoles.map((role: Role) => ({ id: role.id, roleLabel: role.roleLabel })));
const mapped = mandateRoles.map((role: Role) => ({ id: role.id, roleLabel: role.roleLabel }));
mapped.sort((a, b) => a.roleLabel.localeCompare(b.roleLabel, undefined, { sensitivity: 'base' }));
setRoles(mapped);
});
fetchMandateUsers(selectedMandate.id).then((users: { userId: string }[]) => {
setExistingAccessUserIds(new Set(users.map(u => u.userId)));
}).catch(() => setExistingAccessUserIds(new Set()));
} else if (inviteType === 'featureInstance' && selectedInstance) {
fetchInstanceRoles(selectedMandate.id, selectedInstance.id).then((r: FeatureInstanceRole[]) => {
setRoles(r.map(role => ({ id: role.id, roleLabel: role.roleLabel })));
const mapped = r.map(role => ({ id: role.id, roleLabel: role.roleLabel }));
mapped.sort((a, b) => a.roleLabel.localeCompare(b.roleLabel, undefined, { sensitivity: 'base' }));
setRoles(mapped);
});
fetchInstanceUsers(selectedMandate.id, selectedInstance.id).then((users: { userId: string }[]) => {
setExistingAccessUserIds(new Set(users.map(u => u.userId)));
@ -160,7 +167,10 @@ export const AdminInvitationWizardPage: React.FC = () => {
setRoles([]);
setExistingAccessUserIds(new Set());
}
fetchAllUsers().then(setAllSystemUsers);
fetchAllUsers().then((data: any[]) => {
data.sort((a: any, b: any) => (a.username || '').localeCompare(b.username || '', undefined, { sensitivity: 'base' }));
setAllSystemUsers(data);
});
}
}, [step, selectedMandate, selectedInstance, inviteType, fetchRoles, fetchInstanceRoles, fetchAllUsers, fetchMandateUsers, fetchInstanceUsers]);

View file

@ -129,6 +129,7 @@ export const AdminMandateWizardPage: React.FC = () => {
const loadMandates = useCallback(async () => {
try {
const data = await fetchMandatesList();
data.sort((a: Mandate, b: Mandate) => getMandateName(a).localeCompare(getMandateName(b), undefined, { sensitivity: 'base' }));
setMandates(data);
} catch {
setError(t('Fehler beim Laden der Mandanten'));
@ -162,13 +163,16 @@ export const AdminMandateWizardPage: React.FC = () => {
const loadAllSystemUsers = useCallback(async () => {
const data = await fetchAllUsers();
data.sort((a: any, b: any) => getUserDisplayName(a).localeCompare(getUserDisplayName(b), undefined, { sensitivity: 'base' }));
setAllSystemUsers(data);
}, [fetchAllUsers]);
const loadMandateRoles = useCallback(async () => {
if (!selectedMandate) return;
const data = await fetchMandateRolesList(selectedMandate.id);
setMandateRoles(data.map((r: Role) => ({ id: r.id, roleLabel: r.roleLabel })));
const mapped = data.map((r: Role) => ({ id: r.id, roleLabel: r.roleLabel }));
mapped.sort((a: RoleOption, b: RoleOption) => a.roleLabel.localeCompare(b.roleLabel, undefined, { sensitivity: 'base' }));
setMandateRoles(mapped);
}, [selectedMandate, fetchMandateRolesList]);
useEffect(() => {
@ -200,7 +204,9 @@ export const AdminMandateWizardPage: React.FC = () => {
const loadInstanceRoles = useCallback(async () => {
if (!selectedInstance || !selectedMandate) return;
const data = await fetchInstanceRolesList(selectedMandate.id, selectedInstance.id);
setInstanceRoles(data.map((r: FeatureInstanceRole) => ({ id: r.id, roleLabel: r.roleLabel })));
const mapped = data.map((r: FeatureInstanceRole) => ({ id: r.id, roleLabel: r.roleLabel }));
mapped.sort((a: RoleOption, b: RoleOption) => a.roleLabel.localeCompare(b.roleLabel, undefined, { sensitivity: 'base' }));
setInstanceRoles(mapped);
}, [selectedInstance, selectedMandate, fetchInstanceRolesList]);
useEffect(() => {

View file

@ -95,17 +95,19 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
fetchInstanceRoles(mandateId, createdInstanceId),
api.get(`/api/mandates/${mandateId}/users`),
]);
setInstanceRoles(Array.isArray(roleList) ? roleList : []);
const roles = Array.isArray(roleList) ? [...roleList] : [];
roles.sort((a: { roleLabel: string }, b: { roleLabel: string }) => a.roleLabel.localeCompare(b.roleLabel, undefined, { sensitivity: 'base' }));
setInstanceRoles(roles);
const data = usersRes.data?.items || usersRes.data || [];
setMandateUsers(
Array.isArray(data)
const users = Array.isArray(data)
? data.map((u: { userId: string; username: string; email?: string }) => ({
id: u.userId,
username: u.username,
email: u.email,
}))
: []
);
: [];
users.sort((a: { username: string }, b: { username: string }) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' }));
setMandateUsers(users);
} catch {
setInstanceRoles([]);
setMandateUsers([]);
@ -183,7 +185,7 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
<p className={wizardStyles.fieldHint}>{t('Keine Mandanten verfügbar')}</p>
) : (
<div className={wizardStyles.cardGrid}>
{mandates.map((m) => {
{[...mandates].sort((a, b) => getMandateName(a).localeCompare(getMandateName(b), undefined, { sensitivity: 'base' })).map((m) => {
const isActive = mandateId === m.id;
return (
<button
@ -209,7 +211,7 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
<p className={wizardStyles.fieldHint}>{t('Keine Features verfügbar')}</p>
) : (
<div className={wizardStyles.cardGrid}>
{features.map((f) => {
{[...features].sort((a, b) => (a.label || a.code).localeCompare(b.label || b.code, undefined, { sensitivity: 'base' })).map((f) => {
const isActive = featureCode === f.code;
return (
<button

View file

@ -7,7 +7,7 @@
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
*/
import React, { useState, useMemo, useEffect, useRef } from 'react';
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
@ -63,7 +63,12 @@ export const ConnectionsPage: React.FC = () => {
const [wizardOpen, setWizardOpen] = useState(false);
const { request } = useApiRequest();
// Banner shown while knowledge bootstrap is running in the background
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (paginationParams?: any) => {
if (paginationParams) _lastTableParams.current = paginationParams;
else paginationParams = _lastTableParams.current;
return refetch(paginationParams);
}, [refetch]);
const [syncBanner, setSyncBanner] = useState<{
connector: string;
startedAt: number;
@ -80,9 +85,8 @@ export const ConnectionsPage: React.FC = () => {
setSyncBanner(null);
};
// Initial fetch
useEffect(() => {
refetch();
_tableRefetch();
}, []);
// Generate columns from attributes - hide internal/redundant fields
@ -151,7 +155,7 @@ export const ConnectionsPage: React.FC = () => {
await handleInlineUpdate(editingConnection.id, updateData, editingConnection);
setEditingConnection(null);
refetch();
_tableRefetch();
} catch (error) {
console.error('Error updating connection:', error);
}
@ -162,7 +166,7 @@ export const ConnectionsPage: React.FC = () => {
setDeletingConnections(prev => new Set(prev).add(connection.id));
try {
await deleteConnection(connection.id);
refetch();
_tableRefetch();
} catch (error) {
console.error('Error deleting connection:', error);
} finally {
@ -178,7 +182,7 @@ export const ConnectionsPage: React.FC = () => {
const handleConnect = async (connection: Connection) => {
try {
await connectWithPopup(connection.id);
refetch();
_tableRefetch();
} catch (error) {
console.error('Error connecting:', error);
}
@ -193,7 +197,7 @@ export const ConnectionsPage: React.FC = () => {
} else if (connection.authority === 'google') {
await refreshGoogleToken(connection.id);
}
refetch();
_tableRefetch();
} catch (error) {
console.error('Error refreshing token:', error);
} finally {
@ -211,7 +215,7 @@ export const ConnectionsPage: React.FC = () => {
setReconnectingConnections(prev => new Set(prev).add(connection.id));
try {
await connectWithPopup(connection.id, true);
refetch();
_tableRefetch();
} catch (error) {
console.error('Error reconnecting:', error);
} finally {
@ -230,7 +234,7 @@ export const ConnectionsPage: React.FC = () => {
) => {
try {
await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences ?? null);
refetch();
_tableRefetch();
if (knowledgeEnabled) {
const LABELS: Record<ConnectorType, string> = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp', infomaniak: 'Infomaniak' };
showSyncBanner(LABELS[type] ?? type);
@ -244,7 +248,7 @@ export const ConnectionsPage: React.FC = () => {
try {
const newConn = await createInfomaniakConnection();
await submitInfomaniakToken(newConn.id, token);
refetch();
_tableRefetch();
if (knowledgeEnabled) showSyncBanner('Infomaniak');
} catch (error) {
console.error('Error creating Infomaniak connection:', error);
@ -266,7 +270,7 @@ export const ConnectionsPage: React.FC = () => {
setTogglingConsent(prev => new Set(prev).add(connection.id));
try {
await patchKnowledgeConsent(request, connection.id, newEnabled);
await refetch();
await _tableRefetch();
} catch (error) {
console.error('Error toggling knowledge consent:', error);
} finally {
@ -298,7 +302,7 @@ export const ConnectionsPage: React.FC = () => {
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{t('Fehler beim Laden der Verbindungen: {detail}', { detail: String(error) })}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<button className={styles.secondaryButton} onClick={() => _tableRefetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
@ -324,7 +328,7 @@ export const ConnectionsPage: React.FC = () => {
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
onClick={() => _tableRefetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -444,7 +448,7 @@ export const ConnectionsPage: React.FC = () => {
]}
onDelete={handleDelete}
hookData={{
refetch,
refetch: _tableRefetch,
permissions,
pagination,
groupLayout,

View file

@ -100,7 +100,10 @@ export const FilesPage: React.FC = () => {
const [treeVisible, setTreeVisible] = useState(true);
const [tableVisible, setTableVisible] = useState(true);
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (params?: any) => {
if (params) _lastTableParams.current = params;
else params = _lastTableParams.current;
const nextParams = { ...(params || {}) };
const nextFilters = { ...(nextParams.filters || {}) };
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
@ -172,7 +175,7 @@ export const FilesPage: React.FC = () => {
);
const _refreshAll = useCallback(async () => {
await _tableRefetch({ page: 1, pageSize: 25 });
await _tableRefetch();
setTreeKey(k => k + 1);
}, [_tableRefetch]);

View file

@ -7,7 +7,7 @@
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
*/
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { usePrompts, usePromptOperations } from '../../hooks/usePrompts';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
@ -58,16 +58,15 @@ export const PromptsPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPrompt, setEditingPrompt] = useState<Prompt | null>(null);
// ── Table refetch wrapper (stable signature used by FormGeneratorTable) ──
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (params?: any) => {
if (params) _lastTableParams.current = params;
else params = _lastTableParams.current;
await refetch(params);
}, [refetch]);
// ── Refresh-All for the header "Aktualisieren" button ────────────────────
// Forces a paginated request so the cache key matches what the table uses
// internally. This guarantees fresh (non-cached) data is pulled in.
const _refreshAll = useCallback(async () => {
await _tableRefetch({ page: 1, pageSize: 25 });
await _tableRefetch();
}, [_tableRefetch]);
// Initial fetch

View file

@ -1,6 +1,6 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
@ -32,6 +32,12 @@ const AdminSubscriptionsPage: React.FC = () => {
const { confirm, ConfirmDialog } = useConfirm();
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (paginationParams?: any) => {
if (paginationParams) _lastTableParams.current = paginationParams;
else paginationParams = _lastTableParams.current;
return refetch(paginationParams);
}, [refetch]);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<EnterpriseDialogMode>('create');
@ -106,8 +112,8 @@ const AdminSubscriptionsPage: React.FC = () => {
} else if (mode === 'update') {
await updateEnterprise(request, values as any);
}
await refetch();
}, [request, refetch]);
await _tableRefetch();
}, [request, _tableRefetch]);
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 },
@ -153,11 +159,11 @@ const AdminSubscriptionsPage: React.FC = () => {
try {
await api.post('/api/subscription/force-cancel', { subscriptionId: row.id });
await refetch();
await _tableRefetch();
} catch (err) {
console.error('Force cancel failed:', err);
}
}, [confirm, refetch, t]);
}, [confirm, _tableRefetch, t]);
const _isEnterprise = (row: any) => row.isEnterprise || row.planKey === 'ENTERPRISE';
@ -215,7 +221,8 @@ const AdminSubscriptionsPage: React.FC = () => {
pagination={true}
pageSize={50}
selectable={false}
hookData={{ refetch, pagination }}
initialSort={[{ key: 'startedAt', direction: 'desc' }]}
hookData={{ refetch: _tableRefetch, pagination }}
customActions={customActions}
emptyMessage={t('Keine Abonnements vorhanden')}
/>

View file

@ -757,6 +757,7 @@ export const BillingDataView: React.FC = () => {
filterable={true}
sortable={true}
selectable={false}
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
emptyMessage={t('Keine Transaktionen vorhanden')}
hookData={transactionsHookData}
/>

View file

@ -8,7 +8,7 @@
* 2. Gespraechspartner Persona CRUD with FormGeneratorTable (apiEndpoint-driven)
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
@ -102,9 +102,12 @@ export const CommcoachSettingsView: React.FC = () => {
const [personaError, setPersonaError] = useState<string | null>(null);
const personaApiEndpoint = instanceId ? `/api/commcoach/${instanceId}/personas` : undefined;
const _lastPersonaParams = useRef<any>(undefined);
const _refetchPersonas = useCallback(async (params?: any) => {
if (!instanceId) return;
if (params) _lastPersonaParams.current = params;
else params = _lastPersonaParams.current || { page: 1, pageSize: 50 };
setPersonasLoading(true);
try {
const data = await fetchPersonasPaginated(request, instanceId, params);
@ -125,7 +128,7 @@ export const CommcoachSettingsView: React.FC = () => {
useEffect(() => {
if (activeTab === 'personas' && instanceId) {
_refetchPersonas({ page: 1, pageSize: 50 });
_refetchPersonas();
}
}, [activeTab, instanceId, _refetchPersonas]);
@ -159,7 +162,7 @@ export const CommcoachSettingsView: React.FC = () => {
});
setPersonaForm({ label: '', description: '', gender: '' });
setShowCreatePersona(false);
await _refetchPersonas({ page: 1, pageSize: 50 });
await _refetchPersonas();
} catch (err: any) {
setPersonaError(err.message || 'Fehler beim Erstellen');
} finally {
@ -179,7 +182,7 @@ export const CommcoachSettingsView: React.FC = () => {
});
setEditingPersona(null);
setPersonaForm({ label: '', description: '', gender: '' });
await _refetchPersonas({ page: 1, pageSize: 50 });
await _refetchPersonas();
} catch (err: any) {
setPersonaError(err.message || 'Fehler beim Speichern');
} finally {

View file

@ -16,7 +16,7 @@
* Embedded NO own StackLayout. Regions: Panel `toolbar` + Panel `table`.
*/
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useTrusteeDocuments, useTrusteeDocumentOperations, TrusteeDocument } from '../../../hooks/useTrustee';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
@ -58,12 +58,20 @@ export const TrusteeDocumentsView: React.FC = () => {
const [editingDocument, setEditingDocument] = useState<TrusteeDocument | null>(null);
const [isCreateMode, setIsCreateMode] = useState(false);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (paginationParams?: any) => {
if (paginationParams) _lastTableParams.current = paginationParams;
else paginationParams = _lastTableParams.current;
return refetch(paginationParams);
}, [refetch]);
useEffect(() => {
if (instanceId) {
refetch();
_lastTableParams.current = undefined;
_tableRefetch();
}
}, [instanceId, refetch]);
}, [instanceId, _tableRefetch]);
const documentColumnOrder = ['sysCreatedAt'];
@ -113,13 +121,13 @@ export const TrusteeDocumentsView: React.FC = () => {
const result = await handleCreate(data);
if (result.success) {
setIsCreateMode(false);
refetch();
_tableRefetch();
}
} else if (editingDocument) {
const result = await handleUpdate(editingDocument.id, data);
if (result.success) {
setEditingDocument(null);
refetch();
_tableRefetch();
}
}
};
@ -128,7 +136,7 @@ export const TrusteeDocumentsView: React.FC = () => {
removeOptimistically(doc.id);
const success = await handleDelete(doc.id);
if (!success) {
refetch();
_tableRefetch();
}
};
@ -173,7 +181,7 @@ export const TrusteeDocumentsView: React.FC = () => {
updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result.success) {
refetch();
_tableRefetch();
}
};
@ -189,7 +197,7 @@ export const TrusteeDocumentsView: React.FC = () => {
<p className={styles.errorMessage}>
{t('Fehler beim Laden der Dokumente: {detail}', { detail: String(error) })}
</p>
<button type="button" className={styles.secondaryButton} onClick={() => refetch()}>
<button type="button" className={styles.secondaryButton} onClick={() => _tableRefetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
@ -204,7 +212,7 @@ export const TrusteeDocumentsView: React.FC = () => {
<button
type="button"
className={styles.secondaryButton}
onClick={() => refetch()}
onClick={() => _tableRefetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -258,7 +266,7 @@ export const TrusteeDocumentsView: React.FC = () => {
]}
onDelete={handleDeleteDoc}
hookData={{
refetch,
refetch: _tableRefetch,
permissions,
pagination,
handleDelete,

View file

@ -16,7 +16,7 @@
* Embedded NO own StackLayout. Regions: Panel `toolbar` + Panel `table`.
*/
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
@ -63,12 +63,20 @@ export const TrusteePositionsView: React.FC = () => {
const [editingPosition, setEditingPosition] = useState<TrusteePosition | null>(null);
const [isCreateMode, setIsCreateMode] = useState(false);
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (paginationParams?: any) => {
if (paginationParams) _lastTableParams.current = paginationParams;
else paginationParams = _lastTableParams.current;
return refetch(paginationParams);
}, [refetch]);
useEffect(() => {
if (instanceId) {
refetch();
_lastTableParams.current = undefined;
_tableRefetch();
}
}, [instanceId, refetch]);
}, [instanceId, _tableRefetch]);
const handleBatchSyncToAccounting = useCallback(
async (rows: TrusteePosition[]) => {
@ -91,7 +99,7 @@ export const TrusteePositionsView: React.FC = () => {
firstError?.errorMessage || t('{count} Fehler.', { count: res.errors }),
);
}
refetch();
_tableRefetch();
} catch (err: any) {
showError(
t('Sync fehlgeschlagen'),
@ -105,7 +113,7 @@ export const TrusteePositionsView: React.FC = () => {
});
}
},
[instanceId, request, refetch, showSuccess, showError, t],
[instanceId, request, _tableRefetch, showSuccess, showError, t],
);
const handleSingleSyncToAccounting = useCallback(
@ -290,13 +298,13 @@ export const TrusteePositionsView: React.FC = () => {
const result = await handleCreate(processedData);
if (result.success) {
setIsCreateMode(false);
refetch();
_tableRefetch();
}
} else if (editingPosition) {
const result = await handleUpdate(editingPosition.id, processedData);
if (result.success) {
setEditingPosition(null);
refetch();
_tableRefetch();
}
}
};
@ -305,7 +313,7 @@ export const TrusteePositionsView: React.FC = () => {
removeOptimistically(pos.id);
const success = await handleDelete(pos.id);
if (!success) {
refetch();
_tableRefetch();
}
};
@ -323,7 +331,7 @@ export const TrusteePositionsView: React.FC = () => {
updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result.success) {
refetch();
_tableRefetch();
}
};
@ -339,7 +347,7 @@ export const TrusteePositionsView: React.FC = () => {
<p className={styles.errorMessage}>
{t('Fehler beim Laden der Positionen: {detail}', { detail: String(error) })}
</p>
<button type="button" className={styles.secondaryButton} onClick={() => refetch()}>
<button type="button" className={styles.secondaryButton} onClick={() => _tableRefetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
@ -354,7 +362,7 @@ export const TrusteePositionsView: React.FC = () => {
<button
type="button"
className={styles.secondaryButton}
onClick={() => refetch()}
onClick={() => _tableRefetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -416,7 +424,7 @@ export const TrusteePositionsView: React.FC = () => {
]}
onDelete={handleDeletePos}
hookData={{
refetch,
refetch: _tableRefetch,
permissions,
pagination,
handleDelete,

View file

@ -10,7 +10,7 @@
* Embedded in WorkflowAutomationHubPage via TemplatesTab (embedded=true).
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { FaCopy, FaSync, FaShareAlt, FaPen } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
@ -89,6 +89,7 @@ export const WorkflowTemplatesPage: React.FC<WorkflowTemplatesPageProps> = ({
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
const _lastTableParams = useRef<any>(undefined);
useEffect(() => {
fetchAttributes(request, 'AutoWorkflowView')
@ -100,6 +101,8 @@ export const WorkflowTemplatesPage: React.FC<WorkflowTemplatesPageProps> = ({
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
if (paginationParams) _lastTableParams.current = paginationParams;
else paginationParams = _lastTableParams.current;
setLoading(true);
try {
const scope = activeScope === 'all' ? undefined : activeScope;

View file

@ -278,9 +278,8 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
useEffect(() => {
if (_sttPrefsLoaded.current) return;
_sttPrefsLoaded.current = true;
fetch('/api/voice/preferences', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); })
api.get('/api/voice/preferences')
.then(res => { if (res.data?.sttLanguage) setVoiceLanguage(res.data.sttLanguage); })
.catch(() => {});
}, []);
@ -872,7 +871,7 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
onClick={() => {
setVoiceLanguage(lang.bcp47);
setShowLangPicker(false);
fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.bcp47 }) }).catch(() => {});
api.put('/api/voice/preferences', { sttLanguage: lang.bcp47 }).catch(() => {});
}}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,