panel ui
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s

This commit is contained in:
ValueOn AG 2026-06-11 16:43:53 +02:00
parent e29c99a849
commit d579df1c92
189 changed files with 10068 additions and 12558 deletions

View file

@ -75,7 +75,6 @@ Die folgende Tabelle ist die **Checkliste pro Modul**. Pro Zeile: **was** verkau
| `trustee` | Treuhand | | | ☐ ja ☐ nein | | | | `trustee` | Treuhand | | | ☐ ja ☐ nein | | |
| `realestate` | Immobilien | | | ☐ ja ☐ nein | | | | `realestate` | Immobilien | | | ☐ ja ☐ nein | | |
| `chatbot` | Chatbot | | | ☐ ja ☐ nein | | | | `chatbot` | Chatbot | | | ☐ ja ☐ nein | | |
| `chatworkflow` | Workflow | | | ☐ ja ☐ nein | | |
| `automation` | Automatisierung | | | ☐ ja ☐ nein | | | | `automation` | Automatisierung | | | ☐ ja ☐ nein | | |
| `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | | | `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | |
| `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | | | `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | |
@ -144,12 +143,6 @@ Viele **Views** sind Kandidaten für „Basic / Pro“ oder Add-ons (technisch:
- [ ] `dashboard` — … - [ ] `dashboard` — …
- [ ] `instance-roles` (adminOnly) — … - [ ] `instance-roles` (adminOnly) — …
### `chatworkflow`
- [ ] `dashboard` — …
- [ ] `runs` — …
- [ ] `files` — …
**Paket-Entscheid (freies Feld):** **Paket-Entscheid (freies Feld):**
| Paketname | Enthaltene `featureCode`s | Enthaltene Views / Ausnahmen | Limits (Instanzen, Nutzer, Speicher, Credits) | | Paketname | Enthaltene `featureCode`s | Enthaltene Views / Ausnahmen | Limits (Instanzen, Nutzer, Speicher, Credits) |

View file

@ -43,7 +43,7 @@ Transparenz: Verbrauch lässt sich nach **Feature**, **Instanz**, **Provider/Mod
| Treuhand (`trustee`) | Dokumente, Positionen, Import/Scan, Buchhaltung | | Treuhand (`trustee`) | Dokumente, Positionen, Import/Scan, Buchhaltung |
| Immobilien (`realestate`) | Karte / Mandantenfähigkeit | | Immobilien (`realestate`) | Karte / Mandantenfähigkeit |
| Chatbot (`chatbot`) | Konversationen, Konfiguration | | Chatbot (`chatbot`) | Konversationen, Konfiguration |
| Workflow (`chatworkflow`) | Überblicke, Runs, Dateien | | Workflow-Automation (Systemkomponente) | Workflows, Editor, Durchläufe |
| Automatisierung (`automation`) | Definitionen, Vorlagen, Logs | | Automatisierung (`automation`) | Definitionen, Vorlagen, Logs |
| Teams Bot (`teamsbot`) | Dashboard, Sessions, Settings | | Teams Bot (`teamsbot`) | Dashboard, Sessions, Settings |
| Neutralisierung (`neutralization`) | Playground, Config, Attribute | | Neutralisierung (`neutralization`) | Playground, Config, Attribute |

View file

@ -56,18 +56,6 @@ const MOCK_CUSTOMER_PERMISSIONS: InstancePermissions = {
}, },
}; };
const MOCK_WORKFLOW_PERMISSIONS: InstancePermissions = {
tables: {
WorkflowRun: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' },
WorkflowFile: { view: true, read: 'g', create: 'g', update: 'm', delete: 'm' },
},
views: {
'chatworkflow-dashboard': true,
'chatworkflow-runs': true,
'chatworkflow-files': true,
},
};
const MOCK_RESPONSE: FeaturesMyResponse = { const MOCK_RESPONSE: FeaturesMyResponse = {
mandates: [ mandates: [
{ {
@ -101,22 +89,6 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
}, },
], ],
}, },
{
code: 'chatworkflow',
label: 'Workflow',
icon: 'play_circle',
instances: [
{
id: 'inst-soha-workflow',
featureCode: 'chatworkflow',
mandateId: 'mand-soha',
mandateName: 'Soha Treuhand',
instanceLabel: 'Beratung Dynamic',
userRoles: ['user'],
permissions: MOCK_WORKFLOW_PERMISSIONS,
},
],
},
], ],
}, },
{ {
@ -193,7 +165,6 @@ export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
if (USE_MOCK) { if (USE_MOCK) {
return [ return [
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] }, { code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
]; ];
} }

View file

@ -5,6 +5,7 @@
*/ */
import React, { useState, useRef, useEffect, useMemo } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { FloatingPortal } from '../../UiComponents/FloatingPortal';
import { import {
FaPlay, FaPlay,
FaSpinner, FaSpinner,
@ -146,13 +147,13 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
const badge = statusBadge[currentStatus] || statusBadge.draft; const badge = statusBadge[currentStatus] || statusBadge.draft;
const [newMenuOpen, setNewMenuOpen] = useState(false); const [newMenuOpen, setNewMenuOpen] = useState(false);
const newMenuRef = useRef<HTMLDivElement>(null); const newMenuAnchorRef = useRef<HTMLDivElement>(null);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false); const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null); const templateMenuAnchorRef = useRef<HTMLDivElement>(null);
const [zoomMenuOpen, setZoomMenuOpen] = useState(false); const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const zoomMenuRef = useRef<HTMLDivElement>(null); const zoomMenuAnchorRef = useRef<HTMLButtonElement>(null);
const [zoomInputDraft, setZoomInputDraft] = useState(''); const [zoomInputDraft, setZoomInputDraft] = useState('');
useEffect(() => { useEffect(() => {
@ -160,16 +161,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
if (zp !== undefined) setZoomInputDraft(String(zp)); if (zp !== undefined) setZoomInputDraft(String(zp));
}, [canvasEdit?.zoomPercent]); }, [canvasEdit?.zoomPercent]);
useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
};
document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside);
}, []);
const scopeLabels = useMemo( const scopeLabels = useMemo(
() => () =>
({ ({
@ -237,7 +228,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')} aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
/> />
)} )}
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}> <div className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}> <div className={styles.canvasHeaderSplitPair}>
<Button <Button
type="button" type="button"
@ -250,34 +241,43 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
aria-label={t('Neuer leerer Workflow')} aria-label={t('Neuer leerer Workflow')}
/> />
{onNewFromTemplate && ( {onNewFromTemplate && (
<Button <div ref={newMenuAnchorRef}>
type="button" <Button
variant={_tb} type="button"
size={_ts} variant={_tb}
icon={FaCaretDown} size={_ts}
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`} icon={FaCaretDown}
onClick={() => setNewMenuOpen((p) => !p)} className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
title={t('Aus Vorlage…')} onClick={() => setNewMenuOpen((p) => !p)}
aria-label={t('Neu aus Vorlage')} title={t('Aus Vorlage…')}
aria-haspopup="menu" aria-label={t('Neu aus Vorlage')}
aria-expanded={newMenuOpen} aria-haspopup="menu"
/> aria-expanded={newMenuOpen}
/>
</div>
)} )}
</div> </div>
{newMenuOpen && onNewFromTemplate && ( {onNewFromTemplate && (
<div className={styles.canvasHeaderMenuDropdown} role="menu"> <FloatingPortal
<button open={newMenuOpen}
type="button" anchorRef={newMenuAnchorRef}
className={styles.canvasHeaderMenuItem} onClose={() => setNewMenuOpen(false)}
onClick={() => { placement="bottom"
onNewFromTemplate(); >
setNewMenuOpen(false); <div className={styles.canvasHeaderMenuDropdown} role="menu">
}} <button
role="menuitem" type="button"
> className={styles.canvasHeaderMenuItem}
{t('Aus Vorlage…')} onClick={() => {
</button> onNewFromTemplate();
</div> setNewMenuOpen(false);
}}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
</div>
</FloatingPortal>
)} )}
</div> </div>
<select <select
@ -329,7 +329,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
title={_runTitle} title={_runTitle}
/> />
{currentWorkflowId && onSaveAsTemplate && ( {currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}> <div ref={templateMenuAnchorRef} className={styles.canvasHeaderNewSplit}>
<Button <Button
type="button" type="button"
variant={_tb} variant={_tb}
@ -344,7 +344,12 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
> >
{t('Als Vorlage')} {t('Als Vorlage')}
</Button> </Button>
{templateMenuOpen && ( <FloatingPortal
open={templateMenuOpen}
anchorRef={templateMenuAnchorRef}
onClose={() => setTemplateMenuOpen(false)}
placement="bottom"
>
<div className={styles.canvasHeaderMenuDropdown} role="menu"> <div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => ( {(['user', 'instance', 'mandate'] as const).map((s) => (
<button <button
@ -361,7 +366,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
</button> </button>
))} ))}
</div> </div>
)} </FloatingPortal>
</div> </div>
)} )}
@ -387,7 +392,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
role="toolbar" role="toolbar"
aria-label={t('Canvas bearbeiten')} aria-label={t('Canvas bearbeiten')}
> >
<div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}> <div className={styles.canvasHeaderZoomCombo}>
<div className={styles.canvasHeaderZoomInputWrap}> <div className={styles.canvasHeaderZoomInputWrap}>
<input <input
type="text" type="text"
@ -410,6 +415,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
</span> </span>
</div> </div>
<button <button
ref={zoomMenuAnchorRef}
type="button" type="button"
className={styles.canvasHeaderZoomChevronBtn} className={styles.canvasHeaderZoomChevronBtn}
onClick={() => setZoomMenuOpen((p) => !p)} onClick={() => setZoomMenuOpen((p) => !p)}
@ -420,7 +426,13 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
> >
<FaCaretDown aria-hidden /> <FaCaretDown aria-hidden />
</button> </button>
{zoomMenuOpen && ( <FloatingPortal
open={zoomMenuOpen}
anchorRef={zoomMenuAnchorRef}
onClose={() => setZoomMenuOpen(false)}
placement="bottom"
align="end"
>
<div className={styles.canvasHeaderMenuDropdown} role="menu"> <div className={styles.canvasHeaderMenuDropdown} role="menu">
<button <button
type="button" type="button"
@ -459,7 +471,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
</button> </button>
))} ))}
</div> </div>
)} </FloatingPortal>
</div> </div>
<button <button
type="button" type="button"

View file

@ -604,16 +604,11 @@
} }
.canvasHeaderMenuDropdown { .canvasHeaderMenuDropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
min-width: 11rem; min-width: 11rem;
margin-top: 0.25rem;
} }
.canvasHeaderMenuItem { .canvasHeaderMenuItem {

View file

@ -328,17 +328,12 @@
/* Filter dropdown */ /* Filter dropdown */
.filterDropdown { .filterDropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 200px; min-width: 200px;
max-width: 320px; max-width: 320px;
background: var(--color-bg); background: var(--color-bg);
border: 1px solid var(--color-border, #e2e8f0); border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
z-index: 1000;
margin-top: 4px;
} }
.filterDropdownHeader { .filterDropdownHeader {

View file

@ -56,7 +56,7 @@
* *
* See useOrgUsers / AdminUsersPage for a full reference implementation. * See useOrgUsers / AdminUsersPage for a full reference implementation.
*/ */
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useCallback } from 'react'; import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import type { IconType } from 'react-icons'; import type { IconType } from 'react-icons';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorTable.module.css'; import styles from './FormGeneratorTable.module.css';
@ -73,6 +73,7 @@ import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
import { FormGeneratorControls } from '../FormGeneratorControls'; import { FormGeneratorControls } from '../FormGeneratorControls';
import { FilterSearchInput } from '../FilterSearchInput'; import { FilterSearchInput } from '../FilterSearchInput';
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue'; import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
import { FloatingPortal } from '../../UiComponents/FloatingPortal';
import { import {
isDateTimeType, isDateTimeType,
isCheckboxType, isCheckboxType,
@ -93,6 +94,12 @@ import {
type TableListViewRow, type TableListViewRow,
type TableViewConfig, type TableViewConfig,
} from '../../../api/tableViewApi'; } from '../../../api/tableViewApi';
import { useVisibilityRemeasure } from '../../../hooks/useVisibilityRemeasure';
import {
buildTableFilterStorageKey,
loadTableFilterState,
saveTableFilterState,
} from '../../../utils/tableFilterPersistence';
function groupLevelsFromViewConfig(raw: unknown): GroupByLevelSpec[] { function groupLevelsFromViewConfig(raw: unknown): GroupByLevelSpec[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
@ -335,6 +342,11 @@ export interface FormGeneratorTableProps<T = any> {
* and sends `viewKey` in list pagination. Examples: `files/list`, `connections`, `prompts`. * and sends `viewKey` in list pagination. Examples: `files/list`, `connections`, `prompts`.
*/ */
tableContextKey?: string; tableContextKey?: string;
/**
* Mandate/instance scope for filter+search localStorage persistence (L10).
* Fail-closed: when unset, filters are not persisted across sessions.
*/
filterScopeKey?: string;
/** /**
* `sections`: one level of grouping one paginated table per group below the category header * `sections`: one level of grouping one paginated table per group below the category header
* (requires hookData.fetchGroupSectionSummaries + refetchForSection). * (requires hookData.fetchGroupSectionSummaries + refetchForSection).
@ -708,6 +720,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onRowDragStart, onRowDragStart,
compact = false, compact = false,
tableContextKey, tableContextKey,
filterScopeKey,
tableGroupLayoutMode = 'inline', tableGroupLayoutMode = 'inline',
localDataMode = false, localDataMode = false,
viewKeyForQueries, viewKeyForQueries,
@ -933,6 +946,34 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Multi-column sorting: array of sort configs in order of priority // Multi-column sorting: array of sort configs in order of priority
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>(initialSort ?? []); const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>(initialSort ?? []);
const [filters, setFilters] = useState<Record<string, any>>(initialFilters || {}); const [filters, setFilters] = useState<Record<string, any>>(initialFilters || {});
const filterPersistKey = useMemo(() => {
if (!filterScopeKey) return null;
const tableId = tableContextKey ?? apiEndpoint;
if (!tableId) return null;
return buildTableFilterStorageKey(filterScopeKey, tableId);
}, [filterScopeKey, tableContextKey, apiEndpoint]);
const filterPersistLoadedRef = useRef(false);
useEffect(() => {
if (!filterPersistKey || filterPersistLoadedRef.current) return;
const stored = loadTableFilterState(filterPersistKey);
if (stored) {
setFilters((prev) => ({ ...stored.filters, ...prev, ...(initialFilters || {}) }));
if (stored.searchTerm) {
setSearchTerm(stored.searchTerm);
}
}
filterPersistLoadedRef.current = true;
}, [filterPersistKey, initialFilters]);
useEffect(() => {
if (!filterPersistKey || !filterPersistLoadedRef.current) return;
const timer = setTimeout(() => {
saveTableFilterState(filterPersistKey, { filters, searchTerm });
}, 300);
return () => clearTimeout(timer);
}, [filterPersistKey, filters, searchTerm]);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({}); const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
// Actions column width - resizable, default based on number of buttons // Actions column width - resizable, default based on number of buttons
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null); const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null);
@ -944,7 +985,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize); const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null); const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
const filterDropdownRef = useRef<HTMLDivElement>(null); const filterAnchorRef = useRef<HTMLButtonElement>(null);
const reloadViews = useCallback(async () => { const reloadViews = useCallback(async () => {
if (!tableContextKey) return; if (!tableContextKey) return;
@ -1151,41 +1192,6 @@ export function FormGeneratorTable<T extends Record<string, any>>({
[detectedColumns], [detectedColumns],
); );
useLayoutEffect(() => {
if (!openFilterColumn) return;
const dd = filterDropdownRef.current;
if (!dd) return;
const positionDropdown = () => {
const th = dd.closest('th');
if (!th) return;
const r = th.getBoundingClientRect();
const margin = 8;
const maxW = 320;
const w = Math.min(Math.max(dd.offsetWidth || maxW, 200), maxW, window.innerWidth - 2 * margin);
let left = r.left;
if (left + w > window.innerWidth - margin) {
left = window.innerWidth - margin - w;
}
if (left < margin) left = margin;
const approxH = dd.offsetHeight || 280;
let top = r.bottom + 4;
if (top + approxH > window.innerHeight - margin) {
top = Math.max(margin, r.top - 4 - approxH);
}
dd.style.position = 'fixed';
dd.style.left = `${left}px`;
dd.style.top = `${top}px`;
dd.style.right = 'auto';
dd.style.bottom = 'auto';
dd.style.width = `${w}px`;
dd.style.maxWidth = `${maxW}px`;
dd.style.zIndex = '2000';
};
positionDropdown();
const id = requestAnimationFrame(() => positionDropdown());
return () => cancelAnimationFrame(id);
}, [openFilterColumn]);
// Expanded groups for client-side groupBy rendering // Expanded groups for client-side groupBy rendering
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupsInitialized, setGroupsInitialized] = useState(false); const [groupsInitialized, setGroupsInitialized] = useState(false);
@ -1416,24 +1422,27 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Check if actions column exceeds 20% of container width (enable wrapping) // Check if actions column exceeds 20% of container width (enable wrapping)
const shouldWrapActionButtons = containerWidth > 0 && currentActionsWidth > containerWidth * 0.20; const shouldWrapActionButtons = containerWidth > 0 && currentActionsWidth > containerWidth * 0.20;
const _updateContainerWidth = useCallback(() => {
const container = tableContainerRef.current;
if (container) {
setContainerWidth(container.clientWidth);
}
}, []);
// Track container width changes // Track container width changes
useEffect(() => { useEffect(() => {
const container = tableContainerRef.current; const container = tableContainerRef.current;
if (!container) return; if (!container) return;
const updateContainerWidth = () => { _updateContainerWidth();
setContainerWidth(container.clientWidth);
};
// Initial measurement const resizeObserver = new ResizeObserver(_updateContainerWidth);
updateContainerWidth();
// Observe resize
const resizeObserver = new ResizeObserver(updateContainerWidth);
resizeObserver.observe(container); resizeObserver.observe(container);
return () => resizeObserver.disconnect(); return () => resizeObserver.disconnect();
}, []); }, [_updateContainerWidth]);
useVisibilityRemeasure(tableContainerRef, _updateContainerWidth);
const resizingColumn = useRef<string | null>(null); const resizingColumn = useRef<string | null>(null);
const startX = useRef<number>(0); const startX = useRef<number>(0);
const startWidth = useRef<number>(0); const startWidth = useRef<number>(0);
@ -1771,20 +1780,6 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return []; return [];
}, [detectedColumns, asyncFilterValues, apiEndpoint, hookData, data, supportsBackendPagination]); }, [detectedColumns, asyncFilterValues, apiEndpoint, hookData, data, supportsBackendPagination]);
// Close filter dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target as Node)) {
setOpenFilterColumn(null);
}
};
if (openFilterColumn) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [openFilterColumn]);
// Toggle filter dropdown // Toggle filter dropdown
const toggleFilterDropdown = useCallback((columnKey: string, event: React.MouseEvent) => { const toggleFilterDropdown = useCallback((columnKey: string, event: React.MouseEvent) => {
event.stopPropagation(); // Prevent sort from triggering event.stopPropagation(); // Prevent sort from triggering
@ -2933,6 +2928,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{/* Filter icon */} {/* Filter icon */}
{filterable && column.filterable !== false && ( {filterable && column.filterable !== false && (
<button <button
ref={openFilterColumn === column.key ? filterAnchorRef : undefined}
className={`${styles.filterIcon} ${column.key in filters ? styles.filterActive : ''}`} className={`${styles.filterIcon} ${column.key in filters ? styles.filterActive : ''}`}
onClick={(e) => toggleFilterDropdown(column.key, e)} onClick={(e) => toggleFilterDropdown(column.key, e)}
title={column.key in filters title={column.key in filters
@ -2980,8 +2976,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{/* Filter dropdown */} {/* Filter dropdown */}
{openFilterColumn === column.key && ( {openFilterColumn === column.key && (
<FloatingPortal
open
anchorRef={filterAnchorRef}
onClose={() => setOpenFilterColumn(null)}
placement="auto"
>
<div <div
ref={filterDropdownRef}
className={styles.filterDropdown} className={styles.filterDropdown}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@ -3152,6 +3153,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
})()} })()}
</div> </div>
</div> </div>
</FloatingPortal>
)} )}
{resizable && ( {resizable && (

View file

@ -543,6 +543,14 @@
line-height: 1.5; line-height: 1.5;
} }
/* Collapsed section: take only header height instead of claiming flex space
from sibling trees in the same panel. */
.collapsedRoot {
flex: 0 0 auto;
height: auto;
max-height: none;
}
/* Embedded workflow / compact pickers — fixed height so flex children (treeWrapper) get a real viewport */ /* Embedded workflow / compact pickers — fixed height so flex children (treeWrapper) get a real viewport */
.embeddedPicker { .embeddedPicker {
display: flex; display: flex;

View file

@ -1206,6 +1206,7 @@ export function FormGeneratorTree<T = any>({
styles.formGeneratorTree, styles.formGeneratorTree,
compact && styles.compactMode, compact && styles.compactMode,
embedMaxHeight != null && styles.embeddedPicker, embedMaxHeight != null && styles.embeddedPicker,
collapsible && sectionCollapsed && styles.collapsedRoot,
className, className,
] ]
.filter(Boolean) .filter(Boolean)

View file

@ -42,10 +42,6 @@
} }
.popover { .popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 4200;
min-width: min(360px, calc(100vw - 24px)); min-width: min(360px, calc(100vw - 24px));
padding: 14px 14px 12px; padding: 14px 14px 12px;
border-radius: 12px; border-radius: 12px;

View file

@ -5,6 +5,7 @@ import { FaLayerGroup, FaTrash } from 'react-icons/fa';
import { TbLayoutList, TbLayoutRows } from 'react-icons/tb'; import { TbLayoutList, TbLayoutRows } from 'react-icons/tb';
import { FiChevronsDown, FiChevronsUp } from 'react-icons/fi'; import { FiChevronsDown, FiChevronsUp } from 'react-icons/fi';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { FloatingPortal } from '../../UiComponents/FloatingPortal';
import styles from './TableViewsBar.module.css'; import styles from './TableViewsBar.module.css';
export interface TableViewOption { export interface TableViewOption {
@ -93,26 +94,18 @@ export function TableViewsBar({
}: TableViewsBarProps) { }: TableViewsBarProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [groupMenuOpen, setGroupMenuOpen] = useState(false); const [groupMenuOpen, setGroupMenuOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null); const groupTriggerRef = useRef<HTMLButtonElement>(null);
const [saveOpen, setSaveOpen] = useState(false); const [saveOpen, setSaveOpen] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (!groupMenuOpen) return; if (!groupMenuOpen) return;
const onDoc = (e: MouseEvent) => {
const el = wrapRef.current;
if (el && !el.contains(e.target as Node)) setGroupMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setGroupMenuOpen(false); if (e.key === 'Escape') setGroupMenuOpen(false);
}; };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => { return () => document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [groupMenuOpen]); }, [groupMenuOpen]);
const levelsForUi = useMemo( const levelsForUi = useMemo(
@ -196,8 +189,9 @@ export function TableViewsBar({
return ( return (
<div className={styles.toolbar}> <div className={styles.toolbar}>
<div ref={wrapRef} className={styles.popoverAnchor}> <div className={styles.popoverAnchor}>
<button <button
ref={groupTriggerRef}
type="button" type="button"
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`} className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
onClick={() => setGroupMenuOpen((o) => !o)} onClick={() => setGroupMenuOpen((o) => !o)}
@ -207,7 +201,12 @@ export function TableViewsBar({
> >
<FaLayerGroup className={styles.groupIcon} aria-hidden /> <FaLayerGroup className={styles.groupIcon} aria-hidden />
</button> </button>
{groupMenuOpen && ( <FloatingPortal
open={groupMenuOpen}
anchorRef={groupTriggerRef}
onClose={() => setGroupMenuOpen(false)}
placement="bottom"
>
<div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}> <div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}>
<div className={styles.popoverTitle}>{t('Gruppieren nach')}</div> <div className={styles.popoverTitle}>{t('Gruppieren nach')}</div>
<p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p> <p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p>
@ -256,7 +255,7 @@ export function TableViewsBar({
{t('+ Weitere Ebene')} {t('+ Weitere Ebene')}
</button> </button>
</div> </div>
)} </FloatingPortal>
</div> </div>
<span className={styles.activeSummary} title={summary}> <span className={styles.activeSummary} title={summary}>

View file

@ -219,6 +219,26 @@
padding-top: 0.75rem; padding-top: 0.75rem;
} }
/* Only in fill mode: tab content stretches to the bounded panel height. */
.container:not(.containerNatural) .panel > * {
flex: 1;
min-height: 0;
}
/* ---------- Natural-height mode (fill=false) ----------
For use inside a scrolling page (StackLayout variant="scroll"): the tabs and
their content keep their natural height so the page scroll container handles
overflow instead of compressing the regions. */
.containerNatural,
.containerNatural .panel {
flex: 0 0 auto;
min-height: 0;
}
.containerNatural .panel {
overflow: visible;
}
/* ---------- Dark theme ---------- */ /* ---------- Dark theme ---------- */
:global(.dark-theme) .tabBar { :global(.dark-theme) .tabBar {

View file

@ -72,6 +72,7 @@ export function LayoutTabs({
collapsible = false, collapsible = false,
collapseKey, collapseKey,
defaultCollapsed = false, defaultCollapsed = false,
fill = true,
}: LayoutTabsProps) { }: LayoutTabsProps) {
const shouldSyncUrl = syncUrl ?? !!urlParam; const shouldSyncUrl = syncUrl ?? !!urlParam;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -222,7 +223,7 @@ export function LayoutTabs({
) : null; ) : null;
return ( return (
<div className={[styles.container, className].filter(Boolean).join(' ')}> <div className={[styles.container, !fill && styles.containerNatural, className].filter(Boolean).join(' ')}>
<div <div
className={[ className={[
styles.tabBarRow, styles.tabBarRow,
@ -302,12 +303,14 @@ export function LayoutTabs({
key={item.id} key={item.id}
style={ style={
item.id === activeId item.id === activeId
? { ? fill
display: 'flex', ? {
flexDirection: 'column' as const, display: 'flex',
flex: 1, flexDirection: 'column' as const,
minHeight: 0, flex: 1,
} minHeight: 0,
}
: { display: 'block' }
: { display: 'none' } : { display: 'none' }
} }
> >

View file

@ -4,7 +4,7 @@
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
border-radius: 8px; border-radius: 8px;
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
overflow: hidden; overflow: clip;
} }
/* --- Variant: table — fills available height, bounded scroll --- */ /* --- Variant: table — fills available height, bounded scroll --- */
@ -43,12 +43,30 @@
display: none; display: none;
} }
/* --- Generic fill — any variant can grow to fill a bounded region --- */
.panel[data-fill="true"] {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel[data-fill="true"] .body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* --- Variant: editor — full height, no body padding --- */ /* --- Variant: editor — full height, no body padding --- */
.panel[data-variant="editor"] { .panel[data-variant="editor"] {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: visible;
} }
.panel[data-variant="editor"] .body { .panel[data-variant="editor"] .body {
@ -57,6 +75,7 @@
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: visible;
} }
/* --- Variant: wizard — step container --- */ /* --- Variant: wizard — step container --- */

View file

@ -29,6 +29,7 @@ export const Panel: FC<PanelProps> = ({
defaultCollapsed = false, defaultCollapsed = false,
collapseKey, collapseKey,
className = '', className = '',
fill = false,
children, children,
}) => { }) => {
const [collapsed, setCollapsed] = useState(() => _loadCollapsed(collapseKey, defaultCollapsed)); const [collapsed, setCollapsed] = useState(() => _loadCollapsed(collapseKey, defaultCollapsed));
@ -47,6 +48,7 @@ export const Panel: FC<PanelProps> = ({
<div <div
className={`${styles.panel} ${collapsed ? styles.panelCollapsed : ''} ${className}`} className={`${styles.panel} ${collapsed ? styles.panelCollapsed : ''} ${className}`}
data-variant={variant} data-variant={variant}
data-fill={fill ? 'true' : undefined}
> >
{hasHeader && ( {hasHeader && (
<div <div

View file

@ -0,0 +1,98 @@
.root {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.root[data-direction="vertical"] {
flex-direction: column;
}
.pane {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.root[data-direction="horizontal"] .pane:not(.paneCollapsed) {
flex-direction: row;
}
.paneCollapsed {
flex: 0 0 auto !important;
overflow: hidden;
flex-direction: column;
}
.paneBody {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
/* Generic region-fill: a pane hosts a single region object, which should
always grow to fill the pane (height + width) so tables/trees/etc. are
usable instead of clipped. */
.paneBody > * {
flex: 1 1 0;
min-height: 0;
min-width: 0;
}
.paneBodyHidden {
display: none;
}
.collapseToggle {
flex-shrink: 0;
align-self: stretch;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
margin: 0;
padding: 0;
border: none;
border-right: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
border-radius: 0;
background: var(--bg-secondary, #f5f5f5);
color: var(--text-secondary, #666);
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.paneCollapsed .collapseToggle {
border-right: none;
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
}
.collapseToggle:hover {
background: var(--bg-hover, #ebebeb);
}
.divider {
flex-shrink: 0;
background: var(--border-color, rgba(0, 0, 0, 0.12));
z-index: 2;
}
.dividerHorizontal {
width: 4px;
cursor: col-resize;
}
.dividerVertical {
height: 4px;
cursor: row-resize;
}
.dividerDragging {
background: var(--primary-color, #2563eb);
}

View file

@ -0,0 +1,298 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* PanelLayout config-driven horizontal/vertical split layout (MVP).
*
* Supports 2+ resizable panes with optional collapse and localStorage persistence.
* Nested split trees can be composed by nesting PanelLayout instances in pane content.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type FC,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { FaChevronLeft, FaChevronRight, FaChevronUp, FaChevronDown } from 'react-icons/fa';
import type { PanelLayoutPaneConfig, PanelLayoutProps } from './types';
import { useVisibilityRemeasure } from '../../hooks/useVisibilityRemeasure';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './PanelLayout.module.css';
const STORAGE_PREFIX = 'po_panel_layout:';
function _loadCollapsed(key: string | undefined, fallback: boolean): boolean {
if (!key) return fallback;
try {
const stored = localStorage.getItem(`panel-collapse:${key}`);
if (stored !== null) return stored === '1';
} catch { /* noop */ }
return fallback;
}
function _saveCollapsed(key: string | undefined, value: boolean): void {
if (!key) return;
try {
localStorage.setItem(`panel-collapse:${key}`, value ? '1' : '0');
} catch { /* noop */ }
}
function _normalizeSizes(sizes: number[]): number[] {
const total = sizes.reduce((sum, s) => sum + s, 0);
if (total <= 0) return sizes.map(() => 100 / sizes.length);
return sizes.map((s) => (s / total) * 100);
}
function _loadSizes(persistenceKey: string, panes: PanelLayoutPaneConfig[]): number[] {
const defaults = panes.map((p) => p.defaultSize ?? 100 / panes.length);
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${persistenceKey}`);
if (!raw) return _normalizeSizes(defaults);
const parsed = JSON.parse(raw) as number[];
if (!Array.isArray(parsed) || parsed.length !== panes.length) {
return _normalizeSizes(defaults);
}
return _normalizeSizes(parsed);
} catch {
return _normalizeSizes(defaults);
}
}
function _saveSizes(persistenceKey: string, sizes: number[]): void {
try {
localStorage.setItem(`${STORAGE_PREFIX}${persistenceKey}`, JSON.stringify(sizes));
} catch { /* noop */ }
}
function _clampPaneSize(
pane: PanelLayoutPaneConfig,
size: number,
): number {
const min = pane.minSize ?? 10;
const max = pane.maxSize ?? 80;
return Math.max(min, Math.min(max, size));
}
export const PanelLayout: FC<PanelLayoutProps> = ({
persistenceKey,
direction = 'horizontal',
panes,
className = '',
}) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null);
const [sizes, setSizes] = useState<number[]>(() => _loadSizes(persistenceKey, panes));
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
for (const pane of panes) {
initial[pane.id] = _loadCollapsed(pane.collapseKey, pane.defaultCollapsed ?? false);
}
return initial;
});
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const dragRef = useRef<{ index: number; startPos: number; startSizes: number[]; containerSize: number } | null>(null);
useEffect(() => {
setSizes(_loadSizes(persistenceKey, panes));
}, [persistenceKey, panes.length]);
useEffect(() => {
if (draggingIndex === null) {
_saveSizes(persistenceKey, sizes);
}
}, [sizes, persistenceKey, draggingIndex]);
const _toggleCollapsed = useCallback((pane: PanelLayoutPaneConfig) => {
setCollapsedById((prev) => {
const next = !prev[pane.id];
_saveCollapsed(pane.collapseKey, next);
return { ...prev, [pane.id]: next };
});
}, []);
const _handleDividerMouseDown = useCallback((index: number, e: ReactMouseEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const containerSize = direction === 'horizontal' ? rect.width : rect.height;
const startPos = direction === 'horizontal' ? e.clientX : e.clientY;
dragRef.current = { index, startPos, startSizes: [...sizes], containerSize };
setDraggingIndex(index);
}, [direction, sizes]);
useEffect(() => {
if (draggingIndex === null) return;
const _onMouseMove = (e: MouseEvent) => {
const drag = dragRef.current;
if (!drag) return;
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
const deltaPercent = ((currentPos - drag.startPos) / drag.containerSize) * 100;
const next = [...drag.startSizes];
const leftPane = panes[drag.index];
const rightPane = panes[drag.index + 1];
let leftSize = next[drag.index] + deltaPercent;
let rightSize = next[drag.index + 1] - deltaPercent;
leftSize = _clampPaneSize(leftPane, leftSize);
rightSize = _clampPaneSize(rightPane, rightSize);
const pairTotal = drag.startSizes[drag.index] + drag.startSizes[drag.index + 1];
const adjustedTotal = leftSize + rightSize;
if (Math.abs(adjustedTotal - pairTotal) > 0.01) {
const scale = pairTotal / adjustedTotal;
leftSize *= scale;
rightSize *= scale;
}
next[drag.index] = leftSize;
next[drag.index + 1] = rightSize;
setSizes(_normalizeSizes(next));
};
const _onMouseUp = () => {
dragRef.current = null;
setDraggingIndex(null);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', _onMouseMove);
document.addEventListener('mouseup', _onMouseUp);
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', _onMouseMove);
document.removeEventListener('mouseup', _onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [draggingIndex, direction, panes]);
const _remeasure = useCallback(() => {
containerRef.current?.dispatchEvent(new Event('panel-layout-remeasure'));
}, []);
useVisibilityRemeasure(containerRef, _remeasure);
const paneStyle = useCallback((pane: PanelLayoutPaneConfig, index: number): CSSProperties => {
const collapsed = collapsedById[pane.id] && pane.collapsible;
if (collapsed) {
const collapsedPx = pane.collapsedSize ?? 40;
return direction === 'horizontal'
? { flex: `0 0 ${collapsedPx}px`, width: collapsedPx }
: { flex: `0 0 ${collapsedPx}px`, height: collapsedPx };
}
const percent = sizes[index] ?? 100 / panes.length;
return { flex: `${percent} 1 0`, minWidth: 0, minHeight: 0 };
}, [collapsedById, direction, panes.length, sizes]);
const dividerClass = useMemo(
() => `${styles.divider} ${direction === 'horizontal' ? styles.dividerHorizontal : styles.dividerVertical}`,
[direction],
);
if (panes.length < 2) {
throw new Error('PanelLayout requires at least 2 panes');
}
return (
<div
ref={containerRef}
className={`${styles.root} ${className}`}
data-direction={direction}
role="group"
>
{panes.map((pane, index) => (
<PaneSlot
key={pane.id}
pane={pane}
style={paneStyle(pane, index)}
collapsed={!!collapsedById[pane.id] && !!pane.collapsible}
onToggleCollapse={() => _toggleCollapsed(pane)}
collapseLabel={t('Panel einklappen')}
expandLabel={t('Panel ausklappen')}
direction={direction}
showDivider={index < panes.length - 1}
dividerClass={`${dividerClass} ${draggingIndex === index ? styles.dividerDragging : ''}`}
onDividerMouseDown={(e) => _handleDividerMouseDown(index, e)}
/>
))}
</div>
);
};
interface PaneSlotProps {
pane: PanelLayoutPaneConfig;
style: CSSProperties;
collapsed: boolean;
onToggleCollapse: () => void;
collapseLabel: string;
expandLabel: string;
direction: 'horizontal' | 'vertical';
showDivider: boolean;
dividerClass: string;
onDividerMouseDown: (e: ReactMouseEvent) => void;
}
const PaneSlot: FC<PaneSlotProps> = ({
pane,
style,
collapsed,
onToggleCollapse,
collapseLabel,
expandLabel,
direction,
showDivider,
dividerClass,
onDividerMouseDown,
}) => {
const _collapseIcon = direction === 'horizontal'
? (collapsed ? <FaChevronRight aria-hidden /> : <FaChevronLeft aria-hidden />)
: (collapsed ? <FaChevronDown aria-hidden /> : <FaChevronUp aria-hidden />);
return (
<>
<div
className={`${styles.pane} ${collapsed ? styles.paneCollapsed : ''}`}
style={style}
data-pane-id={pane.id}
>
{pane.collapsible && (
<button
type="button"
className={styles.collapseToggle}
onClick={onToggleCollapse}
aria-expanded={!collapsed}
aria-label={collapsed ? expandLabel : collapseLabel}
>
{_collapseIcon}
</button>
)}
<div className={`${styles.paneBody} ${collapsed ? styles.paneBodyHidden : ''}`}>
{pane.content}
</div>
</div>
{showDivider && (
<div
className={dividerClass}
role="separator"
aria-orientation="vertical"
onMouseDown={onDividerMouseDown}
/>
)}
</>
);
};
export default PanelLayout;

View file

@ -51,11 +51,25 @@
padding: 16px 20px; padding: 16px 20px;
} }
/* Scroll/form layouts: regions keep their natural height and the body scrolls,
instead of flex-shrinking children below their content (which clips data). */
.bodyScroll > *,
.bodyForm > * {
flex-shrink: 0;
}
.bodyDashboard { .bodyDashboard {
overflow-y: auto; flex: 0 0 auto;
overflow: visible;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
padding-bottom: 24px;
}
/* Dashboard root keeps its bounded height but scrolls its own content */
.root[data-variant="dashboard"] {
overflow-y: auto;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View file

@ -1,8 +1,9 @@
// Copyright (c) 2026 PowerOn AG // Copyright (c) 2026 PowerOn AG
// All rights reserved. // All rights reserved.
import React, { type FC, type ReactNode, Children, isValidElement, cloneElement } from 'react'; import React, { type FC, type ReactNode, Children, isValidElement, cloneElement, useRef } from 'react';
import type { StackLayoutProps, StackLayoutVariant } from './types'; import type { StackLayoutProps, StackLayoutVariant } from './types';
import { useScrollMode } from '../../hooks/useScrollMode'; import { useScrollMode } from '../../hooks/useScrollMode';
import { useScrollRestoration } from '../../hooks/useScrollRestoration';
import styles from './StackLayout.module.css'; import styles from './StackLayout.module.css';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -63,9 +64,12 @@ const _StackLayoutRoot: FC<StackLayoutProps> = ({
children, children,
}) => { }) => {
const scrollMode = useScrollMode(); const scrollMode = useScrollMode();
const rootRef = useRef<HTMLDivElement>(null);
useScrollRestoration(rootRef);
return ( return (
<div <div
ref={rootRef}
className={`${styles.root} ${className}`} className={`${styles.root} ${className}`}
data-scroll-mode={scrollMode} data-scroll-mode={scrollMode}
data-variant={variant} data-variant={variant}

View file

@ -1,36 +1,74 @@
// Copyright (c) 2026 PowerOn AG // Copyright (c) 2026 PowerOn AG
// All rights reserved. // All rights reserved.
import React, { type ReactElement } from 'react'; import React, { type ReactElement, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { ViewMode, ViewStackProps, ViewProps } from './types'; import type { ViewMode, ViewStackProps, ViewProps } from './types';
import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './ViewStack.module.css'; import styles from './ViewStack.module.css';
const VALID_VIEW_MODES: ViewMode[] = ['list', 'catalog', 'detail'];
interface ViewResolution {
activeView: ViewMode;
sanitized: boolean;
}
function _collectChildIds(children: React.ReactNode): ViewMode[] {
const ids: ViewMode[] = [];
React.Children.forEach(children, (child) => {
if (!React.isValidElement<ViewProps>(child)) return;
ids.push(child.props.id);
});
return ids;
}
function _resolveActiveView( function _resolveActiveView(
searchParams: URLSearchParams, searchParams: URLSearchParams,
viewParam: string, viewParam: string,
entityParam: string | undefined, entityParam: string | undefined,
defaultView: ViewMode defaultView: ViewMode,
): ViewMode { registeredViews: ViewMode[],
): ViewResolution {
const rawView = searchParams.get(viewParam) as ViewMode | null; const rawView = searchParams.get(viewParam) as ViewMode | null;
const entityId = entityParam ? searchParams.get(entityParam) : null; const entityId = entityParam ? searchParams.get(entityParam) : null;
let resolved: ViewMode = rawView ?? defaultView; let sanitized = false;
if (entityId && resolved === 'list') { if (rawView && !VALID_VIEW_MODES.includes(rawView)) {
sanitized = true;
} else if (rawView && !registeredViews.includes(rawView)) {
sanitized = true;
}
let resolved: ViewMode = rawView && registeredViews.includes(rawView) ? rawView : defaultView;
if (entityId && resolved === 'list' && registeredViews.includes('detail')) {
resolved = 'detail'; resolved = 'detail';
} }
if (resolved === 'detail' && !entityId) { if (resolved === 'detail') {
if (!registeredViews.includes('detail')) {
sanitized = true;
resolved = defaultView;
} else if (entityParam && !entityId) {
sanitized = true;
resolved = defaultView;
}
}
if (entityId && !registeredViews.includes('detail')) {
sanitized = true;
resolved = defaultView; resolved = defaultView;
} }
return resolved; return { activeView: resolved, sanitized };
} }
function _findActiveChild( function _findActiveChild(
children: React.ReactNode, children: React.ReactNode,
activeView: ViewMode activeView: ViewMode,
): ReactElement<ViewProps> | null { ): ReactElement<ViewProps> | null {
let match: ReactElement<ViewProps> | null = null; let match: ReactElement<ViewProps> | null = null;
@ -48,7 +86,7 @@ function _buildBackParams(
searchParams: URLSearchParams, searchParams: URLSearchParams,
viewParam: string, viewParam: string,
entityParam: string | undefined, entityParam: string | undefined,
defaultView: ViewMode defaultView: ViewMode,
): URLSearchParams { ): URLSearchParams {
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
@ -65,6 +103,23 @@ function _buildBackParams(
return next; return next;
} }
function _buildSanitizedParams(
searchParams: URLSearchParams,
viewParam: string,
entityParam: string | undefined,
defaultView: ViewMode,
): URLSearchParams {
const next = new URLSearchParams(searchParams);
next.delete(viewParam);
if (entityParam) {
next.delete(entityParam);
}
if (defaultView !== 'list') {
next.set(viewParam, defaultView);
}
return next;
}
function View({ children }: ViewProps) { function View({ children }: ViewProps) {
return <>{children}</>; return <>{children}</>;
} }
@ -76,8 +131,33 @@ function ViewStack({
children, children,
}: ViewStackProps) { }: ViewStackProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { showWarning } = useToast();
const { t } = useLanguage();
const toastShownRef = useRef(false);
const registeredViews = useMemo(() => _collectChildIds(children), [children]);
const { activeView, sanitized } = useMemo(
() => _resolveActiveView(searchParams, viewParam, entityParam, defaultView, registeredViews),
[searchParams, viewParam, entityParam, defaultView, registeredViews],
);
useEffect(() => {
if (!sanitized || toastShownRef.current) return;
toastShownRef.current = true;
showWarning(t('Ungültige Ansicht'), t('Die angeforderte Ansicht ist nicht verfügbar.'));
setSearchParams(
_buildSanitizedParams(searchParams, viewParam, entityParam, defaultView),
{ replace: true },
);
}, [sanitized, showWarning, t, setSearchParams, searchParams, viewParam, entityParam, defaultView]);
useEffect(() => {
if (!sanitized) {
toastShownRef.current = false;
}
}, [sanitized]);
const activeView = _resolveActiveView(searchParams, viewParam, entityParam, defaultView);
const activeChild = _findActiveChild(children, activeView); const activeChild = _findActiveChild(children, activeView);
if (!activeChild) return null; if (!activeChild) return null;

View file

@ -25,3 +25,4 @@ export { default as ViewStack } from './ViewStack';
export { LayoutTabs } from './LayoutTabs'; export { LayoutTabs } from './LayoutTabs';
export { Panel } from './Panel'; export { Panel } from './Panel';
export { StackLayout } from './StackLayout'; export { StackLayout } from './StackLayout';
export { PanelLayout } from './PanelLayout';

View file

@ -41,6 +41,14 @@ export interface LayoutTabsProps {
collapseKey?: string; collapseKey?: string;
/** Start collapsed when no persisted state exists. */ /** Start collapsed when no persisted state exists. */
defaultCollapsed?: boolean; defaultCollapsed?: boolean;
/**
* Fill the available height (default `true`): the active tab panel becomes a
* bounded flex column so a `table`/`editor` Panel inside it can scroll
* internally. Set `false` inside a `StackLayout variant="scroll"` page so the
* tab content keeps its natural height and the page scrolls instead of
* compressing the regions.
*/
fill?: boolean;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -80,6 +88,12 @@ export interface PanelProps {
defaultCollapsed?: boolean; defaultCollapsed?: boolean;
collapseKey?: string; collapseKey?: string;
className?: string; className?: string;
/**
* Fill the available height of the parent flex container and let the body
* own its scroll. Use when a `card` (or any non-table/editor) Panel is placed
* in a bounded region (split pane, StackLayout body) and should grow to fill.
*/
fill?: boolean;
children: ReactNode; children: ReactNode;
} }
@ -104,3 +118,30 @@ export interface LayoutPersistenceAdapter {
load: <T>(key: string) => T | null; load: <T>(key: string) => T | null;
save: <T>(key: string, value: T) => void; save: <T>(key: string, value: T) => void;
} }
// ---------------------------------------------------------------------------
// PanelLayout (split tree MVP)
// ---------------------------------------------------------------------------
export type PanelLayoutDirection = 'horizontal' | 'vertical';
export interface PanelLayoutPaneConfig {
id: string;
content: ReactNode;
/** Default share in percent (all panes normalized to 100). */
defaultSize?: number;
minSize?: number;
maxSize?: number;
collapsible?: boolean;
collapseKey?: string;
defaultCollapsed?: boolean;
/** Collapsed strip size in px. Default: 40 */
collapsedSize?: number;
}
export interface PanelLayoutProps {
persistenceKey: string;
direction?: PanelLayoutDirection;
panes: PanelLayoutPaneConfig[];
className?: string;
}

View file

@ -39,6 +39,7 @@ import { usePrompt } from '../../hooks/usePrompt';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import api from '../../api'; import api from '../../api';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { useSidebar } from '../../layouts/SidebarContext';
import styles from './MandateNavigation.module.css'; import styles from './MandateNavigation.module.css';
type NavTranslateFn = (key: string, params?: Record<string, string | number>) => string; type NavTranslateFn = (key: string, params?: Record<string, string | number>) => string;
@ -210,6 +211,7 @@ const EmptyState: React.FC = () => {
export const MandateNavigation: React.FC = () => { export const MandateNavigation: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { collapsed } = useSidebar();
const { blocks, loading, refresh } = useNavigation(); const { blocks, loading, refresh } = useNavigation();
const { prompt, PromptDialog } = usePrompt(); const { prompt, PromptDialog } = usePrompt();
const { showWarning } = useToast(); const { showWarning } = useToast();
@ -332,6 +334,7 @@ export const MandateNavigation: React.FC = () => {
<TreeNavigation <TreeNavigation
items={navigationItems} items={navigationItems}
autoExpandActive={true} autoExpandActive={true}
collapsed={collapsed}
/> />
) : ( ) : (
<EmptyState /> <EmptyState />

View file

@ -345,3 +345,82 @@
background: var(--primary-color, #2563eb); background: var(--primary-color, #2563eb);
color: white; color: white;
} }
/* ============================================ */
/* COLLAPSED ICON RAIL */
/* ============================================ */
.treeNavigationCollapsed {
padding: 0.25rem 0.375rem;
gap: 0.25rem;
align-items: center;
}
.collapsedNavItem {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 8px;
color: var(--text-secondary, #64748b);
text-decoration: none;
transition: background 0.2s ease, color 0.2s ease;
}
.collapsedNavItem:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
color: var(--text-primary, #1a1a1a);
}
.collapsedNavItemActive {
background: var(--primary-light, #e0e7ff);
color: var(--primary-color, #2563eb);
}
.collapsedNavIcon {
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
.collapsedNavLetter {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
background: var(--surface-color, #f0f0f0);
font-size: 0.75rem;
font-weight: 600;
}
.collapsedNavItemActive .collapsedNavLetter {
background: var(--primary-color, #2563eb);
color: var(--text-on-primary, #ffffff);
}
:global(.dark-theme) .collapsedNavItem {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .collapsedNavItem:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
color: var(--text-primary-dark, #fff);
}
:global(.dark-theme) .collapsedNavItemActive {
background: var(--primary-dark-bg, #1e3a5f);
color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .collapsedNavLetter {
background: var(--surface-dark, #2a2a2a);
}
:global(.dark-theme) .collapsedNavItemActive .collapsedNavLetter {
background: var(--primary-color, #2563eb);
color: var(--text-on-primary, #ffffff);
}

View file

@ -76,6 +76,8 @@ export interface TreeNavigationProps {
items: TreeItem[]; items: TreeItem[];
/** Whether to auto-expand nodes when their path is active */ /** Whether to auto-expand nodes when their path is active */
autoExpandActive?: boolean; autoExpandActive?: boolean;
/** Icon-only rail mode for collapsed sidebar */
collapsed?: boolean;
/** Callback when a node is clicked */ /** Callback when a node is clicked */
onNodeClick?: (node: TreeNodeItem) => void; onNodeClick?: (node: TreeNodeItem) => void;
/** Maximum depth to render (0 = unlimited) */ /** Maximum depth to render (0 = unlimited) */
@ -122,6 +124,34 @@ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem {
return 'type' in item && item.type === 'separator'; return 'type' in item && item.type === 'separator';
} }
function _collectNavLinksFromNodes(nodes: TreeNodeItem[], result: TreeNodeItem[]): void {
for (const node of nodes) {
if (node.path) {
result.push(node);
}
if (node.children) {
_collectNavLinksFromNodes(node.children, result);
}
}
}
function _collectNavLinks(items: TreeItem[]): TreeNodeItem[] {
const result: TreeNodeItem[] = [];
for (const item of items) {
if (isTreeSeparator(item)) {
continue;
}
if (isTreeSection(item)) {
_collectNavLinksFromNodes(item.children, result);
continue;
}
if (isTreeNode(item)) {
_collectNavLinksFromNodes([item], result);
}
}
return result;
}
// ============================================================================= // =============================================================================
// TREE NODE COMPONENT // TREE NODE COMPONENT
// ============================================================================= // =============================================================================
@ -344,6 +374,45 @@ const TreeSection: React.FC<TreeSectionProps> = ({
); );
}; };
// =============================================================================
// COLLAPSED ICON RAIL
// =============================================================================
interface CollapsedNavItemProps {
node: TreeNodeItem;
currentPath: string;
onNodeClick?: (node: TreeNodeItem) => void;
}
const CollapsedNavItem: React.FC<CollapsedNavItemProps> = ({ node, currentPath, onNodeClick }) => {
const isActive = node.path
? currentPath === node.path || currentPath.startsWith(`${node.path}/`)
: false;
const letterFallback = node.label.trim().charAt(0).toLocaleUpperCase() || '?';
const handleClick = () => {
if (onNodeClick) {
onNodeClick(node);
}
};
return (
<NavLink
to={node.path!}
className={`${styles.collapsedNavItem} ${isActive ? styles.collapsedNavItemActive : ''}`}
title={node.label}
onClick={handleClick}
data-id={node.dataId}
>
{node.icon ? (
<span className={styles.collapsedNavIcon}>{node.icon}</span>
) : (
<span className={styles.collapsedNavLetter}>{letterFallback}</span>
)}
</NavLink>
);
};
// ============================================================================= // =============================================================================
// MAIN COMPONENT // MAIN COMPONENT
// ============================================================================= // =============================================================================
@ -351,6 +420,7 @@ const TreeSection: React.FC<TreeSectionProps> = ({
export const TreeNavigation: React.FC<TreeNavigationProps> = ({ export const TreeNavigation: React.FC<TreeNavigationProps> = ({
items, items,
autoExpandActive = true, autoExpandActive = true,
collapsed = false,
onNodeClick, onNodeClick,
maxDepth = 0, maxDepth = 0,
className = '', className = '',
@ -358,6 +428,22 @@ export const TreeNavigation: React.FC<TreeNavigationProps> = ({
const location = useLocation(); const location = useLocation();
const currentPath = location.pathname; const currentPath = location.pathname;
if (collapsed) {
const navLinks = _collectNavLinks(items);
return (
<nav className={`${styles.treeNavigation} ${styles.treeNavigationCollapsed} ${className}`}>
{navLinks.map((node, index) => (
<CollapsedNavItem
key={node.id || `collapsed-${index}`}
node={node}
currentPath={currentPath}
onNodeClick={onNodeClick}
/>
))}
</nav>
);
}
return ( return (
<nav className={`${styles.treeNavigation} ${className}`}> <nav className={`${styles.treeNavigation} ${className}`}>
{items.map((item, index) => { {items.map((item, index) => {

View file

@ -10,6 +10,14 @@
padding: 0.5rem; padding: 0.5rem;
padding-bottom: max(0.5rem, env(safe-area-inset-bottom)); padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
border-top: 1px solid var(--border-color, #e0e0e0); border-top: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.userSectionCollapsed {
flex-direction: column;
justify-content: center;
padding: 0.5rem 0.25rem;
gap: 0;
} }
/* Notification Bell */ /* Notification Bell */
@ -36,6 +44,13 @@
background: var(--hover-bg, rgba(0, 0, 0, 0.05)); background: var(--hover-bg, rgba(0, 0, 0, 0.05));
} }
.userButtonCollapsed {
flex: 0 0 auto;
justify-content: center;
padding: 0.375rem;
width: 100%;
}
.avatar { .avatar {
flex-shrink: 0; flex-shrink: 0;
width: 36px; width: 36px;
@ -82,23 +97,20 @@
/* Menu */ /* Menu */
.menu { .menu {
position: absolute;
bottom: 100%;
left: 0.5rem;
right: 0.5rem;
margin-bottom: 0.25rem;
padding: 0.25rem; padding: 0.25rem;
background: var(--bg-primary, #ffffff); background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 100; min-width: 12rem;
}
.menuCollapsed {
min-width: 12rem;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.menu { .menu {
left: 0.25rem;
right: 0.25rem;
max-height: min(60dvh, 420px); max-height: min(60dvh, 420px);
overflow-y: auto; overflow-y: auto;
} }

View file

@ -6,17 +6,20 @@
* Zeigt Benutzerinformationen und Logout-Button in der Sidebar. * Zeigt Benutzerinformationen und Logout-Button in der Sidebar.
*/ */
import React, { useState } from 'react'; import React, { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useCurrentUser } from '../../hooks/useUsers'; import { useCurrentUser } from '../../hooks/useUsers';
import { NotificationBell } from '../NotificationBell'; import { NotificationBell } from '../NotificationBell';
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant'; import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
import { FloatingPortal } from '../UiComponents/FloatingPortal';
import styles from './UserSection.module.css'; import styles from './UserSection.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { useSidebar } from '../../layouts/SidebarContext';
export const UserSection: React.FC = () => { export const UserSection: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { collapsed } = useSidebar();
const { user, logout } = useCurrentUser(); const { user, logout } = useCurrentUser();
const navigate = useNavigate(); const navigate = useNavigate();
@ -24,6 +27,7 @@ export const UserSection: React.FC = () => {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [showLegalModal, setShowLegalModal] = useState(false); const [showLegalModal, setShowLegalModal] = useState(false);
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden()); const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
const userButtonRef = useRef<HTMLButtonElement>(null);
const handleLogout = async () => { const handleLogout = async () => {
setIsLoggingOut(true); setIsLoggingOut(true);
@ -70,29 +74,42 @@ export const UserSection: React.FC = () => {
})(); })();
return ( return (
<div className={styles.userSection}> <div className={`${styles.userSection} ${collapsed ? styles.userSectionCollapsed : ''}`}>
{/* Notification Bell */} {!collapsed && (
<NotificationBell className={styles.notificationBell} /> <NotificationBell className={styles.notificationBell} />
)}
<button <button
className={styles.userButton} ref={userButtonRef}
className={`${styles.userButton} ${collapsed ? styles.userButtonCollapsed : ''}`}
onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }} onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
aria-expanded={showMenu} aria-expanded={showMenu}
title={collapsed ? (user.fullName || user.username) : undefined}
> >
<div className={styles.avatar}> <div className={styles.avatar}>
{initials} {initials}
</div> </div>
<div className={styles.userInfo}> {!collapsed && (
<span className={styles.userName}>{user.fullName || user.username}</span> <>
<span className={styles.userEmail}>{user.email}</span> <div className={styles.userInfo}>
</div> <span className={styles.userName}>{user.fullName || user.username}</span>
<span className={styles.chevron}> <span className={styles.userEmail}>{user.email}</span>
{showMenu ? '▲' : '▼'} </div>
</span> <span className={styles.chevron}>
{showMenu ? '▲' : '▼'}
</span>
</>
)}
</button> </button>
{showMenu && ( <FloatingPortal
<div className={styles.menu}> open={showMenu}
anchorRef={userButtonRef}
onClose={() => setShowMenu(false)}
placement="top"
align="start"
>
<div className={`${styles.menu} ${collapsed ? styles.menuCollapsed : ''}`}>
<button <button
className={styles.menuItem} className={styles.menuItem}
onClick={handleBilling} onClick={handleBilling}
@ -138,7 +155,7 @@ export const UserSection: React.FC = () => {
{isLoggingOut ? t('Abmelden...') : t('Abmelden')} {isLoggingOut ? t('Abmelden...') : t('Abmelden')}
</button> </button>
</div> </div>
)} </FloatingPortal>
{/* Legal Modal */} {/* Legal Modal */}
{showLegalModal && ( {showLegalModal && (

View file

@ -52,16 +52,13 @@
/* Dropdown */ /* Dropdown */
.dropdown { .dropdown {
position: fixed;
bottom: 80px;
left: 290px;
width: 360px; width: 360px;
max-height: 480px; max-width: min(360px, calc(100vw - 16px));
max-height: min(480px, 70vh);
background: var(--card-bg, white); background: var(--card-bg, white);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
overflow: hidden; overflow: hidden;
z-index: 9999;
animation: slideIn 0.2s ease; animation: slideIn 0.2s ease;
} }
@ -367,12 +364,3 @@
background: var(--text-muted, #999); background: var(--text-muted, #999);
} }
@media (max-width: 1024px) {
.dropdown {
left: 0.75rem;
right: 0.75rem;
width: auto;
bottom: calc(76px + env(safe-area-inset-bottom));
max-height: min(70dvh, 520px);
}
}

View file

@ -10,16 +10,16 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa'; import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
import { useNotifications, UserNotification } from '../../hooks/useNotifications'; import { useNotifications, UserNotification } from '../../hooks/useNotifications';
import { FloatingPortal } from '../UiComponents/FloatingPortal';
import styles from './NotificationBell.module.css'; import styles from './NotificationBell.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
// Icon mapping for notification types
const typeIcons: Record<string, React.ReactNode> = { const typeIcons: Record<string, React.ReactNode> = {
invitation: <FaEnvelope />, invitation: <FaEnvelope />,
system: <FaCog />, system: <FaCog />,
workflow: <FaCog />, workflow: <FaCog />,
mention: <FaExclamationTriangle /> mention: <FaExclamationTriangle />,
}; };
interface NotificationBellProps { interface NotificationBellProps {
@ -28,6 +28,7 @@ interface NotificationBellProps {
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => { export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const bellButtonRef = useRef<HTMLButtonElement>(null);
const formatRelativeTime = useCallback((timestamp: number): string => { const formatRelativeTime = useCallback((timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) { if (!Number.isFinite(timestamp) || timestamp <= 0) {
@ -59,46 +60,28 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
executeAction, executeAction,
dismissNotification, dismissNotification,
startPolling, startPolling,
stopPolling stopPolling,
} = useNotifications(); } = useNotifications();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null); const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Start polling on mount
useEffect(() => { useEffect(() => {
startPolling(30000); // Poll every 30 seconds startPolling(30000);
return () => stopPolling(); return () => stopPolling();
}, [startPolling, stopPolling]); }, [startPolling, stopPolling]);
// Fetch notifications when dropdown opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
fetchNotifications({ limit: 10 }); fetchNotifications({ limit: 10 });
} }
}, [isOpen, fetchNotifications]); }, [isOpen, fetchNotifications]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
// Handle action button click
const handleAction = useCallback(async ( const handleAction = useCallback(async (
notification: UserNotification, notification: UserNotification,
actionId: string, actionId: string,
event: React.MouseEvent event: React.MouseEvent,
) => { ) => {
event.stopPropagation(); event.stopPropagation();
setActionLoading(`${notification.id}-${actionId}`); setActionLoading(`${notification.id}-${actionId}`);
@ -109,11 +92,9 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
if (result) { if (result) {
setActionSuccess(notification.id); setActionSuccess(notification.id);
// Reload sidebar when accepting an invitation (grants new mandate/feature access)
if (actionId === 'accept' && notification.referenceType === 'Invitation') { if (actionId === 'accept' && notification.referenceType === 'Invitation') {
window.dispatchEvent(new CustomEvent('features-changed')); window.dispatchEvent(new CustomEvent('features-changed'));
} }
// Clear success state after animation
setTimeout(() => { setTimeout(() => {
setActionSuccess(null); setActionSuccess(null);
fetchNotifications({ limit: 10 }); fetchNotifications({ limit: 10 });
@ -121,31 +102,30 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
} }
}, [executeAction, fetchNotifications]); }, [executeAction, fetchNotifications]);
// Handle dismiss
const handleDismiss = useCallback(async ( const handleDismiss = useCallback(async (
notification: UserNotification, notification: UserNotification,
event: React.MouseEvent event: React.MouseEvent,
) => { ) => {
event.stopPropagation(); event.stopPropagation();
await dismissNotification(notification.id); await dismissNotification(notification.id);
}, [dismissNotification]); }, [dismissNotification]);
// Handle notification click (mark as read)
const handleNotificationClick = useCallback(async (notification: UserNotification) => { const handleNotificationClick = useCallback(async (notification: UserNotification) => {
if (notification.status === 'unread') { if (notification.status === 'unread') {
await markAsRead(notification.id); await markAsRead(notification.id);
} }
}, [markAsRead]); }, [markAsRead]);
// Filter out dismissed notifications
const visibleNotifications = notifications.filter(n => n.status !== 'dismissed'); const visibleNotifications = notifications.filter(n => n.status !== 'dismissed');
return ( return (
<div className={`${styles.notificationBell} ${className || ''}`} ref={dropdownRef}> <div className={`${styles.notificationBell} ${className || ''}`}>
{/* Bell Button */}
<button <button
ref={bellButtonRef}
type="button"
className={styles.bellButton} className={styles.bellButton}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(v => !v)}
aria-expanded={isOpen}
aria-label={unreadCount > 0 ? t('Benachrichtigungen ({count} ungelesen)', { count: String(unreadCount) }) : t('Benachrichtigungen')} aria-label={unreadCount > 0 ? t('Benachrichtigungen ({count} ungelesen)', { count: String(unreadCount) }) : t('Benachrichtigungen')}
> >
<FaBell className={styles.bellIcon} /> <FaBell className={styles.bellIcon} />
@ -156,14 +136,19 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
)} )}
</button> </button>
{/* Dropdown */} <FloatingPortal
{isOpen && ( open={isOpen}
anchorRef={bellButtonRef}
onClose={() => setIsOpen(false)}
placement="auto"
align="end"
>
<div className={styles.dropdown}> <div className={styles.dropdown}>
{/* Header */}
<div className={styles.header}> <div className={styles.header}>
<h3>{t('Benachrichtigungen')}</h3> <h3>{t('Benachrichtigungen')}</h3>
{visibleNotifications.some(n => n.status === 'unread') && ( {visibleNotifications.some(n => n.status === 'unread') && (
<button <button
type="button"
className={styles.markAllRead} className={styles.markAllRead}
onClick={() => markAllAsRead()} onClick={() => markAllAsRead()}
> >
@ -172,7 +157,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
)} )}
</div> </div>
{/* Content */}
<div className={styles.content}> <div className={styles.content}>
{loading && visibleNotifications.length === 0 && ( {loading && visibleNotifications.length === 0 && (
<div className={styles.loading}>{t('Lade')}</div> <div className={styles.loading}>{t('Lade')}</div>
@ -199,7 +183,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
`} `}
onClick={() => handleNotificationClick(notification)} onClick={() => handleNotificationClick(notification)}
> >
{/* Success overlay */}
{actionSuccess === notification.id && ( {actionSuccess === notification.id && (
<div className={styles.successOverlay}> <div className={styles.successOverlay}>
<FaCheckCircle /> <FaCheckCircle />
@ -207,23 +190,21 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
</div> </div>
)} )}
{/* Icon */}
<div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}> <div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}>
{typeIcons[notification.type] || <FaBell />} {typeIcons[notification.type] || <FaBell />}
</div> </div>
{/* Content */}
<div className={styles.notificationContent}> <div className={styles.notificationContent}>
<div className={styles.title}>{notification.title}</div> <div className={styles.title}>{notification.title}</div>
<div className={styles.message}>{notification.message}</div> <div className={styles.message}>{notification.message}</div>
<div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div> <div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div>
{/* Actions */}
{notification.actions && notification.status !== 'actioned' && ( {notification.actions && notification.status !== 'actioned' && (
<div className={styles.actions}> <div className={styles.actions}>
{notification.actions.map(action => ( {notification.actions.map(action => (
<button <button
key={action.actionId} key={action.actionId}
type="button"
className={`${styles.actionButton} ${styles[`action_${action.style}`]}`} className={`${styles.actionButton} ${styles[`action_${action.style}`]}`}
onClick={(e) => handleAction(notification, action.actionId, e)} onClick={(e) => handleAction(notification, action.actionId, e)}
disabled={actionLoading === `${notification.id}-${action.actionId}`} disabled={actionLoading === `${notification.id}-${action.actionId}`}
@ -242,7 +223,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
</div> </div>
)} )}
{/* Action result */}
{notification.actionTaken && ( {notification.actionTaken && (
<div className={styles.actionResult}> <div className={styles.actionResult}>
{notification.actionResult} {notification.actionResult}
@ -250,9 +230,9 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
)} )}
</div> </div>
{/* Dismiss button */}
{notification.status !== 'actioned' && ( {notification.status !== 'actioned' && (
<button <button
type="button"
className={styles.dismissButton} className={styles.dismissButton}
onClick={(e) => handleDismiss(notification, e)} onClick={(e) => handleDismiss(notification, e)}
aria-label={t('Schließen')} aria-label={t('Schließen')}
@ -264,7 +244,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
))} ))}
</div> </div>
</div> </div>
)} </FloatingPortal>
</div> </div>
); );
}; };

View file

@ -51,10 +51,6 @@
/* ---------- Popover ---------- */ /* ---------- Popover ---------- */
.popover { .popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1000;
background: var(--bg-primary, #ffffff); background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1A202C); color: var(--text-primary, #1A202C);
border: 1px solid var(--border-color, #E2E8F0); border: 1px solid var(--border-color, #E2E8F0);
@ -67,11 +63,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.popover.alignRight {
left: auto;
right: 0;
}
.body { .body {
display: grid; display: grid;
grid-template-columns: 200px 240px 1fr; grid-template-columns: 200px 240px 1fr;

View file

@ -14,6 +14,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { FloatingPortal } from '../UiComponents/FloatingPortal';
import PeriodPickerPopover from './PeriodPickerPopover'; import PeriodPickerPopover from './PeriodPickerPopover';
import { import {
formatIsoDateDe, formatIsoDateDe,
@ -108,20 +109,7 @@ export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
); );
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
// Outside click via mousedown (see file header).
useEffect(() => {
if (!open) return;
const _onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (wrapRef.current && wrapRef.current.contains(target)) return;
setOpen(false);
};
window.addEventListener('mousedown', _onDown);
return () => window.removeEventListener('mousedown', _onDown);
}, [open]);
const _initialDraft: PeriodValue = useMemo(() => { const _initialDraft: PeriodValue = useMemo(() => {
if (resolvedValue) return resolvedValue; if (resolvedValue) return resolvedValue;
@ -155,8 +143,9 @@ export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
if (open) triggerCls.push(styles.open); if (open) triggerCls.push(styles.open);
return ( return (
<div ref={wrapRef} className={`${styles.wrapper}${className ? ` ${className}` : ''}`}> <div className={`${styles.wrapper}${className ? ` ${className}` : ''}`}>
<button <button
ref={triggerRef}
type="button" type="button"
className={triggerCls.join(' ')} className={triggerCls.join(' ')}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
@ -169,14 +158,19 @@ export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
<span className={styles.triggerChev} aria-hidden></span> <span className={styles.triggerChev} aria-hidden></span>
</button> </button>
{open && ( <FloatingPortal
open={open}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
placement="auto"
>
<PeriodPickerPopover <PeriodPickerPopover
initialValue={_initialDraft} initialValue={_initialDraft}
constraints={constraints} constraints={constraints}
onApply={_handleApply} onApply={_handleApply}
onCancel={_handleCancel} onCancel={_handleCancel}
/> />
)} </FloatingPortal>
</div> </div>
); );
}; };

View file

@ -7,7 +7,7 @@
* actual commit to the parent via `onApply` / `onCancel`. * actual commit to the parent via `onApply` / `onCancel`.
*/ */
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerCalendar from './PeriodPickerCalendar'; import PeriodPickerCalendar from './PeriodPickerCalendar';
import { import {
@ -192,7 +192,6 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
const footerMax = clampIsoDate(undefined, constraints, 'max'); const footerMax = clampIsoDate(undefined, constraints, 'max');
// Keyboard: Esc cancels, Enter applies // Keyboard: Esc cancels, Enter applies
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const _onKey = (e: KeyboardEvent) => { const _onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); } if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
@ -202,38 +201,8 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
return () => window.removeEventListener('keydown', _onKey); return () => window.removeEventListener('keydown', _onKey);
}, [draft, onApply, onCancel]); }, [draft, onApply, onCancel]);
useLayoutEffect(() => {
const pop = popRef.current;
if (!pop) return;
const _clamp = () => {
const parent = pop.parentElement;
if (!parent) return;
const pRect = parent.getBoundingClientRect();
const margin = 8;
const popW = pop.offsetWidth || 720;
const popH = pop.offsetHeight || 400;
let left = pRect.left;
let top = pRect.bottom + 6;
if (left + popW > window.innerWidth - margin) {
left = window.innerWidth - margin - popW;
}
if (left < margin) left = margin;
if (top + popH > window.innerHeight - margin) {
top = Math.max(margin, pRect.top - 6 - popH);
}
pop.style.position = 'fixed';
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
pop.style.right = 'auto';
pop.style.zIndex = '2001';
};
_clamp();
const id = requestAnimationFrame(() => _clamp());
return () => cancelAnimationFrame(id);
}, []);
return ( return (
<div ref={popRef} className={styles.popover}> <div className={styles.popover}>
<div className={styles.body}> <div className={styles.body}>
{/* Column 1: Presets */} {/* Column 1: Presets */}
<div className={styles.colPresets}> <div className={styles.colPresets}>

View file

@ -75,18 +75,13 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
/* Dropdown Content - opens upward */ /* Dropdown content — positioned by FloatingPortal */
.dropdownContent { .dropdownContent {
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
z-index: 1000;
padding: 8px; padding: 8px;
background: var(--surface-color, #ffffff); background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px; border-radius: 6px;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
min-width: 220px; min-width: 220px;
} }

View file

@ -16,8 +16,9 @@
* resolveProviders(selection, allowedProviders) liefert die konkrete Liste. * resolveProviders(selection, allowedProviders) liefert die konkrete Liste.
*/ */
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import React, { useEffect, useMemo, useState, useRef } from 'react';
import { useBilling } from '../../hooks/useBilling'; import { useBilling } from '../../hooks/useBilling';
import { FloatingPortal } from '../UiComponents/FloatingPortal';
import styles from './ProviderSelector.module.css'; import styles from './ProviderSelector.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
@ -190,7 +191,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
const resolvedLabel = label ?? t('AI-Provider'); const resolvedLabel = label ?? t('AI-Provider');
const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false); const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const { allowedProviders, loadAllowedProviders, loading } = useBilling(); const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => { useEffect(() => {
@ -212,19 +213,6 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
} }
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]); }, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
const _handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
}, []);
useEffect(() => {
if (isExpanded) {
document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside);
}
}, [isExpanded, _handleClickOutside]);
const effectiveSelection = useMemo( const effectiveSelection = useMemo(
() => _resolveProviders(selection, allowedProviders), () => _resolveProviders(selection, allowedProviders),
[selection, allowedProviders], [selection, allowedProviders],
@ -282,20 +270,27 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
return ( return (
<div <div
ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`} className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
> >
<button <button
ref={triggerRef}
type="button" type="button"
className={styles.triggerButton} className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
disabled={disabled} disabled={disabled}
title={t('Provider auswählen')} title={t('Provider auswählen')}
aria-expanded={isExpanded}
> >
<span className={styles.buttonIcon}>{summaryIcon}</span> <span className={styles.buttonIcon}>{summaryIcon}</span>
</button> </button>
{isExpanded && ( <FloatingPortal
open={isExpanded}
anchorRef={triggerRef}
onClose={() => setIsExpanded(false)}
placement="top"
align="center"
>
<div className={styles.dropdownContent}> <div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{resolvedLabel}</div>} {showLabel && <div className={styles.dropdownHeader}>{resolvedLabel}</div>}
@ -336,7 +331,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
<div className={styles.hint}>{summaryHint}</div> <div className={styles.hint}>{summaryHint}</div>
</div> </div>
)} </FloatingPortal>
</div> </div>
); );
}; };

View file

@ -65,10 +65,6 @@
} }
.dropdown { .dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--card-bg, #fff); background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px; border-radius: 8px;

View file

@ -3,6 +3,7 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { FloatingPortal } from '../UiComponents/FloatingPortal';
import styles from './RagRunningBadge.module.css'; import styles from './RagRunningBadge.module.css';
interface _RagJob { interface _RagJob {
@ -25,6 +26,7 @@ export const RagRunningBadge: React.FC = () => {
const [jobs, setJobs] = useState<_RagJob[]>([]); const [jobs, setJobs] = useState<_RagJob[]>([]);
const [justFinished, setJustFinished] = useState(false); const [justFinished, setJustFinished] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const badgeButtonRef = useRef<HTMLButtonElement>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const previousJobCount = useRef(0); const previousJobCount = useRef(0);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -79,6 +81,7 @@ export const RagRunningBadge: React.FC = () => {
return ( return (
<div className={styles.badgeContainer}> <div className={styles.badgeContainer}>
<button <button
ref={badgeButtonRef}
className={styles.badge} className={styles.badge}
onClick={() => setExpanded(prev => !prev)} onClick={() => setExpanded(prev => !prev)}
title={t('RAG-Indexierung aktiv')} title={t('RAG-Indexierung aktiv')}
@ -89,7 +92,13 @@ export const RagRunningBadge: React.FC = () => {
</span> </span>
</button> </button>
{expanded && ( <FloatingPortal
open={expanded}
anchorRef={badgeButtonRef}
onClose={() => setExpanded(false)}
placement="top"
align="end"
>
<div className={styles.dropdown}> <div className={styles.dropdown}>
<div className={styles.dropdownHeader}> <div className={styles.dropdownHeader}>
{t('Aktive RAG-Jobs')} {t('Aktive RAG-Jobs')}
@ -103,7 +112,7 @@ export const RagRunningBadge: React.FC = () => {
</div> </div>
))} ))}
</div> </div>
)} </FloatingPortal>
</div> </div>
); );
}; };

View file

@ -4,12 +4,7 @@
} }
.suggestionsWrapper { .suggestionsWrapper {
position: absolute; width: 100%;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
z-index: 1000;
max-height: 300px; max-height: 300px;
overflow: hidden; overflow: hidden;
border-radius: 12px; border-radius: 12px;

View file

@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import TextField from '../TextField/TextField'; import TextField from '../TextField/TextField';
import { BaseTextFieldProps } from '../TextField/TextFieldTypes'; import { BaseTextFieldProps } from '../TextField/TextFieldTypes';
import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi'; import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi';
import { FloatingPortal } from '../FloatingPortal';
import styles from './AddressAutocomplete.module.css'; import styles from './AddressAutocomplete.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -45,7 +46,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
const [query, setQuery] = useState(value); const [query, setQuery] = useState(value);
const [autocompleteError, setAutocompleteError] = useState<string | null>(null); const [autocompleteError, setAutocompleteError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const anchorRef = useRef<HTMLDivElement>(null);
const suggestionsRef = useRef<HTMLUListElement>(null); const suggestionsRef = useRef<HTMLUListElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null); const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -194,23 +195,6 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
} }
}, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]); }, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]);
// Click outside handler
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
setSelectedIndex(-1);
}
};
if (showSuggestions) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showSuggestions]);
// Scroll selected item into view // Scroll selected item into view
useEffect(() => { useEffect(() => {
if (selectedIndex >= 0 && suggestionsRef.current) { if (selectedIndex >= 0 && suggestionsRef.current) {
@ -254,7 +238,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
}, []); }, []);
return ( return (
<div ref={containerRef} className={`${styles.autocompleteContainer} ${className}`}> <div ref={anchorRef} className={`${styles.autocompleteContainer} ${className}`}>
<TextField <TextField
value={query} value={query}
onChange={handleInputChange} onChange={handleInputChange}
@ -273,7 +257,15 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
{...props} {...props}
/> />
{showSuggestions && ( <FloatingPortal
open={showSuggestions}
anchorRef={anchorRef}
onClose={() => {
setShowSuggestions(false);
setSelectedIndex(-1);
}}
placement="bottom"
>
<div className={styles.suggestionsWrapper}> <div className={styles.suggestionsWrapper}>
<ul ref={suggestionsRef} className={styles.suggestionsList}> <ul ref={suggestionsRef} className={styles.suggestionsList}>
{isLoading && ( {isLoading && (
@ -307,7 +299,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
))} ))}
</ul> </ul>
</div> </div>
)} </FloatingPortal>
</div> </div>
); );
}; };

View file

@ -161,17 +161,12 @@
} }
.dropdownMenu { .dropdownMenu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background-color: var(--color-bg); background-color: var(--color-bg);
border: 1px solid var(--color-border, #E2E8F0); border: 1px solid var(--color-border, #E2E8F0);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000; min-width: 180px;
overflow: hidden; overflow: hidden;
min-width: 100%;
} }
.dropdownHeader { .dropdownHeader {

View file

@ -1,8 +1,9 @@
// Copyright (c) 2026 PowerOn AG // Copyright (c) 2026 PowerOn AG
// All rights reserved. // All rights reserved.
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from 'react';
import { IconType } from 'react-icons'; import { IconType } from 'react-icons';
import { IoChevronDown, IoClose } from 'react-icons/io5'; import { IoChevronDown, IoClose } from 'react-icons/io5';
import { FloatingPortal } from '../FloatingPortal';
import styles from './DropdownSelect.module.css'; import styles from './DropdownSelect.module.css';
import { ButtonVariant, ButtonSize } from '../Button/ButtonTypes'; import { ButtonVariant, ButtonSize } from '../Button/ButtonTypes';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -61,24 +62,7 @@ function DropdownSelect<T = any>({
const resolvedPlaceholder = placeholder ?? t('Element auswählen'); const resolvedPlaceholder = placeholder ?? t('Element auswählen');
const resolvedEmptyMessage = emptyMessage ?? t('Keine Einträge verfügbar'); const resolvedEmptyMessage = emptyMessage ?? t('Keine Einträge verfügbar');
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Find selected item // Find selected item
const selectedItem = selectedItemId !== null && selectedItemId !== undefined const selectedItem = selectedItemId !== null && selectedItemId !== undefined
@ -174,8 +158,7 @@ function DropdownSelect<T = any>({
}; };
return ( return (
<div ref={dropdownRef} className={styles.dropdownContainer} style={{ minWidth }}> <div className={styles.dropdownContainer} style={{ minWidth }}>
{/* Show clear button if item is selected and showClearButton is enabled */}
{selectedItem && showClearButton ? ( {selectedItem && showClearButton ? (
renderClearButtonContent() renderClearButtonContent()
) : renderButton ? ( ) : renderButton ? (
@ -184,17 +167,25 @@ function DropdownSelect<T = any>({
</div> </div>
) : ( ) : (
<button <button
ref={triggerRef}
type="button" type="button"
className={buttonClasses} className={buttonClasses}
onClick={toggleDropdown} onClick={toggleDropdown}
disabled={disabled || loading} disabled={disabled || loading}
aria-expanded={isOpen}
> >
{renderDefaultButton()} {renderDefaultButton()}
</button> </button>
)} )}
{isOpen && ( <FloatingPortal
<div className={styles.dropdownMenu} style={{ maxHeight }}> open={isOpen}
anchorRef={triggerRef}
onClose={() => setIsOpen(false)}
placement="bottom"
align="start"
>
<div className={styles.dropdownMenu} style={{ maxHeight, minWidth }}>
{headerText && ( {headerText && (
<div className={styles.dropdownHeader}> <div className={styles.dropdownHeader}>
{headerText} {headerText}
@ -237,7 +228,7 @@ function DropdownSelect<T = any>({
</div> </div>
)} )}
</div> </div>
)} </FloatingPortal>
</div> </div>
); );
} }

View file

@ -0,0 +1,5 @@
.layer {
position: fixed;
z-index: 3000;
box-sizing: border-box;
}

View file

@ -0,0 +1,168 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* FloatingPortal renders floating UI on document.body with fixed positioning
* relative to an anchor element. Escapes ancestor overflow clipping.
*/
import React, { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './FloatingPortal.module.css';
export type FloatingPlacement = 'top' | 'bottom' | 'auto';
export interface FloatingPortalProps {
open: boolean;
anchorRef: React.RefObject<HTMLElement | null>;
onClose?: () => void;
placement?: FloatingPlacement;
offset?: number;
align?: 'start' | 'center' | 'end';
/** Keep children mounted while closed (avoids reload lag on reopen). */
keepMounted?: boolean;
className?: string;
children: React.ReactNode;
}
interface FloatingCoords {
top: number;
left: number;
minWidth: number;
}
function _resolvePlacement(
preferred: FloatingPlacement,
anchorRect: DOMRect,
popRect: { width: number; height: number },
offset: number,
): 'top' | 'bottom' {
if (preferred === 'top' || preferred === 'bottom') return preferred;
const spaceBelow = window.innerHeight - anchorRect.bottom - offset;
const spaceAbove = anchorRect.top - offset;
if (spaceBelow >= popRect.height) return 'bottom';
if (spaceAbove >= popRect.height) return 'top';
return spaceBelow >= spaceAbove ? 'bottom' : 'top';
}
function _computeCoords(
anchor: HTMLElement,
popEl: HTMLElement,
placement: FloatingPlacement,
offset: number,
align: 'start' | 'center' | 'end',
): FloatingCoords {
const margin = 8;
const anchorRect = anchor.getBoundingClientRect();
const popW = popEl.offsetWidth || 220;
const popH = popEl.offsetHeight || 200;
const resolved = _resolvePlacement(placement, anchorRect, { width: popW, height: popH }, offset);
let top = resolved === 'bottom'
? anchorRect.bottom + offset
: anchorRect.top - offset - popH;
let left = anchorRect.left;
if (align === 'center') {
left = anchorRect.left + anchorRect.width / 2 - popW / 2;
} else if (align === 'end') {
left = anchorRect.right - popW;
}
if (left + popW > window.innerWidth - margin) {
left = window.innerWidth - margin - popW;
}
if (left < margin) left = margin;
if (top + popH > window.innerHeight - margin) {
top = Math.max(margin, window.innerHeight - margin - popH);
}
if (top < margin) top = margin;
return {
top,
left,
minWidth: Math.max(anchorRect.width, 0),
};
}
export const FloatingPortal: React.FC<FloatingPortalProps> = ({
open,
anchorRef,
onClose,
placement = 'auto',
offset = 4,
align = 'start',
keepMounted = false,
className,
children,
}) => {
const layerRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<FloatingCoords | null>(null);
useLayoutEffect(() => {
if (!open && !keepMounted) {
setCoords(null);
return;
}
if (!open) return;
const anchor = anchorRef.current;
const layer = layerRef.current;
if (!anchor || !layer) return;
const _update = () => {
const next = _computeCoords(anchor, layer, placement, offset, align);
setCoords(next);
};
_update();
const rafId = requestAnimationFrame(_update);
window.addEventListener('resize', _update);
window.addEventListener('scroll', _update, true);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('resize', _update);
window.removeEventListener('scroll', _update, true);
};
}, [open, keepMounted, anchorRef, placement, offset, align, children]);
useLayoutEffect(() => {
if (!open || !onClose) return;
const _onPointerDown = (event: MouseEvent) => {
const layer = layerRef.current;
const anchor = anchorRef.current;
const target = event.target as Node;
if (layer?.contains(target) || anchor?.contains(target)) return;
onClose();
};
document.addEventListener('mousedown', _onPointerDown);
return () => document.removeEventListener('mousedown', _onPointerDown);
}, [open, onClose, anchorRef]);
if (!open && !keepMounted) return null;
return createPortal(
<div
ref={layerRef}
className={[styles.layer, className].filter(Boolean).join(' ')}
style={!open ? {
top: -9999,
left: -9999,
visibility: 'hidden',
pointerEvents: 'none',
} : coords ? {
top: coords.top,
left: coords.left,
minWidth: coords.minWidth,
visibility: 'visible',
} : {
top: -9999,
left: -9999,
visibility: 'hidden',
}}
>
{children}
</div>,
document.body,
);
};
export default FloatingPortal;

View file

@ -0,0 +1,4 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { FloatingPortal, default } from './FloatingPortal';
export type { FloatingPortalProps, FloatingPlacement } from './FloatingPortal';

View file

@ -1,50 +0,0 @@
.tabsContainer {
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
min-height: 0;
gap: 0;
}
.tabsHeader {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border, #e0e0e0);
margin-bottom: 1rem;
flex-shrink: 0;
}
.tabButton {
padding: 0.75rem 1.5rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
color: var(--color-text, #666);
transition: all 0.2s ease;
margin-bottom: -2px;
font-family: var(--font-family);
}
.tabButton:hover {
color: var(--color-text, #333);
background: var(--color-bg-hover, rgba(0, 0, 0, 0.02));
}
.tabButtonActive {
color: var(--color-secondary, #007bff);
border-bottom-color: var(--color-primary, #007bff);
font-weight: 600;
}
.tabsContent {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
}

View file

@ -1,61 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import React, { useState } from 'react';
import styles from './Tabs.module.css';
export interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
export interface TabsProps {
tabs: Tab[];
defaultTabId?: string;
/** Controlled active tab. When provided, internal state is ignored. */
activeTabId?: string;
onTabChange?: (tabId: string) => void;
className?: string;
}
export function Tabs({ tabs, defaultTabId, activeTabId: controlledTabId, onTabChange, className = '' }: TabsProps) {
const [internalTabId, setInternalTabId] = useState<string>(
defaultTabId || tabs[0]?.id || ''
);
const activeTabId = controlledTabId ?? internalTabId;
const handleTabClick = (tabId: string) => {
if (!controlledTabId) setInternalTabId(tabId);
onTabChange?.(tabId);
};
const activeTab = tabs.find(tab => tab.id === activeTabId) || tabs[0];
if (!tabs || tabs.length === 0) {
return null;
}
return (
<div className={`${styles.tabsContainer} ${className}`}>
<div className={styles.tabsHeader}>
{tabs.map(tab => (
<button
key={tab.id}
className={`${styles.tabButton} ${activeTabId === tab.id ? styles.tabButtonActive : ''}`}
onClick={() => handleTabClick(tab.id)}
type="button"
>
{tab.label}
</button>
))}
</div>
<div className={styles.tabsContent}>
{activeTab && activeTab.content}
</div>
</div>
);
}
export default Tabs;

View file

@ -1,5 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { Tabs, default } from './Tabs';
export type { TabsProps, Tab } from './Tabs';

View file

@ -19,9 +19,8 @@ export type { LogMessageProps } from './Log/LogMessage';
export { WorkflowStatus } from './WorkflowStatus'; export { WorkflowStatus } from './WorkflowStatus';
export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes'; export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
export * from './AutoScroll'; export * from './AutoScroll';
export * from './Tabs';
export type { TabsProps, Tab } from './Tabs';
export * from './AccordionList'; export * from './AccordionList';
export * from './Toast'; export * from './Toast';
export * from './VoiceLanguageSelect'; export * from './VoiceLanguageSelect';
export * from './Modal'; export * from './Modal';
export * from './FloatingPortal';

View file

@ -1,6 +1,8 @@
.chatsTab { .chatsTab {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
min-height: 0;
gap: 8px; gap: 8px;
} }

View file

@ -28,7 +28,7 @@ type ChatFilter = 'active' | 'archived';
interface ChatsTabProps { interface ChatsTabProps {
context: UdbContext; context: UdbContext;
onSelectChat?: (chatId: string, featureInstanceId: string) => void; onSelectChat?: (chatId: string, featureInstanceId: string, label?: string) => void;
onDragStart?: (chatId: string, event: React.DragEvent) => void; onDragStart?: (chatId: string, event: React.DragEvent) => void;
activeWorkflowId?: string; activeWorkflowId?: string;
chatListRefreshKey?: number; chatListRefreshKey?: number;
@ -288,7 +288,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
key={chat.id} key={chat.id}
className={itemClassName} className={itemClassName}
onClick={() => { onClick={() => {
if (!isEditing) onSelectChat?.(chat.id, featureInstanceId); if (!isEditing) onSelectChat?.(chat.id, featureInstanceId, chat.label);
}} }}
draggable={!!onDragStart && !isEditing} draggable={!!onDragStart && !isEditing}
onDragStart={(e) => { onDragStart={(e) => {

View file

@ -1,6 +1,8 @@
.filesTab { .filesTab {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
min-height: 0;
height: 100%; height: 100%;
position: relative; position: relative;
} }

View file

@ -1,6 +1,8 @@
.udb { .udb {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
min-height: 0;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
@ -38,6 +40,7 @@
.tabContent { .tabContent {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 8px; padding: 8px;
} }

View file

@ -43,7 +43,7 @@ interface UnifiedDataBarProps {
activeTab?: UdbTab; activeTab?: UdbTab;
onTabChange?: (tab: UdbTab) => void; onTabChange?: (tab: UdbTab) => void;
hideTabs?: UdbTab[]; hideTabs?: UdbTab[];
onSelectChat?: (chatId: string, featureInstanceId: string) => void; onSelectChat?: (chatId: string, featureInstanceId: string, label?: string) => void;
activeWorkflowId?: string; activeWorkflowId?: string;
onRenameChat?: (chatId: string, newName: string) => void; onRenameChat?: (chatId: string, newName: string) => void;
chatListRefreshKey?: number; chatListRefreshKey?: number;

View file

@ -1,5 +1,6 @@
// Copyright (c) 2026 PowerOn AG // Copyright (c) 2026 PowerOn AG
// All rights reserved. // All rights reserved.
export { default as UnifiedDataBar } from './UnifiedDataBar'; export { default as UnifiedDataBar } from './UnifiedDataBar';
export { default as ChatsTab } from './ChatsTab';
export type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from './UnifiedDataBar'; export type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from './UnifiedDataBar';
export { useUdlContext } from './useUdlContext'; export { useUdlContext } from './useUdlContext';

View file

@ -4,6 +4,8 @@ import type { KeepAliveEntry } from '../types/keepAlive.types';
import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage'; import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage';
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage'; import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
import { CommcoachSessionView } from '../pages/views/commcoach'; import { CommcoachSessionView } from '../pages/views/commcoach';
import { TeamsbotSessionView } from '../pages/views/teamsbot/TeamsbotSessionView';
import { RedmineBrowserView } from '../pages/views/redmine/RedmineBrowserView';
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage'; import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
import { WorkflowAutomationPage } from '../pages/workflowAutomation/WorkflowAutomationHubPage'; import { WorkflowAutomationPage } from '../pages/workflowAutomation/WorkflowAutomationHubPage';
@ -31,6 +33,19 @@ export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
shellOverflowHidden: false, shellOverflowHidden: false,
render: ({ scopeKey }) => <CommcoachSessionView key={scopeKey} />, render: ({ scopeKey }) => <CommcoachSessionView key={scopeKey} />,
}, },
{
id: 'teamsbot-session',
pathRegex: /\/mandates\/[^/]+\/teamsbot\/[^/]+\/sessions/,
scopeRegex: /\/mandates\/([^/]+)\/teamsbot\/([^/]+)\/sessions/,
shellOverflowHidden: false,
render: ({ scopeKey }) => <TeamsbotSessionView key={scopeKey} />,
},
{
id: 'redmine-browser',
pathRegex: /\/mandates\/[^/]+\/redmine\/[^/]+\/browser/,
scopeRegex: /\/mandates\/([^/]+)\/redmine\/([^/]+)\/browser/,
render: ({ scopeKey }) => <RedmineBrowserView key={scopeKey} />,
},
{ {
id: 'admin-languages', id: 'admin-languages',
pathRegex: /\/admin\/languages(?:$|\/)/, pathRegex: /\/admin\/languages(?:$|\/)/,

View file

@ -136,7 +136,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.neutralization': <FaShieldAlt />, 'feature.neutralization': <FaShieldAlt />,
'feature.trustee': <FaBriefcase />, 'feature.trustee': <FaBriefcase />,
'feature.realestate': <FaBuilding />, 'feature.realestate': <FaBuilding />,
'feature.chatworkflow': <FaPlay />,
'feature.teamsbot': <FaHeadset />, 'feature.teamsbot': <FaHeadset />,
// Feature pages - Workspace // Feature pages - Workspace

View file

@ -0,0 +1,37 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* useDocumentTitle sets `${appName} - ${pageTitle}` on document.title.
*
* Pass `isActive=false` (or a failing routeMatch) on Keep-Alive pages so a
* hidden instance does not overwrite the visible route's title.
*/
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { getAppName } from '../../config/config';
export type RouteMatchFn = (pathname: string, search: string) => boolean;
export interface UseDocumentTitleOptions {
/** When false, the title is not updated. Default: true. */
isActive?: boolean;
/** When set, title updates only while this matcher returns true. */
routeMatch?: RouteMatchFn;
}
export function useDocumentTitle(
pageTitle: string,
options: UseDocumentTitleOptions = {},
): void {
const location = useLocation();
const { isActive = true, routeMatch } = options;
const routeMatches = !routeMatch || routeMatch(location.pathname, location.search);
const shouldSet = isActive && routeMatches && pageTitle.trim().length > 0;
useEffect(() => {
if (!shouldSet) return;
document.title = `${getAppName()} - ${pageTitle}`;
}, [pageTitle, shouldSet]);
}

View file

@ -125,61 +125,102 @@ function isDynamicBlock(block: NavigationBlock): block is DynamicBlock {
return block.type === 'dynamic'; return block.type === 'dynamic';
} }
// =============================================================================
// SHARED CACHE (single in-flight request for all hook consumers)
// =============================================================================
type NavigationListener = () => void;
let sharedBlocks: NavigationBlock[] = [];
let sharedLoading = false;
let sharedError: string | null = null;
let inFlightFetch: Promise<void> | null = null;
const listeners = new Set<NavigationListener>();
let changeEventsBound = false;
function _notifyNavigationListeners() {
listeners.forEach((listener) => listener());
}
function _parseNavigationError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|| 'Fehler beim Laden der Navigation';
}
async function _fetchNavigationShared(force = false): Promise<void> {
if (inFlightFetch && !force) {
return inFlightFetch;
}
sharedLoading = true;
sharedError = null;
_notifyNavigationListeners();
const fetchPromise = (async () => {
try {
const response = await api.get<NavigationResponse>('/api/navigation');
sharedBlocks = response.data.blocks || [];
} catch (err: unknown) {
sharedError = _parseNavigationError(err);
sharedBlocks = [];
} finally {
sharedLoading = false;
inFlightFetch = null;
_notifyNavigationListeners();
}
})();
inFlightFetch = fetchPromise;
return fetchPromise;
}
function _ensureNavigationChangeListeners() {
if (changeEventsBound) {
return;
}
changeEventsBound = true;
const onNavigationChanged = () => {
void _fetchNavigationShared(true);
};
window.addEventListener('features-changed', onNavigationChanged);
window.addEventListener('userInfoUpdated', onNavigationChanged);
}
// ============================================================================= // =============================================================================
// HOOK // HOOK
// ============================================================================= // =============================================================================
export function useNavigation(): UseNavigationReturn { export function useNavigation(): UseNavigationReturn {
const [blocks, setBlocks] = useState<NavigationBlock[]>([]); const [, setRevision] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchNavigation = useCallback(async () => { useEffect(() => {
setLoading(true); _ensureNavigationChangeListeners();
setError(null);
try { const listener = () => setRevision((revision) => revision + 1);
const response = await api.get<NavigationResponse>('/api/navigation'); listeners.add(listener);
setBlocks(response.data.blocks || []); void _fetchNavigationShared();
} catch (err: unknown) {
const errorMsg = err instanceof Error return () => {
? err.message listeners.delete(listener);
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail };
|| 'Fehler beim Laden der Navigation';
setError(errorMsg);
setBlocks([]);
} finally {
setLoading(false);
}
}, []); }, []);
useEffect(() => { const refresh = useCallback(() => _fetchNavigationShared(true), []);
fetchNavigation();
}, [fetchNavigation]);
useEffect(() => { const staticBlocks = sharedBlocks.filter(isStaticBlock);
const onFeaturesChanged = () => { const dynamicBlock = sharedBlocks.find(isDynamicBlock) || null;
fetchNavigation();
};
window.addEventListener('features-changed', onFeaturesChanged);
window.addEventListener('userInfoUpdated', onFeaturesChanged);
return () => {
window.removeEventListener('features-changed', onFeaturesChanged);
window.removeEventListener('userInfoUpdated', onFeaturesChanged);
};
}, [fetchNavigation]);
// Derive static and dynamic blocks
const staticBlocks = blocks.filter(isStaticBlock);
const dynamicBlock = blocks.find(isDynamicBlock) || null;
return { return {
blocks, blocks: sharedBlocks,
staticBlocks, staticBlocks,
dynamicBlock, dynamicBlock,
loading, loading: sharedLoading,
error, error: sharedError,
refresh: fetchNavigation, refresh,
}; };
} }

View file

@ -0,0 +1,85 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* useScrollRestoration remembers scroll position per route (pathname + search).
* Restores on return; scrolls to top when the saved offset exceeds scroll height.
*/
import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react';
import { useLocation } from 'react-router-dom';
import { useScrollMode } from './useScrollMode';
const STORAGE_PREFIX = 'po_scroll:';
function _routeKey(pathname: string, search: string): string {
return `${pathname}${search}`;
}
function _findScrollContainer(anchor: HTMLElement | null, scrollMode: 'bounded' | 'document'): HTMLElement {
if (scrollMode === 'document') {
const content = document.querySelector('[data-scroll-mode="document"].content') as HTMLElement | null;
if (content && content.scrollHeight > content.clientHeight) return content;
return document.documentElement;
}
let node: HTMLElement | null = anchor;
while (node) {
const { overflowY } = getComputedStyle(node);
if (overflowY === 'auto' || overflowY === 'scroll') {
return node;
}
node = node.parentElement;
}
return document.documentElement;
}
function _readPosition(key: string): number | null {
try {
const raw = sessionStorage.getItem(`${STORAGE_PREFIX}${key}`);
if (raw === null) return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
function _writePosition(key: string, top: number): void {
try {
sessionStorage.setItem(`${STORAGE_PREFIX}${key}`, String(top));
} catch {
// sessionStorage unavailable
}
}
export function useScrollRestoration(anchorRef: RefObject<HTMLElement | null>): void {
const location = useLocation();
const scrollMode = useScrollMode();
const prevKeyRef = useRef<string | null>(null);
useLayoutEffect(() => {
const routeKey = _routeKey(location.pathname, location.search);
const container = _findScrollContainer(anchorRef.current, scrollMode);
const saved = _readPosition(routeKey);
if (saved !== null && saved > 0) {
const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
container.scrollTop = saved <= maxScroll ? saved : 0;
} else {
container.scrollTop = 0;
}
prevKeyRef.current = routeKey;
}, [location.pathname, location.search, scrollMode, anchorRef]);
useEffect(() => {
const routeKey = _routeKey(location.pathname, location.search);
const container = _findScrollContainer(anchorRef.current, scrollMode);
return () => {
if (prevKeyRef.current === routeKey) {
_writePosition(routeKey, container.scrollTop);
}
};
}, [location.pathname, location.search, scrollMode, anchorRef]);
}

View file

@ -0,0 +1,55 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* useVisibilityRemeasure re-runs a callback when a hidden element becomes visible.
*
* Keep-Alive routes use display:none; ResizeObserver reports 0×0 until shown again.
* FormGeneratorTable and split panels use this to re-clamp height/width.
*/
import { useEffect, useRef, type RefObject } from 'react';
export function useVisibilityRemeasure(
elementRef: RefObject<HTMLElement | null>,
onRemeasure: () => void,
): void {
const wasVisibleRef = useRef(false);
const onRemeasureRef = useRef(onRemeasure);
onRemeasureRef.current = onRemeasure;
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const _triggerRemeasure = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => onRemeasureRef.current());
});
};
const _check = () => {
const visible = el.clientWidth > 0 && el.clientHeight > 0;
if (!wasVisibleRef.current && visible) {
_triggerRemeasure();
}
wasVisibleRef.current = visible;
};
_check();
const resizeObserver = new ResizeObserver(_check);
resizeObserver.observe(el);
const parent = el.parentElement;
let parentObserver: MutationObserver | null = null;
if (parent) {
parentObserver = new MutationObserver(_check);
parentObserver.observe(parent, { attributes: true, attributeFilter: ['style'] });
}
return () => {
resizeObserver.disconnect();
parentObserver?.disconnect();
};
}, [elementRef]);
}

View file

@ -1,708 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { fetchAttributes as fetchAttributesApi } from '../api/attributesApi';
import type { AttributeDefinition, ApiRequestFunction } from '../api/attributesApi';
import {
fetchWorkflows as fetchWorkflowsFromApi,
fetchWorkflow as fetchWorkflowFromApi,
deleteWorkflow as deleteWorkflowFromApi,
updateWorkflow as updateWorkflowFromApi,
} from '../api/workflowAutomationApi';
import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext';
import { usePermissions, type UserPermissions } from './usePermissions';
export type StartWorkflowRequest = Record<string, unknown>;
function _isValidApiBaseUrl(apiBaseUrl: string | undefined): boolean {
return apiBaseUrl === '/api/workflow-automation';
}
async function _deleteWorkflowsSequential(
request: ApiRequestFunction,
workflowIds: string[],
) {
for (const id of workflowIds) {
await deleteWorkflowFromApi(request, id);
}
}
async function startWorkflowApi(
request: ApiRequestFunction,
_instanceId: string,
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' },
) {
const wfId = options?.workflowId ?? (workflowData as { workflowId?: string }).workflowId;
return await request({
url: `/api/workflow-automation/execute`,
method: 'post',
data: {
workflowId: wfId,
payload: workflowData,
},
});
}
async function stopWorkflowApi(request: ApiRequestFunction, instanceId: string, workflowId: string) {
await request({
url: `/api/workspace/${instanceId}/${workflowId}/stop`,
method: 'post',
});
}
async function deleteMessageApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
messageId: string,
) {
await request({
url: `/api/workspace/${instanceId}/workflows/${workflowId}/messages/${messageId}`,
method: 'delete',
});
}
async function deleteFileFromMessageApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
messageId: string,
fileId: string,
) {
await request({
url: `/api/workspace/${instanceId}/workflows/${workflowId}/messages/${messageId}/files/${fileId}`,
method: 'delete',
});
}
// Workflow interface matching backend
export interface UserWorkflow {
id: string;
mandateId: string;
status: string;
name?: string;
workflowMode?: string;
[key: string]: any; // Allow additional properties
}
// Re-export AttributeDefinition from attributesApi
export type { AttributeDefinition } from '../api/attributesApi';
// Attribute option interface (from backend)
export interface AttributeOption {
value: string | number;
label: string;
}
// Pagination parameters
export interface PaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
}
/** Get apiBaseUrl for workflow APIs (mandate-scoped) */
export function getWorkflowApiBaseUrl(_instanceId: string | undefined, featureCode: string | undefined): string | undefined {
if (!featureCode) return undefined;
if (featureCode === 'workflowAutomation') return `/api/workflow-automation`;
return undefined;
}
// Workflows list hook - pass instanceId and featureCode when in feature context for feature-scoped API
export function useUserWorkflows(options?: { instanceId?: string; featureCode?: string }) {
const apiBaseUrl = getWorkflowApiBaseUrl(options?.instanceId, options?.featureCode);
const [workflows, setWorkflows] = useState<UserWorkflow[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, unknown>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const attrs = await fetchAttributesApi(request, 'ChatWorkflow');
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, [request]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'ChatWorkflow');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchWorkflowsData = useCallback(async (params?: PaginationParams) => {
try {
if (!apiBaseUrl || !_isValidApiBaseUrl(apiBaseUrl)) {
console.error('useUserWorkflows: apiBaseUrl is required (missing featureCode)');
return;
}
let listParams: { pagination?: Record<string, unknown> } | undefined = undefined;
if (params) {
const paginationObj: Record<string, unknown> = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
listParams = { pagination: paginationObj };
}
}
const data: unknown = await fetchWorkflowsFromApi(request, listParams ? { pagination: listParams.pagination } : undefined);
// Handle paginated response
if (data && typeof data === 'object' && data !== null && 'items' in data) {
const d = data as { items?: unknown; pagination?: unknown };
const items = Array.isArray(d.items) ? d.items : [];
// Map API response to our frontend model
const mappedWorkflows = items.map((apiWorkflow: any): UserWorkflow => {
return {
id: apiWorkflow.id,
mandateId: apiWorkflow.mandateId || '',
status: apiWorkflow.status || 'unknown',
name: apiWorkflow.name,
workflowMode: apiWorkflow.workflowMode,
...apiWorkflow // Include any additional properties
};
});
setWorkflows(mappedWorkflows);
if (d.pagination && typeof d.pagination === 'object') {
setPagination(d.pagination as any);
}
} else {
// Handle non-paginated response (backward compatibility)
const items = Array.isArray(data) ? data : [];
const mappedWorkflows = items.map((apiWorkflow: any): UserWorkflow => {
return {
id: apiWorkflow.id,
mandateId: apiWorkflow.mandateId || '',
status: apiWorkflow.status || 'unknown',
name: apiWorkflow.name,
workflowMode: apiWorkflow.workflowMode,
...apiWorkflow
};
});
setWorkflows(mappedWorkflows);
setPagination(null);
}
} catch (error: any) {
// Error is already handled by useApiRequest
setWorkflows([]);
setPagination(null);
}
}, [request, apiBaseUrl]);
// Optimistically remove a workflow from the local state
const removeOptimistically = (workflowId: string) => {
setWorkflows(prevWorkflows => prevWorkflows.filter(workflow => workflow.id !== workflowId));
};
// Optimistically update a workflow in the local state
const updateOptimistically = (workflowId: string, updateData: Partial<UserWorkflow>) => {
setWorkflows(prevWorkflows =>
prevWorkflows.map(workflow =>
workflow.id === workflowId
? { ...workflow, ...updateData }
: workflow
)
);
};
// Fetch a single workflow by ID
const fetchWorkflowById = useCallback(async (workflowId: string): Promise<UserWorkflow | null> => {
try {
if (!_isValidApiBaseUrl(apiBaseUrl)) return null;
const workflow = await fetchWorkflowFromApi(request, workflowId);
return workflow as unknown as UserWorkflow | null;
} catch (error: any) {
console.error('Error fetching workflow by ID:', error);
return null;
}
}, [request, apiBaseUrl]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string; // For options that need to be fetched (e.g., "user.role")
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
// Filter out non-editable fields based on readonly/editable flags
if (attr.readonly === true || attr.editable === false) {
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
// Map backend attribute type to form field type
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
// Map backend types to form field types
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => ({
value: opt.value,
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => ({
value: opt.value,
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
} else if (attr.type === 'text') {
fieldType = (attr as any).multiline === true ? 'textarea' : 'string';
}
// Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union
// If needed, they should be handled via type casting: (attr as any).type === 'boolean'
// Define validators and required fields
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
let minRows: number | undefined = undefined;
let maxRows: number | undefined = undefined;
if (attr.name === 'name') {
required = true;
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Workflow name cannot be empty';
}
if (typeof value === 'string' && value.length > 100) {
return 'Workflow name cannot exceed 100 characters';
}
return null;
};
} else if (fieldType === 'textarea') {
minRows = 4;
maxRows = 8;
}
// Multiselect validation
else if (fieldType === 'multiselect' && required) {
validator = (value: any[]) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return `${attr.label} is required`;
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
minRows,
maxRows,
options,
optionsReference
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded - can be called by EditActionButton
const ensureAttributesLoaded = useCallback(async () => {
// If attributes are already loaded, return them
if (attributes && attributes.length > 0) {
return attributes;
}
// Otherwise, fetch them and return the result
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
// Note: Do NOT fetch workflows here - let the table component control pagination
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Listen for workflow creation events to refetch workflows list
useEffect(() => {
const handleWorkflowCreated = (_event: CustomEvent<{ workflow: UserWorkflow }>) => {
// Refetch to ensure we have the latest data
fetchWorkflowsData();
};
window.addEventListener('workflowCreated', handleWorkflowCreated as EventListener);
return () => {
window.removeEventListener('workflowCreated', handleWorkflowCreated as EventListener);
};
}, [fetchWorkflowsData]);
return {
data: workflows,
loading,
error,
refetch: fetchWorkflowsData,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchWorkflowById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Workflow operations hook - pass instanceId and featureCode when in feature context for feature-scoped API
export function useWorkflowOperations(options?: { instanceId?: string; featureCode?: string }) {
const instanceId = options?.instanceId;
const [startingWorkflow, setStartingWorkflow] = useState(false);
const [stoppingWorkflows, setStoppingWorkflows] = useState<Set<string>>(new Set());
const [deletingWorkflows, setDeletingWorkflows] = useState<Set<string>>(new Set());
const [editingWorkflows, setEditingWorkflows] = useState<Set<string>>(new Set());
const [deletingMessages, setDeletingMessages] = useState<Set<string>>(new Set());
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [startError, setStartError] = useState<string | null>(null);
const [stopError, setStopError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const [deleteMessageError, setDeleteMessageError] = useState<string | null>(null);
const [deleteFileError, setDeleteFileError] = useState<string | null>(null);
// Workflow selection context - to clear selection if deleted workflow is selected
const { selectedWorkflowId, clearWorkflow } = useWorkflowSelection();
const { request } = useApiRequest();
// Generic delete operation handler
const handleDeleteOperation = async <T>(
operationKey: string,
setLoadingSet: React.Dispatch<React.SetStateAction<Set<string>>>,
setErrorState: React.Dispatch<React.SetStateAction<string | null>>,
operation: () => Promise<T>,
errorMessages: { default: string; notFound: string; forbidden: string }
): Promise<{ success: boolean; error?: string }> => {
setErrorState(null);
setLoadingSet(prev => new Set(prev).add(operationKey));
try {
await operation();
return { success: true };
} catch (error: any) {
let errorMessage = error.message || errorMessages.default;
if (error.response?.status === 404) {
errorMessage = errorMessages.notFound;
return { success: true };
} else if (error.response?.status === 403) {
errorMessage = errorMessages.forbidden;
}
setErrorState(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoadingSet(prev => {
const newSet = new Set(prev);
newSet.delete(operationKey);
return newSet;
});
}
};
const startWorkflow = async (
instanceId: string,
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
) => {
setStartError(null);
setStartingWorkflow(true);
try {
const response = await startWorkflowApi(request, instanceId, workflowData, options);
return { success: true, data: response };
} catch (error: any) {
const errorMessage = error.message || 'Failed to start workflow';
setStartError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setStartingWorkflow(false);
}
};
const stopWorkflow = async (instanceId: string, workflowId: string) => {
setStopError(null);
setStoppingWorkflows(prev => new Set(prev).add(workflowId));
try {
await stopWorkflowApi(request, instanceId, workflowId);
return { success: true };
} catch (error: any) {
const errorMessage = error.message || 'Failed to stop workflow';
setStopError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setStoppingWorkflows(prev => {
const newSet = new Set(prev);
newSet.delete(workflowId);
return newSet;
});
}
};
const handleWorkflowDelete = async (workflowId: string) => {
const result = await handleDeleteOperation(
workflowId,
setDeletingWorkflows,
setDeleteError,
() => {
return deleteWorkflowFromApi(request, workflowId);
},
{
default: 'Failed to delete workflow',
notFound: 'Workflow not found or has already been deleted.',
forbidden: 'No permission to delete this workflow.'
}
);
if (result.success) {
// Add a small delay to ensure backend has time to process
await new Promise(resolve => setTimeout(resolve, 300));
// Dispatch event to notify other components (e.g., dashboard dropdown)
window.dispatchEvent(new CustomEvent('workflowDeleted', {
detail: { workflowIds: [workflowId] }
}));
// Clear workflow selection if the deleted workflow was selected
if (selectedWorkflowId === workflowId) {
clearWorkflow();
}
}
return result.success;
};
const handleWorkflowDeleteMultiple = async (workflowIds: string[]) => {
setDeleteError(null);
setDeletingWorkflows(prev => {
const newSet = new Set(prev);
workflowIds.forEach(id => newSet.add(id));
return newSet;
});
try {
await _deleteWorkflowsSequential(request, workflowIds);
// Add a small delay to ensure backend has time to process
await new Promise(resolve => setTimeout(resolve, 300));
// Dispatch event to notify other components (e.g., dashboard dropdown)
window.dispatchEvent(new CustomEvent('workflowDeleted', {
detail: { workflowIds }
}));
// Clear workflow selection if the selected workflow was deleted
if (selectedWorkflowId && workflowIds.includes(selectedWorkflowId)) {
clearWorkflow();
}
return true;
} catch (error: any) {
console.error(`❌ Bulk delete failed:`, error);
setDeleteError(error.message || 'Bulk delete failed');
return false;
} finally {
setDeletingWorkflows(prev => {
const newSet = new Set(prev);
workflowIds.forEach(id => newSet.delete(id));
return newSet;
});
}
};
const deleteMessage = async (workflowId: string, messageId: string) => {
const operationKey = `${workflowId}:${messageId}`;
return handleDeleteOperation(
operationKey,
setDeletingMessages,
setDeleteMessageError,
() => {
if (!instanceId) throw new Error('instanceId required');
return deleteMessageApi(request, instanceId, workflowId, messageId);
},
{
default: 'Failed to delete message',
notFound: 'Message not found or has already been deleted.',
forbidden: 'No permission to delete this message.'
}
);
};
const deleteFileFromMessage = async (
workflowId: string,
messageId: string,
fileId: string
) => {
const operationKey = `${workflowId}:${messageId}:${fileId}`;
return handleDeleteOperation(
operationKey,
setDeletingFiles,
setDeleteFileError,
() => {
if (!instanceId) throw new Error('instanceId required');
return deleteFileFromMessageApi(request, instanceId, workflowId, messageId, fileId);
},
{
default: 'Failed to delete file',
notFound: 'File not found or has already been deleted.',
forbidden: 'No permission to delete this file.'
}
);
};
const handleWorkflowUpdate = async (workflowId: string, updateData: Partial<{ name: string; description?: string; tags?: string[] }>, _originalWorkflowData?: any) => {
setUpdateError(null);
setEditingWorkflows(prev => new Set(prev).add(workflowId));
try {
const updatedWorkflow = await updateWorkflowFromApi(request, workflowId, {
label: updateData.name,
});
return { success: true, workflowData: updatedWorkflow };
} catch (error: any) {
console.error(`Update failed for workflow ID ${workflowId}:`, error);
const errorMessage = error.response?.data?.message || error.message || 'Failed to update workflow';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
// Return detailed error information for proper handling
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
} finally {
setEditingWorkflows(prev => {
const newSet = new Set(prev);
newSet.delete(workflowId);
return newSet;
});
}
};
// Generic inline update handler for FormGeneratorTable
// Must merge changes with existing row data because backend requires full object
const handleInlineUpdate = async (workflowId: string, changes: Partial<UserWorkflow>, existingRow?: any) => {
if (!existingRow) {
throw new Error(`Existing row data required for inline update`);
}
// Merge changes with existing row data
const mergedData = {
name: existingRow.name,
...changes
};
const result = await handleWorkflowUpdate(workflowId, mergedData);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return {
// Loading states
startingWorkflow,
stoppingWorkflows,
deletingWorkflows,
editingWorkflows,
deletingMessages,
deletingFiles,
// Error states
startError,
stopError,
deleteError,
updateError,
deleteMessageError,
deleteFileError,
// Operations
startWorkflow,
stopWorkflow,
handleWorkflowDelete,
handleWorkflowDeleteMultiple,
handleWorkflowUpdate,
handleInlineUpdate,
deleteMessage,
deleteFileFromMessage
};
}

View file

@ -22,6 +22,8 @@
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
flex-shrink: 0;
width: 280px; width: 280px;
min-width: 280px; min-width: 280px;
height: 100%; height: 100%;
@ -31,13 +33,25 @@
z-index: 1200; z-index: 1200;
} }
.sidebarCollapsed {
overflow: visible;
}
/* Logo */ /* Logo */
.logoContainer { .logoContainer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem;
padding: 1.25rem 1rem; padding: 1.25rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0); border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.sidebarCollapsed .logoContainer {
flex-direction: column;
padding: 0.75rem 0.5rem;
gap: 0.375rem;
} }
.logoImage { .logoImage {
@ -46,6 +60,55 @@
object-fit: contain; object-fit: contain;
} }
.logoIcon {
height: 28px;
width: 28px;
object-fit: contain;
}
.collapseToggle {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
background: var(--bg-primary, #ffffff);
color: var(--text-secondary, #666);
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.collapseToggle:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
color: var(--text-primary, #1a1a1a);
}
.sidebarCollapsed .collapseToggle {
width: 2rem;
height: 2rem;
}
.resizeHandle {
position: absolute;
top: 0;
right: 0;
width: 4px;
height: 100%;
cursor: col-resize;
z-index: 2;
transition: background 0.15s ease;
}
.resizeHandle:hover,
.resizeHandle:active {
background: var(--primary-color, #2563eb);
opacity: 0.35;
}
.logoText { .logoText {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
@ -94,6 +157,7 @@
min-height: 0; min-height: 0;
position: relative; position: relative;
--mobile-topbar-height: 0px; --mobile-topbar-height: 0px;
--content-inset: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* Let child components handle their own scrolling for sticky headers */ /* Let child components handle their own scrolling for sticky headers */
@ -113,6 +177,8 @@
overflow-x: auto; overflow-x: auto;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding-left: var(--content-inset, 16px);
padding-right: var(--content-inset, 16px);
} }
/* scrollMode: document — .content becomes the scroll container, outletShell is transparent */ /* scrollMode: document — .content becomes the scroll container, outletShell is transparent */
@ -169,6 +235,17 @@
filter: brightness(0) invert(1); filter: brightness(0) invert(1);
} }
:global(.dark-theme) .collapseToggle {
border-color: var(--border-dark, #333);
background: var(--surface-dark, #1a1a1a);
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .collapseToggle:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .logoPower { :global(.dark-theme) .logoPower {
color: var(--text-primary-dark, #ffffff); color: var(--text-primary-dark, #ffffff);
} }
@ -224,13 +301,20 @@
top: 0; top: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 280px;
min-width: 280px;
height: 100dvh; height: 100dvh;
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.2s ease-in-out; transition: transform 0.25s ease;
box-shadow: 0 18px 32px rgba(0, 0, 0, 0.2); box-shadow: 0 18px 32px rgba(0, 0, 0, 0.2);
border-right: 1px solid var(--border-color, #e0e0e0); border-right: 1px solid var(--border-color, #e0e0e0);
} }
.collapseToggle,
.resizeHandle {
display: none;
}
@supports not (height: 100dvh) { @supports not (height: 100dvh) {
.sidebar { .sidebar {
height: 100vh; height: 100vh;
@ -259,6 +343,7 @@
.content { .content {
--mobile-topbar-height: 57px; --mobile-topbar-height: 57px;
--content-inset: 8px;
} }
.mobileBackdrop { .mobileBackdrop {

View file

@ -7,8 +7,9 @@
* Enthält den FeatureProvider für das Multi-Tenant-System. * Enthält den FeatureProvider für das Multi-Tenant-System.
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection'; import { UserSection } from '../components/Navigation/UserSection';
@ -17,17 +18,50 @@ import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes'
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types'; import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
import { isKeepAliveScoped } from '../types/keepAlive.types'; import { isKeepAliveScoped } from '../types/keepAlive.types';
import { useScrollMode } from '../hooks/useScrollMode'; import { useScrollMode } from '../hooks/useScrollMode';
import { SidebarContext } from './SidebarContext';
import styles from './MainLayout.module.css'; import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
const SIDEBAR_WIDTH_KEY = 'sidebar-width';
const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed';
const SIDEBAR_WIDTH_DEFAULT = 280;
const SIDEBAR_WIDTH_MIN = 180;
const SIDEBAR_WIDTH_MAX = 400;
const SIDEBAR_COLLAPSED_WIDTH = 60;
const DESKTOP_BREAKPOINT = 1024;
function _readSidebarWidth(): number {
try {
const value = parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY) ?? '', 10);
if (value >= SIDEBAR_WIDTH_MIN && value <= SIDEBAR_WIDTH_MAX) {
return value;
}
} catch {
/* ignore */
}
return SIDEBAR_WIDTH_DEFAULT;
}
function _readSidebarCollapsed(): boolean {
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
} catch {
return false;
}
}
function _isDesktopViewport(): boolean {
return window.innerWidth > DESKTOP_BREAKPOINT;
}
const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({ const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
display: isVisible ? 'flex' : 'none', display: isVisible ? 'flex' : 'none',
flexDirection: 'column', flexDirection: 'column',
position: 'absolute', position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)', top: 'var(--mobile-topbar-height, 0px)',
left: 0, left: 'var(--content-inset, 16px)',
right: 0, right: 'var(--content-inset, 16px)',
bottom: 0, bottom: 0,
...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}), ...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}),
}); });
@ -111,8 +145,42 @@ const MainLayoutInner: React.FC = () => {
const { loadFeatures, initialized, loading, error } = useFeatureStore(); const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation(); const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(_isDesktopViewport);
const [sidebarCollapsed, setSidebarCollapsed] = useState(_readSidebarCollapsed);
const [sidebarWidth, setSidebarWidth] = useState(_readSidebarWidth);
const resizingRef = useRef<{ startX: number; startW: number } | null>(null);
const sidebarRef = useRef<HTMLElement>(null);
const animFrameRef = useRef<number>(0);
const hideOutletShell = hideFeatureOutlet(location.pathname, location.search); const hideOutletShell = hideFeatureOutlet(location.pathname, location.search);
const effectiveCollapsed = isDesktop && sidebarCollapsed;
const effectiveSidebarWidth = effectiveCollapsed ? SIDEBAR_COLLAPSED_WIDTH : sidebarWidth;
const toggleSidebarCollapsed = useCallback(() => {
setSidebarCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}, []);
const sidebarContextValue = useMemo(
() => ({ collapsed: effectiveCollapsed }),
[effectiveCollapsed],
);
const sidebarStyle = useMemo(
() => ({
width: `${effectiveSidebarWidth}px`,
minWidth: `${effectiveSidebarWidth}px`,
}),
[effectiveSidebarWidth],
);
// Features laden beim Mount // Features laden beim Mount
useEffect(() => { useEffect(() => {
if (!initialized && !loading) { if (!initialized && !loading) {
@ -126,7 +194,9 @@ const MainLayoutInner: React.FC = () => {
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (window.innerWidth > 1024) { const desktop = _isDesktopViewport();
setIsDesktop(desktop);
if (desktop) {
setIsMobileSidebarOpen(false); setIsMobileSidebarOpen(false);
} }
}; };
@ -135,65 +205,170 @@ const MainLayoutInner: React.FC = () => {
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isDesktop) {
return;
}
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
event.preventDefault();
toggleSidebarCollapsed();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isDesktop, toggleSidebarCollapsed]);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (!resizingRef.current) {
return;
}
const { startX, startW } = resizingRef.current;
const nextWidth = Math.max(
SIDEBAR_WIDTH_MIN,
Math.min(SIDEBAR_WIDTH_MAX, startW + (event.clientX - startX)),
);
cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(() => {
const el = sidebarRef.current;
if (el) {
el.style.width = `${nextWidth}px`;
el.style.minWidth = `${nextWidth}px`;
}
});
};
const handleMouseUp = (event: MouseEvent) => {
if (!resizingRef.current) {
return;
}
cancelAnimationFrame(animFrameRef.current);
const { startX, startW } = resizingRef.current;
const finalWidth = Math.max(
SIDEBAR_WIDTH_MIN,
Math.min(SIDEBAR_WIDTH_MAX, startW + (event.clientX - startX)),
);
resizingRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
setSidebarWidth(finalWidth);
try {
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(finalWidth));
} catch {
/* ignore */
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, []);
const handleResizeStart = useCallback((event: React.MouseEvent) => {
if (!isDesktop || effectiveCollapsed) {
return;
}
event.preventDefault();
resizingRef.current = { startX: event.clientX, startW: sidebarWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [effectiveCollapsed, isDesktop, sidebarWidth]);
return ( return (
<div className={styles.mainLayout}> <SidebarContext.Provider value={sidebarContextValue}>
{isMobileSidebarOpen && ( <div className={styles.mainLayout}>
<button {isMobileSidebarOpen && (
className={styles.mobileBackdrop}
onClick={() => setIsMobileSidebarOpen(false)}
aria-label={t('Navigation schließen')}
/>
)}
{/* Sidebar */}
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
<div className={styles.logoContainer}>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.logoImage} />
</div>
<nav className={styles.navigation}>
{loading && <div className={styles.loadingNav}>{t('Lade Navigation…')}</div>}
{error && (
<div className={styles.errorNav}>
{t('Fehler')}: {error}
</div>
)}
{initialized && !loading && <MandateNavigation />}
</nav>
{/* User-Bereich am unteren Rand */}
<UserSection />
</aside>
{/* Content */}
<main className={styles.content} data-scroll-mode={scrollMode}>
<div className={styles.mobileTopBar}>
<button <button
className={styles.mobileMenuButton} className={styles.mobileBackdrop}
onClick={() => setIsMobileSidebarOpen(true)} onClick={() => setIsMobileSidebarOpen(false)}
aria-label={t('Navigation öffnen')} aria-label={t('Navigation schließen')}
> />
)}
</button>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.mobileLogo} />
</div>
{KEEP_ALIVE_ROUTES.map((routeEntry) => ( {/* Sidebar */}
<RoutedKeepAliveSlot key={routeEntry.id} entry={routeEntry} pathname={location.pathname} search={location.search} /> <aside
))} ref={sidebarRef}
className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''} ${effectiveCollapsed ? styles.sidebarCollapsed : ''}`}
<div style={isDesktop ? sidebarStyle : undefined}
className={styles.outletShell}
style={{ display: hideOutletShell ? 'none' : undefined }}
> >
<Outlet /> <div className={styles.logoContainer}>
</div> <img
</main> src={effectiveCollapsed ? '/favicon.png' : '/logos/poweron-logo.png'}
alt="PowerOn"
className={effectiveCollapsed ? styles.logoIcon : styles.logoImage}
/>
{isDesktop && (
<button
type="button"
className={styles.collapseToggle}
onClick={toggleSidebarCollapsed}
aria-label={effectiveCollapsed ? t('Sidebar erweitern') : t('Sidebar einklappen')}
title={effectiveCollapsed ? t('Sidebar erweitern (Strg+B)') : t('Sidebar einklappen (Strg+B)')}
>
{effectiveCollapsed ? <FaChevronRight size={12} /> : <FaChevronLeft size={12} />}
</button>
)}
</div>
<RagRunningBadge /> <nav className={styles.navigation}>
</div> {loading && <div className={styles.loadingNav}>{t('Lade Navigation…')}</div>}
{error && (
<div className={styles.errorNav}>
{t('Fehler')}: {error}
</div>
)}
{initialized && !loading && <MandateNavigation />}
</nav>
{/* User-Bereich am unteren Rand */}
<UserSection />
{isDesktop && !effectiveCollapsed && (
<div
className={styles.resizeHandle}
onMouseDown={handleResizeStart}
role="separator"
aria-orientation="vertical"
aria-label={t('Sidebar-Breite anpassen')}
/>
)}
</aside>
{/* Content */}
<main className={styles.content} data-scroll-mode={scrollMode}>
<div className={styles.mobileTopBar}>
<button
className={styles.mobileMenuButton}
onClick={() => setIsMobileSidebarOpen(true)}
aria-label={t('Navigation öffnen')}
>
</button>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.mobileLogo} />
</div>
{KEEP_ALIVE_ROUTES.map((routeEntry) => (
<RoutedKeepAliveSlot key={routeEntry.id} entry={routeEntry} pathname={location.pathname} search={location.search} />
))}
<div
className={styles.outletShell}
style={{ display: hideOutletShell ? 'none' : undefined }}
>
<Outlet />
</div>
</main>
<RagRunningBadge />
</div>
</SidebarContext.Provider>
); );
}; };

View file

@ -0,0 +1,14 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { createContext, useContext } from 'react';
export interface SidebarContextValue {
collapsed: boolean;
}
export const SidebarContext = createContext<SidebarContextValue>({ collapsed: false });
export function useSidebar(): SidebarContextValue {
return useContext(SidebarContext);
}

View file

@ -10,11 +10,12 @@
*/ */
import React, { useState, useCallback, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { import {
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, BarChart, Bar, PieChart, Pie, Cell, Tooltip, BarChart, Bar, PieChart, Pie, Cell,
} from 'recharts'; } from 'recharts';
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa'; import { FaDownload, FaEye, FaTrash } from 'react-icons/fa';
import api from '../api'; import api from '../api';
import { useApiRequest } from '../hooks/useApi'; import { useApiRequest } from '../hooks/useApi';
import { fetchAttributes } from '../api/attributesApi'; import { fetchAttributes } from '../api/attributesApi';
@ -29,6 +30,11 @@ import {
resolvePeriod, resolvePeriod,
type PeriodValue, type PeriodValue,
} from '../components/PeriodPicker'; } from '../components/PeriodPicker';
import { StackLayout } from '../components/Layout/StackLayout';
import { Panel } from '../components/Layout/Panel';
import { LayoutTabs } from '../components/Layout/LayoutTabs';
import type { LayoutTabItem } from '../components/Layout/types';
import ViewStack from '../components/Layout/ViewStack';
import styles from './ComplianceAuditPage.module.css'; import styles from './ComplianceAuditPage.module.css';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
@ -130,7 +136,7 @@ interface Mandate {
label?: string; label?: string;
} }
interface ContentModalData { interface ContentDetailData {
row: any; row: any;
contentInputFull?: string; contentInputFull?: string;
contentOutputFull?: string; contentOutputFull?: string;
@ -146,6 +152,7 @@ const _NEUT_PAGE_SIZE = 100;
export const ComplianceAuditPage: React.FC = () => { export const ComplianceAuditPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { request } = useApiRequest(); const { request } = useApiRequest();
const [searchParams, setSearchParams] = useSearchParams();
const [aiAuditAttrs, setAiAuditAttrs] = useState<AttributeDefinition[]>([]); const [aiAuditAttrs, setAiAuditAttrs] = useState<AttributeDefinition[]>([]);
const [auditLogAttrs, setAuditLogAttrs] = useState<AttributeDefinition[]>([]); const [auditLogAttrs, setAuditLogAttrs] = useState<AttributeDefinition[]>([]);
const [neutAttrs, setNeutAttrs] = useState<AttributeDefinition[]>([]); const [neutAttrs, setNeutAttrs] = useState<AttributeDefinition[]>([]);
@ -161,7 +168,7 @@ export const ComplianceAuditPage: React.FC = () => {
const [mandates, setMandates] = useState<Mandate[]>([]); const [mandates, setMandates] = useState<Mandate[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true); const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null); const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('audit-log'); const activeTab = (searchParams.get('tab') as TabId) || 'audit-log';
// ── Tab A: AI-Log state ── // ── Tab A: AI-Log state ──
const [aiEntries, setAiEntries] = useState<any[]>([]); const [aiEntries, setAiEntries] = useState<any[]>([]);
@ -186,10 +193,7 @@ export const ComplianceAuditPage: React.FC = () => {
const [neutPagination, setNeutPagination] = useState<any>(undefined); const [neutPagination, setNeutPagination] = useState<any>(undefined);
const [neutLoading, setNeutLoading] = useState(false); const [neutLoading, setNeutLoading] = useState(false);
// ── Content View Modal state ── const selectedEntryId = searchParams.get('entryId');
const [contentModal, setContentModal] = useState<ContentModalData | null>(null);
const [contentModalLoading, setContentModalLoading] = useState(false);
const [contentModalTab, setContentModalTab] = useState<'input' | 'output'>('input');
// ── Mandate loader ── // ── Mandate loader ──
@ -373,29 +377,14 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Content view handler (modal) ── // ── Content view handler (modal) ──
const _handleContentView = useCallback(async (row: any) => { const _handleContentView = useCallback((row: any) => {
if (!selectedMandateId || !row?.id) return; if (!selectedMandateId || !row?.id) return;
setContentModalLoading(true); setSearchParams((prev) => {
setContentModalTab('input'); const next = new URLSearchParams(prev);
setContentModal({ row, neutralizationMappings: [] }); next.set('entryId', row.id);
try { return next;
const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, { }, { replace: true });
headers: _mandateHeaders(), }, [selectedMandateId, setSearchParams]);
});
setContentModal({
row,
contentInputFull: data?.contentInputFull,
contentOutputFull: data?.contentOutputFull,
contentInputPreview: data?.contentInputPreview,
contentOutputPreview: data?.contentOutputPreview,
neutralizationMappings: data?.neutralizationMappings ?? [],
});
} catch (err) {
console.error('Content load failed:', err);
} finally {
setContentModalLoading(false);
}
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Content download handler ── // ── Content download handler ──
@ -435,17 +424,107 @@ export const ComplianceAuditPage: React.FC = () => {
} }
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Mapping lookup for modal ── const _AiLogDetailView: React.FC = () => {
const [contentTab, setContentTab] = useState<'input' | 'output'>('input');
const [detail, setDetail] = useState<ContentDetailData | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const _modalMappingLookup = useMemo(() => { useEffect(() => {
const map = new Map<string, NeutMapping>(); if (!selectedMandateId || !selectedEntryId) return;
if (contentModal?.neutralizationMappings) { let cancelled = false;
for (const m of contentModal.neutralizationMappings) { (async () => {
map.set(m.id, m); setDetailLoading(true);
try {
const { data } = await api.get(`/api/audit/ai-log/${selectedEntryId}/content`, {
headers: _mandateHeaders(),
});
if (!cancelled) {
setDetail({
row: { id: selectedEntryId },
contentInputFull: data?.contentInputFull,
contentOutputFull: data?.contentOutputFull,
contentInputPreview: data?.contentInputPreview,
contentOutputPreview: data?.contentOutputPreview,
neutralizationMappings: data?.neutralizationMappings ?? [],
});
}
} catch (err) {
console.error('Content load failed:', err);
} finally {
if (!cancelled) setDetailLoading(false);
}
})();
return () => { cancelled = true; };
}, [selectedEntryId, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
const mappingLookup = useMemo(() => {
const map = new Map<string, NeutMapping>();
if (detail?.neutralizationMappings) {
for (const m of detail.neutralizationMappings) {
map.set(m.id, m);
}
} }
} return map;
return map; }, [detail?.neutralizationMappings]);
}, [contentModal?.neutralizationMappings]);
return (
<Panel variant="editor" title={t('AI-Audit Inhalt')}>
{detail?.neutralizationMappings && detail.neutralizationMappings.length > 0 && (
<div className={styles.modalMappingBar}>
<span className={styles.modalMappingLabel}>
{t('{n} Platzhalter aufgelöst', { n: String(detail.neutralizationMappings.length) })}
</span>
<span className={styles.modalMappingHint}>
{t('Hover über markierte Platzhalter für Originaltext')}
</span>
</div>
)}
<div className={styles.modalTabBar}>
<button
type="button"
className={`${styles.modalTab} ${contentTab === 'input' ? styles.modalTabActive : ''}`}
onClick={() => setContentTab('input')}
>
{t('Input')}
</button>
<button
type="button"
className={`${styles.modalTab} ${contentTab === 'output' ? styles.modalTabActive : ''}`}
onClick={() => setContentTab('output')}
>
{t('Output')}
</button>
</div>
<div className={styles.modalBody}>
{detailLoading ? (
<p className={styles.loadingText}>{t('Lade Inhalt…')}</p>
) : (
<div className={styles.modalTextContent}>
{contentTab === 'input' ? (
(() => {
const text = detail?.contentInputFull
|| detail?.contentInputPreview
|| t('(kein Input gespeichert)');
return mappingLookup.size > 0
? _renderHighlightedText(text, mappingLookup)
: text;
})()
) : (
(() => {
const text = detail?.contentOutputFull
|| detail?.contentOutputPreview
|| t('(kein Output gespeichert)');
return mappingLookup.size > 0
? _renderHighlightedText(text, mappingLookup)
: text;
})()
)}
</div>
)}
</div>
</Panel>
);
};
// ── Column definitions ── // ── Column definitions ──
@ -623,372 +702,337 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Render ── // ── Render ──
const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats']; const auditTabs: LayoutTabItem[] = useMemo(() => {
if (!selectedMandateId) return [];
return ( const statsPanel = (
<div className={styles.wrap}> <Panel variant="dashboard">
<h2 className={styles.pageTitle}>{t('Compliance & AI-Audit')}</h2> <div className={styles.statsControls}>
<p className={styles.pageDesc}> <PeriodPicker
{t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')} value={statsPeriod}
</p> onChange={(next) => {
setStatsPeriod(next);
{/* Mandate selector */} void _loadStats({ dateFrom: next.fromDate, dateTo: next.toDate });
<div className={styles.mandateSelector}> }}
<label className={styles.mandateLabel}>{t('Mandant auswählen')}</label> direction="past"
<select defaultPreset={_DEFAULT_STATS_PRESET}
className={styles.mandateSelect} enabledPresets={[
value={selectedMandateId || ''} 'lastN', 'last12Months', 'lastYear',
onChange={e => setSelectedMandateId(e.target.value || null)} 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
disabled={mandatesLoading} 'ytd', 'custom',
>
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>{mandateDisplayLabel(m)}</option>
))}
</select>
</div>
{!selectedMandateId ? (
<p className={styles.emptyText}>{t('Bitte wählen Sie einen Mandanten aus.')}</p>
) : (
<>
{/* Tab bar */}
<div className={styles.tabBar}>
{_tabs.map(tab => (
<button
key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)}
>
{_tabLabel(tab, t)}
</button>
))}
</div>
{/* ── Tab A: AI Data-Flow Log ── */}
{activeTab === 'ai-log' && (
<div className={styles.tabContent}>
<FormGeneratorTable
key={`ai-log-${selectedMandateId}`}
data={aiEntries}
columns={aiLogColumns}
loading={aiLoading}
pagination={true}
pageSize={_AI_LOG_PAGE_SIZE}
sortable={true}
filterable={true}
searchable={true}
selectable={false}
emptyMessage={t('Keine AI-Audit-Einträge vorhanden.')}
onRefresh={_loadAiLog}
hookData={aiLogHookData}
customActions={[
{
id: 'viewContent',
title: t('Input/Output anzeigen'),
icon: <FaEye />,
onClick: _handleContentView,
},
{
id: 'downloadContent',
title: t('Input/Output herunterladen'),
icon: <FaDownload />,
onClick: _handleContentDownload,
},
]} ]}
/> />
</div> </div>
)}
{/* ── Tab B: Audit Log ── */} {statsLoading ? (
{activeTab === 'audit-log' && ( <p className={styles.loadingText}>{t('Lade Statistiken…')}</p>
<div className={styles.tabContent}> ) : !stats ? (
<FormGeneratorTable <p className={styles.emptyText}>{t('Keine Daten verfügbar.')}</p>
key={`audit-log-${selectedMandateId}`} ) : (
data={auditEntries} <>
columns={auditLogColumns} <div className={styles.kpiGrid}>
loading={auditLoading} <div className={styles.kpiCard}>
pagination={true} <p className={styles.kpiValue}>{stats.totalCalls}</p>
pageSize={_AUDIT_LOG_PAGE_SIZE} <p className={styles.kpiLabel}>{t('AI-Aufrufe')}</p>
sortable={true}
filterable={true}
searchable={true}
selectable={false}
emptyMessage={t('Keine Audit-Einträge vorhanden.')}
onRefresh={_loadAuditLog}
hookData={auditLogHookData}
/>
</div>
)}
{/* ── Tab C: Statistics ── */}
{activeTab === 'stats' && (
<div className={styles.tabContentScrollable}>
<div className={styles.statsControls}>
<PeriodPicker
value={statsPeriod}
onChange={(next) => {
setStatsPeriod(next);
void _loadStats({ dateFrom: next.fromDate, dateTo: next.toDate });
}}
direction="past"
defaultPreset={_DEFAULT_STATS_PRESET}
enabledPresets={[
'lastN', 'last12Months', 'lastYear',
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
'ytd', 'custom',
]}
/>
</div>
{statsLoading ? (
<p className={styles.loadingText}>{t('Lade Statistiken…')}</p>
) : !stats ? (
<p className={styles.emptyText}>{t('Keine Daten verfügbar.')}</p>
) : (
<>
{/* KPIs */}
<div className={styles.kpiGrid}>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{stats.totalCalls}</p>
<p className={styles.kpiLabel}>{t('AI-Aufrufe')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{stats.neutralizationPercent}%</p>
<p className={styles.kpiLabel}>{t('Neutralisierungsquote')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{Object.keys(stats.callsByModel).length}</p>
<p className={styles.kpiLabel}>{t('Genutzte Modelle')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>
{stats.costPerDay.reduce((s, d) => s + d.cost, 0).toFixed(2)}
</p>
<p className={styles.kpiLabel}>{t('Gesamtkosten (CHF)')}</p>
</div>
</div> </div>
<div className={styles.kpiCard}>
{/* Charts row 1: Calls/Day + Cost/Day */} <p className={styles.kpiValue}>{stats.neutralizationPercent}%</p>
<div className={styles.chartRow}> <p className={styles.kpiLabel}>{t('Neutralisierungsquote')}</p>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('AI-Aufrufe pro Tag')}</h3>
{stats.callsPerDay.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={stats.callsPerDay}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
<Tooltip />
<Line type="monotone" dataKey="calls" name={t('Aufrufe')} stroke="#1976d2" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Kosten-Verlauf (CHF)')}</h3>
{stats.costPerDay.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={stats.costPerDay}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Line type="monotone" dataKey="cost" name={t('CHF')} stroke="#e65100" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
</div> </div>
<div className={styles.kpiCard}>
{/* Charts row 2: By Model (pie) + By Feature (bar) */} <p className={styles.kpiValue}>{Object.keys(stats.callsByModel).length}</p>
<div className={styles.chartRow}> <p className={styles.kpiLabel}>{t('Genutzte Modelle')}</p>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('AI-Aufrufe nach Modell')}</h3>
{Object.keys(stats.callsByModel).length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={Object.entries(stats.callsByModel).map(([name, value]) => ({ name, value }))}
dataKey="value" nameKey="name"
cx="50%" cy="50%" outerRadius={80}
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
>
{Object.keys(stats.callsByModel).map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('AI-Aufrufe nach Feature')}</h3>
{Object.keys(stats.callsByFeature).length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={Object.entries(stats.callsByFeature).map(([name, value]) => ({ name, value }))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="value" name={t('Aufrufe')} fill="#00897b" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div> </div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>
{stats.costPerDay.reduce((s, d) => s + d.cost, 0).toFixed(2)}
</p>
<p className={styles.kpiLabel}>{t('Gesamtkosten (CHF)')}</p>
</div>
</div>
{/* Top Users */} <div className={styles.chartRow}>
{Object.keys(stats.topUsers).length > 0 && ( <div className={styles.chartBlock}>
<div className={styles.chartBlock}> <h3 className={styles.chartTitle}>{t('AI-Aufrufe pro Tag')}</h3>
<h3 className={styles.chartTitle}>{t('Top-Nutzer nach AI-Aufrufen')}</h3> {stats.callsPerDay.length === 0 ? (
<ResponsiveContainer width="100%" height={200}> <p className={styles.meta}>{t('Keine Daten')}</p>
<BarChart ) : (
data={Object.entries(stats.topUsers).map(([name, value]) => ({ name, value }))} <ResponsiveContainer width="100%" height={220}>
layout="vertical" margin={{ left: 8, right: 16 }} <LineChart data={stats.callsPerDay}>
>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} /> <XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" width={140} tick={{ fontSize: 10 }} /> <YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
<Tooltip /> <Tooltip />
<Bar dataKey="value" name={t('Aufrufe')} fill="#6a1b9a" radius={[0, 4, 4, 0]} /> <Line type="monotone" dataKey="calls" name={t('Aufrufe')} stroke="#1976d2" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Kosten-Verlauf (CHF)')}</h3>
{stats.costPerDay.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={stats.costPerDay}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Line type="monotone" dataKey="cost" name={t('CHF')} stroke="#e65100" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className={styles.chartRow}>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('AI-Aufrufe nach Modell')}</h3>
{Object.keys(stats.callsByModel).length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={Object.entries(stats.callsByModel).map(([name, value]) => ({ name, value }))}
dataKey="value" nameKey="name"
cx="50%" cy="50%" outerRadius={80}
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
>
{Object.keys(stats.callsByModel).map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('AI-Aufrufe nach Feature')}</h3>
{Object.keys(stats.callsByFeature).length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={Object.entries(stats.callsByFeature).map(([name, value]) => ({ name, value }))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="value" name={t('Aufrufe')} fill="#00897b" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> )}
)}
</>
)}
</div>
)}
{/* ── Tab D: Neutralization Mappings ── */}
{activeTab === 'neutralization' && (
<div className={styles.tabContent}>
<FormGeneratorTable
key={`neut-${selectedMandateId}`}
data={neutEntries}
columns={neutColumns}
loading={neutLoading}
pagination={true}
pageSize={_NEUT_PAGE_SIZE}
sortable={true}
filterable={true}
searchable={true}
selectable={true}
emptyMessage={t('Keine Neutralisierungs-Zuordnungen vorhanden.')}
onRefresh={_loadNeutMappings}
hookData={neutHookData}
batchActions={[
{
label: t('Ausgewählte löschen'),
onClick: _handleDeleteMappingsBatch,
},
]}
customActions={[
{
id: 'deleteMapping',
title: t('Zuordnung löschen'),
icon: <FaTrash />,
onClick: _handleDeleteMapping,
},
]}
/>
</div>
)}
</>
)}
{/* ── Content View Modal ── */}
{contentModal && (
<div className={styles.modalOverlay}>
<div className={styles.modalContainer}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3>
<div className={styles.modalMeta}>
{contentModal.row?.username || contentModal.row?.userId?.slice(0, 8) || ''}
{' · '}
{contentModal.row?.aiModel || ''}
{' · '}
{contentModal.row?.timestamp
? new Date(contentModal.row.timestamp * 1000).toLocaleString()
: ''}
</div> </div>
<button
className={styles.modalClose}
onClick={() => setContentModal(null)}
title={t('Schliessen')}
>
<FaTimes />
</button>
</div> </div>
{contentModal.neutralizationMappings.length > 0 && ( {Object.keys(stats.topUsers).length > 0 && (
<div className={styles.modalMappingBar}> <div className={styles.chartBlock}>
<span className={styles.modalMappingLabel}> <h3 className={styles.chartTitle}>{t('Top-Nutzer nach AI-Aufrufen')}</h3>
{t('{n} Platzhalter aufgelöst', { n: String(contentModal.neutralizationMappings.length) })} <ResponsiveContainer width="100%" height={200}>
</span> <BarChart
<span className={styles.modalMappingHint}> data={Object.entries(stats.topUsers).map(([name, value]) => ({ name, value }))}
{t('Hover über markierte Platzhalter für Originaltext')} layout="vertical" margin={{ left: 8, right: 16 }}
</span> >
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} />
<YAxis type="category" dataKey="name" width={140} tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="value" name={t('Aufrufe')} fill="#6a1b9a" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div> </div>
)} )}
</>
)}
</Panel>
);
<div className={styles.modalTabBar}> return [
<button {
className={`${styles.modalTab} ${contentModalTab === 'input' ? styles.modalTabActive : ''}`} id: 'audit-log',
onClick={() => setContentModalTab('input')} label: _tabLabel('audit-log', t),
> render: () => (
{t('Input')} <Panel variant="table">
</button> <FormGeneratorTable
<button key={`audit-log-${selectedMandateId}`}
className={`${styles.modalTab} ${contentModalTab === 'output' ? styles.modalTabActive : ''}`} data={auditEntries}
onClick={() => setContentModalTab('output')} columns={auditLogColumns}
> loading={auditLoading}
{t('Output')} pagination={true}
</button> pageSize={_AUDIT_LOG_PAGE_SIZE}
</div> sortable={true}
filterable={true}
searchable={true}
selectable={false}
emptyMessage={t('Keine Audit-Einträge vorhanden.')}
onRefresh={_loadAuditLog}
hookData={auditLogHookData}
/>
</Panel>
),
},
{
id: 'ai-log',
label: _tabLabel('ai-log', t),
render: () => (
<ViewStack entityParam="entryId">
<ViewStack.View id="list">
<Panel variant="table">
<FormGeneratorTable
key={`ai-log-${selectedMandateId}`}
data={aiEntries}
columns={aiLogColumns}
loading={aiLoading}
pagination={true}
pageSize={_AI_LOG_PAGE_SIZE}
sortable={true}
filterable={true}
searchable={true}
selectable={false}
emptyMessage={t('Keine AI-Audit-Einträge vorhanden.')}
onRefresh={_loadAiLog}
hookData={aiLogHookData}
customActions={[
{
id: 'viewContent',
title: t('Input/Output anzeigen'),
icon: <FaEye />,
onClick: _handleContentView,
},
{
id: 'downloadContent',
title: t('Input/Output herunterladen'),
icon: <FaDownload />,
onClick: _handleContentDownload,
},
]}
/>
</Panel>
</ViewStack.View>
<ViewStack.View
id="detail"
backLabel={t('Zurück zum AI-Datenfluss')}
title={t('AI-Audit Inhalt')}
>
<_AiLogDetailView />
</ViewStack.View>
</ViewStack>
),
},
{
id: 'neutralization',
label: _tabLabel('neutralization', t),
render: () => (
<Panel variant="table">
<FormGeneratorTable
key={`neut-${selectedMandateId}`}
data={neutEntries}
columns={neutColumns}
loading={neutLoading}
pagination={true}
pageSize={_NEUT_PAGE_SIZE}
sortable={true}
filterable={true}
searchable={true}
selectable={true}
emptyMessage={t('Keine Neutralisierungs-Zuordnungen vorhanden.')}
onRefresh={_loadNeutMappings}
hookData={neutHookData}
batchActions={[
{
label: t('Ausgewählte löschen'),
onClick: _handleDeleteMappingsBatch,
},
]}
customActions={[
{
id: 'deleteMapping',
title: t('Zuordnung löschen'),
icon: <FaTrash />,
onClick: _handleDeleteMapping,
},
]}
/>
</Panel>
),
},
{
id: 'stats',
label: _tabLabel('stats', t),
render: () => statsPanel,
},
];
}, [
t,
selectedMandateId,
auditEntries,
auditLogColumns,
auditLoading,
auditLogHookData,
_loadAuditLog,
aiEntries,
aiLogColumns,
aiLoading,
aiLogHookData,
_loadAiLog,
_handleContentView,
_handleContentDownload,
neutEntries,
neutColumns,
neutLoading,
neutHookData,
_loadNeutMappings,
_handleDeleteMapping,
_handleDeleteMappingsBatch,
statsPeriod,
statsLoading,
stats,
_loadStats,
]);
<div className={styles.modalBody}> return (
{contentModalLoading ? ( <StackLayout variant="table">
<p className={styles.loadingText}>{t('Lade Inhalt…')}</p> <StackLayout.Header>
) : ( <h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Compliance & AI-Audit')}</h1>
<div className={styles.modalTextContent}> <p className={styles.pageDesc}>
{contentModalTab === 'input' ? ( {t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')}
(() => { </p>
const text = contentModal.contentInputFull </StackLayout.Header>
|| contentModal.contentInputPreview <StackLayout.Body>
|| t('(kein Input gespeichert)'); <Panel variant="toolbar">
return _modalMappingLookup.size > 0 <div className={styles.mandateSelector}>
? _renderHighlightedText(text, _modalMappingLookup) <label className={styles.mandateLabel}>{t('Mandant auswählen')}</label>
: text; <select
})() className={styles.mandateSelect}
) : ( value={selectedMandateId || ''}
(() => { onChange={e => setSelectedMandateId(e.target.value || null)}
const text = contentModal.contentOutputFull disabled={mandatesLoading}
|| contentModal.contentOutputPreview >
|| t('(kein Output gespeichert)'); <option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
return _modalMappingLookup.size > 0 {mandates.map(m => (
? _renderHighlightedText(text, _modalMappingLookup) <option key={m.id} value={m.id}>{mandateDisplayLabel(m)}</option>
: text; ))}
})() </select>
)}
</div>
)}
</div>
</div> </div>
</div> </Panel>
)}
{!selectedMandateId ? (
<Panel variant="card">
<p className={styles.emptyText}>{t('Bitte wählen Sie einen Mandanten aus.')}</p>
</Panel>
) : (
<LayoutTabs
items={auditTabs}
urlParam="tab"
defaultTab="audit-log"
preserveSearchParams
lazy
/>
)}
</StackLayout.Body>
<ConfirmDialog /> <ConfirmDialog />
</div> </StackLayout>
); );
}; };

View file

@ -15,23 +15,18 @@ import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureIn
import { getPageIcon } from '../config/pageRegistry'; import { getPageIcon } from '../config/pageRegistry';
import { FaArrowRight, FaBuilding } from 'react-icons/fa'; import { FaArrowRight, FaBuilding } from 'react-icons/fa';
import OnboardingAssistant from '../components/OnboardingAssistant'; import OnboardingAssistant from '../components/OnboardingAssistant';
import { StackLayout } from '../components/Layout/StackLayout';
import { Panel } from '../components/Layout/Panel';
import styles from './Dashboard.module.css'; import styles from './Dashboard.module.css';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
// =============================================================================
// INSTANCE CARD
// =============================================================================
interface InstanceCardProps { interface InstanceCardProps {
instance: NavFeatureInstance; instance: NavFeatureInstance;
feature: MandateFeature; feature: MandateFeature;
} }
const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => { const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
// Ersten verfügbaren View-Pfad vom Backend nehmen
const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined; const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined;
if (!targetPath) return null; if (!targetPath) return null;
return ( return (
@ -52,39 +47,45 @@ const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
); );
}; };
// =============================================================================
// DASHBOARD PAGE
// =============================================================================
export const DashboardPage: React.FC = () => { export const DashboardPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { dynamicBlock, loading } = useNavigation(); const { dynamicBlock, loading } = useNavigation();
// Alle Mandate und deren Features/Instanzen aus der Navigation
const mandates: NavigationMandate[] = dynamicBlock?.mandates || []; const mandates: NavigationMandate[] = dynamicBlock?.mandates || [];
// Gesamtzahl Instanzen und Mandate berechnen
let totalInstances = 0; let totalInstances = 0;
const totalMandates = mandates.length; const totalMandates = mandates.length;
mandates.forEach(m => m.features.forEach(f => { mandates.forEach(m => m.features.forEach(f => {
totalInstances += f.instances.length; totalInstances += f.instances.length;
})); }));
const mandateSections = mandates
.filter(mandate => mandate.features.some(f => f.instances.length > 0))
.map(mandate => {
const mandateInstances: { instance: NavFeatureInstance; feature: MandateFeature }[] = [];
for (const feature of mandate.features) {
for (const instance of feature.instances) {
mandateInstances.push({ instance, feature });
}
}
return { mandate, mandateInstances };
});
if (loading) { if (loading) {
return ( return (
<div className={styles.dashboard}> <StackLayout variant="dashboard">
<header className={styles.header}> <StackLayout.Header>
<h1>{t('Übersicht')}</h1> <h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Übersicht')}</h1>
<p className={styles.subtitle}>{t('Lade')}</p> <p className={styles.subtitle}>{t('Lade')}</p>
</header> </StackLayout.Header>
</div> </StackLayout>
); );
} }
return ( return (
<div className={styles.dashboard}> <StackLayout variant="dashboard">
<header className={styles.header}> <StackLayout.Header>
<h1>{t('Übersicht')}</h1> <h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Übersicht')}</h1>
{totalInstances > 0 && ( {totalInstances > 0 && (
<p className={styles.subtitle}> <p className={styles.subtitle}>
{t('{instanceCount} Feature-Instanzen in {mandateCount} Mandanten', { {t('{instanceCount} Feature-Instanzen in {mandateCount} Mandanten', {
@ -93,42 +94,36 @@ export const DashboardPage: React.FC = () => {
})} })}
</p> </p>
)} )}
</header> </StackLayout.Header>
<StackLayout.Body>
<Panel variant="card">
<OnboardingAssistant />
</Panel>
<OnboardingAssistant /> {mandateSections.map(({ mandate, mandateInstances }) => (
<Panel
<main className={styles.content}> key={mandate.id}
{mandates variant="dashboard"
.filter(mandate => mandate.features.some(f => f.instances.length > 0)) title={(
.map(mandate => { <span className={styles.sectionTitle}>
// Alle Instanzen dieses Mandats sammeln (flach, ohne Feature-Gruppierung) <FaBuilding />
const mandateInstances: { instance: NavFeatureInstance; feature: MandateFeature }[] = []; <span>{mandate.uiLabel}</span>
for (const feature of mandate.features) { </span>
for (const instance of feature.instances) { )}
mandateInstances.push({ instance, feature }); >
} <div className={styles.instanceGrid}>
} {mandateInstances.map(({ instance, feature }) => (
<InstanceCard
return ( key={instance.id}
<section key={mandate.id} className={styles.featureSection}> instance={instance}
<h2 className={styles.sectionTitle}> feature={feature}
<FaBuilding /> />
<span>{mandate.uiLabel}</span> ))}
</h2> </div>
<div className={styles.instanceGrid}> </Panel>
{mandateInstances.map(({ instance, feature }) => ( ))}
<InstanceCard </StackLayout.Body>
key={instance.id} </StackLayout>
instance={instance}
feature={feature}
/>
))}
</div>
</section>
);
})}
</main>
</div>
); );
}; };

View file

@ -5,9 +5,10 @@
.featureView { .featureView {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; flex: 1;
overflow: hidden;
min-height: 0; min-height: 0;
min-width: 0;
overflow: hidden;
} }
.viewHeader { .viewHeader {
@ -29,31 +30,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: auto; min-width: 0;
padding: 1.5rem; overflow: hidden;
}
/* Placeholder View */
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
padding: 2rem;
}
.placeholder h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.placeholder p {
margin: 0;
color: var(--text-secondary, #666);
} }
/* Not Found */ /* Not Found */
@ -92,12 +70,10 @@
} }
:global(.dark-theme) .viewTitle, :global(.dark-theme) .viewTitle,
:global(.dark-theme) .placeholder h2,
:global(.dark-theme) .notFound h2 { :global(.dark-theme) .notFound h2 {
color: var(--text-primary-dark, #ffffff); color: var(--text-primary-dark, #ffffff);
} }
:global(.dark-theme) .placeholder p,
:global(.dark-theme) .notFound p, :global(.dark-theme) .notFound p,
:global(.dark-theme) .accessDenied p { :global(.dark-theme) .accessDenied p {
color: var(--text-secondary-dark, #aaa); color: var(--text-secondary-dark, #aaa);
@ -115,11 +91,6 @@
/* scrollMode: document — view grows with content, no internal scroll */ /* scrollMode: document — view grows with content, no internal scroll */
:global(html[data-scroll-mode="document"]) .featureView { :global(html[data-scroll-mode="document"]) .featureView {
height: auto;
overflow: visible;
}
:global(html[data-scroll-mode="document"]) .viewContent {
overflow: visible;
flex: 0 0 auto; flex: 0 0 auto;
overflow: visible;
} }

View file

@ -48,42 +48,10 @@ import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, C
// Redmine Views // Redmine Views
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine'; import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
// Solutions View (Layout Foundation MVP)
import { SolutionsView } from './views/solutions/SolutionsView';
import styles from './FeatureView.module.css'; import styles from './FeatureView.module.css';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
// =============================================================================
// PLACEHOLDER VIEWS (für nicht implementierte Features)
// =============================================================================
const PlaceholderView: React.FC<{ title: string; description: string }> = ({ title, description }) => (
<div className={styles.placeholder}>
<h2>{title}</h2>
<p>{description}</p>
</div>
);
// Chatworkflow Views
const ChatworkflowDashboard: React.FC = () => {
const { t } = useLanguage();
return (
<PlaceholderView title={t('Workflow-Dashboard')} description={t('Übersicht der Workflows')} />
);
};
const ChatworkflowRuns: React.FC = () => {
const { t } = useLanguage();
return <PlaceholderView title={t('Ausführungen')} description={t('Workflow-Ausführungen')} />;
};
const ChatworkflowFiles: React.FC = () => {
const { t } = useLanguage();
return <PlaceholderView title={t('Dateien')} description={t('Workflow-Dateien')} />;
};
// Generic/Fallback // Generic/Fallback
const NotFound: React.FC = () => { const NotFound: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -115,18 +83,12 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
trustee: { trustee: {
dashboard: TrusteeDashboardView, dashboard: TrusteeDashboardView,
'data-tables': TrusteeDataTablesView, 'data-tables': TrusteeDataTablesView,
solutions: SolutionsView,
'instance-roles': TrusteeInstanceRolesView, 'instance-roles': TrusteeInstanceRolesView,
'import-process': TrusteeImportProcessView, 'import-process': TrusteeImportProcessView,
settings: TrusteeAccountingSettingsView, settings: TrusteeAccountingSettingsView,
analyse: TrusteeAnalyseView, analyse: TrusteeAnalyseView,
abschluss: TrusteeAbschlussView, abschluss: TrusteeAbschlussView,
}, },
chatworkflow: {
dashboard: ChatworkflowDashboard,
runs: ChatworkflowRuns,
files: ChatworkflowFiles,
},
realestate: { realestate: {
dashboard: RealEstatePekView, dashboard: RealEstatePekView,
'instance-roles': RealEstateInstanceRolesPlaceholder, 'instance-roles': RealEstateInstanceRolesPlaceholder,
@ -212,9 +174,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return ( return (
<div className={styles.featureView}> <div className={styles.featureView}>
<main className={styles.viewContent}> <ViewComponent />
<ViewComponent />
</main>
</div> </div>
); );
}; };

View file

@ -11,8 +11,9 @@ import { Link } from 'react-router-dom';
import { FaDownload, FaFileExport, FaShieldAlt, FaSpinner, FaTrash } from 'react-icons/fa'; import { FaDownload, FaFileExport, FaShieldAlt, FaSpinner, FaTrash } from 'react-icons/fa';
import api from '../api'; import api from '../api';
import { clearUserDataCache } from '../utils/userCache'; import { clearUserDataCache } from '../utils/userCache';
import { StackLayout } from '../components/Layout/StackLayout';
import { Panel } from '../components/Layout/Panel';
import styles from './GDPR.module.css'; import styles from './GDPR.module.css';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
type ConsentInfo = { type ConsentInfo = {
@ -27,7 +28,7 @@ type ActionMessage = {
text: string; text: string;
}; };
const downloadJson = (data: unknown, fileName: string, mimeType = 'application/json') => { const _downloadJson = (data: unknown, fileName: string, mimeType = 'application/json') => {
const fileBlob = new Blob([JSON.stringify(data, null, 2)], { type: mimeType }); const fileBlob = new Blob([JSON.stringify(data, null, 2)], { type: mimeType });
const fileUrl = URL.createObjectURL(fileBlob); const fileUrl = URL.createObjectURL(fileBlob);
const link = document.createElement('a'); const link = document.createElement('a');
@ -92,7 +93,7 @@ export const GDPRPage: React.FC = () => {
setActionMessage(null); setActionMessage(null);
try { try {
const response = await api.get('/api/user/me/data-export'); const response = await api.get('/api/user/me/data-export');
downloadJson(response.data, 'gdpr-data-export.json'); _downloadJson(response.data, 'gdpr-data-export.json');
setActionMessage({ type: 'success', text: t('Datenexport heruntergeladen.') }); setActionMessage({ type: 'success', text: t('Datenexport heruntergeladen.') });
} catch (error: any) { } catch (error: any) {
console.error('GDPR export failed:', error); console.error('GDPR export failed:', error);
@ -108,9 +109,9 @@ export const GDPRPage: React.FC = () => {
setActionMessage(null); setActionMessage(null);
try { try {
const response = await api.get('/api/user/me/data-portability', { const response = await api.get('/api/user/me/data-portability', {
headers: { Accept: 'application/ld+json' } headers: { Accept: 'application/ld+json' },
}); });
downloadJson(response.data, 'gdpr-data-portability.json', 'application/ld+json'); _downloadJson(response.data, 'gdpr-data-portability.json', 'application/ld+json');
setActionMessage({ type: 'success', text: t('Portabler Export heruntergeladen.') }); setActionMessage({ type: 'success', text: t('Portabler Export heruntergeladen.') });
} catch (error: any) { } catch (error: any) {
console.error('GDPR portability export failed:', error); console.error('GDPR portability export failed:', error);
@ -145,25 +146,25 @@ export const GDPRPage: React.FC = () => {
}; };
return ( return (
<div className={styles.gdpr}> <StackLayout variant="scroll">
<header className={styles.header}> <StackLayout.Header>
<div> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<h1 className={styles.title}> <div>
<FaShieldAlt className={styles.titleIcon} /> <h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{t('DSGVO / Datenschutz')} <FaShieldAlt className={styles.titleIcon} />
</h1> {t('DSGVO / Datenschutz')}
<p className={styles.subtitle}> </h1>
{t('Verwalten Sie Ihre personenbezogenen Datenexporte und Kontolöschung.')} <p className={styles.subtitle}>
</p> {t('Verwalten Sie Ihre personenbezogenen Datenexporte und Kontolöschung.')}
</p>
</div>
<Link to="/settings" className={styles.backLink}>
{t('Zurück zu Einstellungen')}
</Link>
</div> </div>
<Link to="/settings" className={styles.backLink}> </StackLayout.Header>
{t('Zurück zu Einstellungen')} <StackLayout.Body>
</Link> <Panel variant="card" title={t('Ihre Datenrechte')}>
</header>
<main className={styles.content}>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Ihre Datenrechte')}</h2>
<div className={styles.actions}> <div className={styles.actions}>
<div className={styles.actionCard}> <div className={styles.actionCard}>
<h3>{t('Zugriff (Artikel 15)')}</h3> <h3>{t('Zugriff (Artikel 15)')}</h3>
@ -279,10 +280,9 @@ export const GDPRPage: React.FC = () => {
{actionMessage.text} {actionMessage.text}
</div> </div>
)} )}
</section> </Panel>
<section className={styles.section}> <Panel variant="card" title={t('Verarbeitungsinformationen')}>
<h2 className={styles.sectionTitle}>{t('Verarbeitungsinformationen')}</h2>
{isLoadingConsent && <p className={styles.mutedText}>{t('Lade Einwilligungsinformationen')}</p>} {isLoadingConsent && <p className={styles.mutedText}>{t('Lade Einwilligungsinformationen')}</p>}
{consentError && <p className={styles.errorText}>{consentError}</p>} {consentError && <p className={styles.errorText}>{consentError}</p>}
{!isLoadingConsent && !consentError && consentInfo && ( {!isLoadingConsent && !consentError && consentInfo && (
@ -332,9 +332,9 @@ export const GDPRPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</section> </Panel>
</main> </StackLayout.Body>
</div> </StackLayout>
); );
}; };

View file

@ -8,6 +8,8 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useIntegrationsOverview, type DataLayerItem, type LiveStats } from '../hooks/useIntegrationsOverview'; import { useIntegrationsOverview, type DataLayerItem, type LiveStats } from '../hooks/useIntegrationsOverview';
import { StackLayout } from '../components/Layout/StackLayout';
import { Panel } from '../components/Layout/Panel';
import styles from './IntegrationsOverview.module.css'; import styles from './IntegrationsOverview.module.css';
/** de-CH: 1'234'567 */ /** de-CH: 1'234'567 */
@ -211,20 +213,24 @@ export const IntegrationsOverviewPage: React.FC = () => {
}, [diagram?.dataLayerItems]); }, [diagram?.dataLayerItems]);
return ( return (
<div className={styles.pageRoot}> <StackLayout variant="scroll">
<div className={styles.pageIntro}> <StackLayout.Header>
<h1 className={styles.pageHeading}>{t('Integrationen')}</h1> <h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Integrationen')}</h1>
<p className={styles.pageLead}> </StackLayout.Header>
{t('PORTA Architektur — Daten, Verarbeitung und Mandanten auf einen Blick.')} <StackLayout.Body>
</p> <Panel variant="card">
</div> <p className={styles.pageLead} style={{ margin: 0 }}>
{t('PORTA Architektur — Daten, Verarbeitung und Mandanten auf einen Blick.')}
</p>
</Panel>
<h2 className={styles.srOnly}> <Panel variant="card">
{t('PORTA Architektur v3: Drei separate Boxen in Schicht 2 — Infrastruktur, PORTA, Nutzen')} <h2 className={styles.srOnly}>
</h2> {t('PORTA Architektur v3: Drei separate Boxen in Schicht 2 — Infrastruktur, PORTA, Nutzen')}
</h2>
<div className={styles.diagramScroll}> <div className={styles.diagramScroll}>
<div className={styles.arch}> <div className={styles.arch}>
{loading && <div className={styles.loadingWrap}>{t('Laden…')}</div>} {loading && <div className={styles.loadingWrap}>{t('Laden…')}</div>}
{error && ( {error && (
<div className={styles.errorWrap}> <div className={styles.errorWrap}>
@ -487,8 +493,10 @@ export const IntegrationsOverviewPage: React.FC = () => {
</div> </div>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </Panel>
</StackLayout.Body>
</StackLayout>
); );
}; };

View file

@ -32,12 +32,14 @@ import { FaCheckCircle, FaTimesCircle, FaSpinner, FaSignInAlt, FaUserPlus } from
import styles from './InvitePage.module.css'; import styles from './InvitePage.module.css';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
// Key for storing pending invitation token // Key for storing pending invitation token
export const PENDING_INVITATION_KEY = 'pendingInvitationToken'; export const PENDING_INVITATION_KEY = 'pendingInvitationToken';
export const InvitePage: React.FC = () => { export const InvitePage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
useDocumentTitle(t('Einladung annehmen'));
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const navigate = useNavigate(); const navigate = useNavigate();

View file

@ -14,6 +14,7 @@ import styles from './Login.module.css';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector'; import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
type LoginPhase = 'credentials' | 'mfa_code' | 'mfa_setup'; type LoginPhase = 'credentials' | 'mfa_code' | 'mfa_setup';
@ -47,8 +48,9 @@ function Login() {
const fromLocation = location.state?.from; const fromLocation = location.state?.from;
const from = (fromLocation?.pathname || "/") + (fromLocation?.search || ""); const from = (fromLocation?.pathname || "/") + (fromLocation?.search || "");
useDocumentTitle(t('Login'));
useEffect(() => { useEffect(() => {
document.title = "PowerOn AI Platform - Login";
generateAndStoreCSRFToken(); generateAndStoreCSRFToken();
}, []); }, []);

View file

@ -9,6 +9,7 @@ import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector'; import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
function PasswordResetRequest() { function PasswordResetRequest() {
const { t } = useLanguage(); const { t } = useLanguage();
@ -20,9 +21,9 @@ function PasswordResetRequest() {
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Set page title and generate CSRF token useDocumentTitle(t('Passwort zurücksetzen'));
useEffect(() => { useEffect(() => {
document.title = "PowerOn AI Platform - Passwort zurücksetzen";
generateAndStoreCSRFToken(); generateAndStoreCSRFToken();
}, []); }, []);

View file

@ -3,16 +3,6 @@
max-width: 1100px; max-width: 1100px;
} }
/* ── Page Header ── */
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.headerLeft { .headerLeft {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -10,6 +10,7 @@
*/ */
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useApiRequest } from '../hooks/useApi'; import { useApiRequest } from '../hooks/useApi';
import { useConfirm } from '../hooks/useConfirm'; import { useConfirm } from '../hooks/useConfirm';
@ -17,17 +18,20 @@ import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '.
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa'; import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal'; import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
import { StackLayout } from '../components/Layout/StackLayout';
import { Panel } from '../components/Layout/Panel';
import styles from './RagInventoryPage.module.css'; import styles from './RagInventoryPage.module.css';
export const RagInventoryPage: React.FC = () => { export const RagInventoryPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { request } = useApiRequest(); const { request } = useApiRequest();
const { confirm, ConfirmDialog } = useConfirm(); const { confirm, ConfirmDialog } = useConfirm();
const [searchParams, setSearchParams] = useSearchParams();
const [mandates, setMandates] = useState<any[]>([]); const [mandates, setMandates] = useState<any[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true); const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedScope, setSelectedScope] = useState<string>('personal'); const selectedScope = searchParams.get('context') || 'personal';
const [onlyMyData, setOnlyMyData] = useState(false); const onlyMyData = searchParams.get('onlyMine') === 'true';
const [inventory, setInventory] = useState<RagInventoryDto | null>(null); const [inventory, setInventory] = useState<RagInventoryDto | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -60,13 +64,37 @@ export const RagInventoryPage: React.FC = () => {
if (!cancelled) { if (!cancelled) {
const list = Array.isArray(data) ? data : []; const list = Array.isArray(data) ? data : [];
setMandates(list); setMandates(list);
if (list.length === 1) setSelectedScope(list[0].id); if (list.length === 1 && selectedScope === 'personal') {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('context', list[0].id);
return next;
}, { replace: true });
}
} }
} catch {} } catch {}
finally { if (!cancelled) setMandatesLoading(false); } finally { if (!cancelled) setMandatesLoading(false); }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [request]); }, [request, selectedScope, setSearchParams]);
const _handleScopeChange = useCallback((value: string) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (value === 'personal') next.delete('context');
else next.set('context', value);
return next;
}, { replace: true });
}, [setSearchParams]);
const _handleOnlyMyDataChange = useCallback((checked: boolean) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (checked) next.set('onlyMine', 'true');
else next.delete('onlyMine');
return next;
}, { replace: true });
}, [setSearchParams]);
const _apiEndpoint = useMemo(() => { const _apiEndpoint = useMemo(() => {
if (selectedScope === 'personal') return '/api/rag/inventory/me'; if (selectedScope === 'personal') return '/api/rag/inventory/me';
@ -200,60 +228,76 @@ export const RagInventoryPage: React.FC = () => {
}, [mandates, t]); }, [mandates, t]);
return ( return (
<div className={styles.page}> <StackLayout variant="scroll">
<ConfirmDialog /> <ConfirmDialog />
<header className={styles.pageHeader}> <StackLayout.Header>
<div className={styles.headerLeft}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<FaDatabase className={styles.headerIcon} /> <div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}>
<div> <FaDatabase className={styles.headerIcon} />
<h1 className={styles.pageTitle}>{t('RAG-Inventar')}</h1> <div>
<p className={styles.pageDesc}> <h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('RAG-Inventar')}</h1>
{t('Übersicht und Steuerung der indexierten Wissensdaten.')} <p className={styles.pageDesc}>
</p> {t('Übersicht und Steuerung der indexierten Wissensdaten.')}
</p>
</div>
</div> </div>
</div> </div>
<div className={styles.headerRight}> </StackLayout.Header>
<div className={styles.filterGroup}> <StackLayout.Body>
<label className={styles.filterLabel}>{t('Kontext:')}</label> <Panel variant="toolbar">
<select <div className={styles.headerRight} style={{ marginLeft: 0 }}>
className={styles.scopeSelect} <div className={styles.filterGroup}>
value={selectedScope} <label className={styles.filterLabel}>{t('Kontext:')}</label>
onChange={e => setSelectedScope(e.target.value)} <select
disabled={mandatesLoading} className={styles.scopeSelect}
> value={selectedScope}
{scopeOptions.map(opt => ( onChange={e => _handleScopeChange(e.target.value)}
<option key={opt.value} value={opt.value}>{opt.label}</option> disabled={mandatesLoading}
))} >
</select> {scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={onlyMyData}
onChange={e => _handleOnlyMyDataChange(e.target.checked)}
/>
{t('nur meine Daten')}
</label>
</div> </div>
<label className={styles.checkboxLabel}> </Panel>
<input
type="checkbox"
checked={onlyMyData}
onChange={e => setOnlyMyData(e.target.checked)}
/>
{t('nur meine Daten')}
</label>
</div>
</header>
{loading && !inventory && <div className={styles.loading}>{t('Laden...')}</div>} {loading && !inventory && (
{error && <div className={styles.error}>{error}</div>} <Panel variant="card">
<div className={styles.loading}>{t('Laden...')}</div>
</Panel>
)}
{error && (
<Panel variant="card">
<div className={styles.error}>{error}</div>
</Panel>
)}
{inventory && ( {inventory && (
<div className={styles.content}> <>
<div className={styles.totals}> <Panel variant="card">
<span className={styles.totalLabel}>{t('Total Dateien')}:</span> <div className={styles.totals}>
<strong className={styles.totalValue}>{inventory.totals?.files ?? 0}</strong> <span className={styles.totalLabel}>{t('Total Dateien')}:</span>
<span className={styles.totalLabel} title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}>{t('Total Chunks')}:</span> <strong className={styles.totalValue}>{inventory.totals?.files ?? 0}</strong>
<strong className={styles.totalValue}>{inventory.totals?.chunks ?? 0}</strong> <span className={styles.totalLabel} title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}>{t('Total Chunks')}:</span>
{inventory.totals?.bytes != null && inventory.totals.bytes > 0 && ( <strong className={styles.totalValue}>{inventory.totals?.chunks ?? 0}</strong>
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span> {inventory.totals?.bytes != null && inventory.totals.bytes > 0 && (
)} <span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
</div> )}
</div>
</Panel>
{(inventory.connections || []).map((conn: RagConnectionDto) => ( {(inventory.connections || []).map((conn: RagConnectionDto) => (
<div key={conn.id} className={styles.connectionCard}> <Panel key={conn.id} variant="card">
<div className={styles.connectionCard} style={{ border: 'none', padding: 0, background: 'transparent' }}>
<div className={styles.connectionHeader}> <div className={styles.connectionHeader}>
<span className={styles.authority}>{conn.authority}</span> <span className={styles.authority}>{conn.authority}</span>
<span className={styles.email}>{conn.externalEmail}</span> <span className={styles.email}>{conn.externalEmail}</span>
@ -388,22 +432,24 @@ export const RagInventoryPage: React.FC = () => {
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div> <div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
)} )}
</div> </div>
</div> </div>
))} </Panel>
))}
{(inventory.featureInstances || []).length > 0 && ( {(inventory.featureInstances || []).length > 0 && (
<> <Panel variant="card" title={(
<h2 className={styles.sectionTitle}> <span className={styles.sectionTitle}>
<FaCubes style={{ marginRight: 8 }} /> <FaCubes style={{ marginRight: 8 }} />
{t('Feature-Daten')} {t('Feature-Daten')}
</h2> </span>
{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => { )}>
{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => {
const runningJobs = fi.runningJobs || []; const runningJobs = fi.runningJobs || [];
const lastSuccess = fi.lastSuccess; const lastSuccess = fi.lastSuccess;
const lastError = fi.lastError; const lastError = fi.lastError;
return ( return (
<div key={fi.featureInstanceId} className={styles.connectionCard}> <div key={fi.featureInstanceId} className={styles.connectionCard} style={{ marginTop: '1rem' }}>
<div className={styles.connectionHeader}> <div className={styles.connectionHeader}>
<span className={styles.authority}>{fi.featureCode}</span> <span className={styles.authority}>{fi.featureCode}</span>
<span className={styles.email}>{fi.label}</span> <span className={styles.email}>{fi.label}</span>
@ -499,14 +545,17 @@ export const RagInventoryPage: React.FC = () => {
</div> </div>
); );
})} })}
</> </Panel>
)} )}
{(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && ( {(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div> <Panel variant="card">
)} <div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
</div> </Panel>
)} )}
</>
)}
</StackLayout.Body>
<DataSourceSettingsModal <DataSourceSettingsModal
open={!!settingsModal} open={!!settingsModal}
@ -518,7 +567,7 @@ export const RagInventoryPage: React.FC = () => {
onSaved={() => _fetchInventory()} onSaved={() => _fetchInventory()}
onClose={() => setSettingsModal(null)} onClose={() => setSettingsModal(null)}
/> />
</div> </StackLayout>
); );
}; };

View file

@ -11,6 +11,7 @@ import { PENDING_INVITATION_KEY } from './InvitePage';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector'; import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
interface RegisterFormData { interface RegisterFormData {
username: string; username: string;
@ -41,8 +42,9 @@ function Register() {
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken; const hasPendingInvitation = !!pendingInvitationToken;
useDocumentTitle(t('Registrieren'));
useEffect(() => { useEffect(() => {
document.title = "PowerOn AI Platform - Registrieren";
generateAndStoreCSRFToken(); generateAndStoreCSRFToken();
}, []); }, []);

View file

@ -9,6 +9,7 @@ import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector'; import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
function Reset() { function Reset() {
const { t } = useLanguage(); const { t } = useLanguage();
@ -27,12 +28,11 @@ function Reset() {
// Get token from URL // Get token from URL
const token = searchParams.get('token'); const token = searchParams.get('token');
// Set page title and generate CSRF token useDocumentTitle(t('Neues Passwort setzen'));
useEffect(() => { useEffect(() => {
document.title = "PowerOn AI Platform - Neues Passwort setzen";
generateAndStoreCSRFToken(); generateAndStoreCSRFToken();
// Validate token exists and format
if (!token) { if (!token) {
setTokenError(t('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.')); setTokenError(t('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.'));
} else if (!_isValidUUID(token)) { } else if (!_isValidUUID(token)) {

View file

@ -5,7 +5,7 @@
* Route: /settings * Route: /settings
*/ */
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useCurrentUser, useUser } from '../hooks/useUsers'; import { useCurrentUser, useUser } from '../hooks/useUsers';
@ -15,39 +15,31 @@ import type { AttributeDefinition } from '../components/FormGenerator/FormGenera
import { useApiRequest } from '../hooks/useApi'; import { useApiRequest } from '../hooks/useApi';
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext'; import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
import { mfaStatusApi, mfaSetupApi, mfaConfirmApi, mfaDisableApi } from '../api/authApi'; import { mfaStatusApi, mfaSetupApi, mfaConfirmApi, mfaDisableApi } from '../api/authApi';
import { StackLayout } from '../components/Layout/StackLayout';
import { Panel } from '../components/Layout/Panel';
import { LayoutTabs } from '../components/Layout/LayoutTabs';
import type { LayoutTabItem } from '../components/Layout/types';
import styles from './Settings.module.css'; import styles from './Settings.module.css';
// ============================================================================= // =============================================================================
// TYPES // TYPES
// ============================================================================= // =============================================================================
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'security' | 'privacy';
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
return [
{ key: 'profile', label: t('Profil') },
{ key: 'appearance', label: t('Darstellung') },
{ key: 'voice', label: t('Stimme & Sprache') },
{ key: 'security', label: t('Sicherheit') },
{ key: 'privacy', label: t('Datenschutz') },
];
}
// ============================================================================= // =============================================================================
// PROFILE EDIT MODAL // PROFILE TAB
// ============================================================================= // =============================================================================
interface ProfileEditModalProps { interface _ProfileTabProps {
isOpen: boolean; currentUser: ReturnType<typeof useCurrentUser>['user'];
onClose: () => void; refetchUser: ReturnType<typeof useCurrentUser>['refetch'];
userData: any; onSave: (formData: any) => Promise<void>;
onSave: (data: any) => Promise<void>;
} }
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => { const _ProfileTab: React.FC<_ProfileTabProps> = ({ currentUser, refetchUser, onSave }) => {
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const { t, availableLanguages } = useLanguage(); const { t, availableLanguages } = useLanguage();
const [isProfileEditing, setIsProfileEditing] = useState(false);
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [profileError, setProfileError] = useState<string | null>(null);
const languageOptions = availableLanguages.map((l) => ({ value: l.code, label: l.label || l.code })); const languageOptions = availableLanguages.map((l) => ({ value: l.code, label: l.label || l.code }));
@ -57,34 +49,68 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
{ name: 'language', type: 'select', label: t('Sprache'), description: t('Anzeigesprache der Anwendung'), required: true, options: languageOptions }, { name: 'language', type: 'select', label: t('Sprache'), description: t('Anzeigesprache der Anwendung'), required: true, options: languageOptions },
]; ];
const handleSubmit = async (formData: any) => { const _handleProfileSubmit = async (formData: any) => {
setIsSaving(true); setIsSavingProfile(true);
setError(null); setProfileError(null);
try { try {
await onSave(formData); await onSave(formData);
onClose(); setIsProfileEditing(false);
} catch (err: any) { } catch (err: any) {
setError(err.message || t('Fehler beim Speichern des Profils')); setProfileError(err.message || t('Fehler beim Speichern des Profils'));
} finally { } finally {
setIsSaving(false); setIsSavingProfile(false);
} }
}; };
if (!isOpen) return null;
return ( return (
<div className={styles.modalOverlay}> <>
<div className={styles.modalContent}> <Panel variant="card" title={t('Konto')}>
<div className={styles.modalHeader}> {!isProfileEditing ? (
<h2>{t('Profil bearbeiten')}</h2> <>
<button className={styles.closeButton} onClick={onClose}>&times;</button> <div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>{t('Profil bearbeiten')}</label>
<p className={styles.settingDescription}>{t('Ändern Sie Ihren Namen und')}</p>
</div>
<div className={styles.settingControl}>
<button
className={styles.button}
onClick={async () => { if (refetchUser) await refetchUser(); setIsProfileEditing(true); }}
>
{t('Profil bearbeiten')}
</button>
</div>
</div>
{currentUser && (
<div className={styles.userInfoCard}>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Benutzername')}</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Name')}</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('E-Mail')}</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
</div>
)}
</>
) : (
<>
{profileError && <div className={styles.errorMessage}>{profileError}</div>}
<FormGeneratorForm
attributes={profileAttributes}
data={currentUser}
mode="edit"
onSubmit={_handleProfileSubmit}
onCancel={() => { setIsProfileEditing(false); setProfileError(null); }}
submitButtonText={isSavingProfile ? t('Speichern') : t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</Panel>
<Panel variant="card" title={t('Applikation')}>
<div className={styles.infoCard}>
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Version')}</span><span className={styles.infoValue}>2.0.0</span></div>
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Build')}</span><span className={styles.infoValue}>2026.03.23</span></div>
</div> </div>
<div className={styles.modalBody}> </Panel>
{error && <div className={styles.errorMessage}>{error}</div>} </>
<FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? t('Speichern') : t('Speichern')} cancelButtonText={t('Abbrechen')} />
</div>
</div>
</div>
); );
}; };
@ -206,10 +232,10 @@ const VoiceSettingsTab: React.FC = () => {
return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code; return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code;
}, [voiceCatalog]); }, [voiceCatalog]);
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>; if (loading) return <Panel variant="card"><p style={{ padding: '1rem', color: 'var(--text-secondary, #888)' }}>{t('Einstellungen werden geladen')}</p></Panel>;
return ( return (
<> <Panel variant="card">
{error && <div className={styles.errorMessage}>{error}</div>} {error && <div className={styles.errorMessage}>{error}</div>}
{success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>} {success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>}
@ -294,7 +320,7 @@ const VoiceSettingsTab: React.FC = () => {
<button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}> <button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}>
{saving ? t('Speichern') : t('Einstellungen speichern')} {saving ? t('Speichern') : t('Einstellungen speichern')}
</button> </button>
</> </Panel>
); );
}; };
@ -353,10 +379,10 @@ const NeutralizationMappingsTab: React.FC = () => {
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2); return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
}; };
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Mappings werden geladen')}</div>; if (loading) return <Panel variant="card"><p style={{ padding: '1rem', color: 'var(--text-secondary, #888)' }}>{t('Mappings werden geladen')}</p></Panel>;
return ( return (
<> <Panel variant="card">
{error && <div className={styles.errorMessage}>{error}</div>} {error && <div className={styles.errorMessage}>{error}</div>}
<section className={styles.section}> <section className={styles.section}>
@ -419,7 +445,7 @@ const NeutralizationMappingsTab: React.FC = () => {
</table> </table>
)} )}
</section> </section>
</> </Panel>
); );
}; };
@ -507,11 +533,11 @@ const MfaSettingsTab: React.FC = () => {
}; };
if (loading) { if (loading) {
return <section className={styles.section}><p>{t('wird geladen…')}</p></section>; return <Panel variant="card"><p>{t('wird geladen…')}</p></Panel>;
} }
return ( return (
<section className={styles.section}> <Panel variant="card">
<h2 className={styles.sectionTitle}>{t('Zwei-Faktor-Authentifizierung (MFA)')}</h2> <h2 className={styles.sectionTitle}>{t('Zwei-Faktor-Authentifizierung (MFA)')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}> <p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
{t('Schützen Sie Ihr Konto mit einem zusätzlichen Bestätigungscode bei der Anmeldung.')} {t('Schützen Sie Ihr Konto mit einem zusätzlichen Bestätigungscode bei der Anmeldung.')}
@ -605,7 +631,79 @@ const MfaSettingsTab: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</section> </Panel>
);
};
// =============================================================================
// APPEARANCE TAB
// =============================================================================
interface _AppearanceTabProps {
theme: 'light' | 'dark';
onThemeChange: (theme: 'light' | 'dark') => void;
currentLanguage: string;
onLanguageChange: (lang: string) => void;
isSavingLanguage: boolean;
languageError: string | null;
availableLanguages: Array<{ code: string; label?: string }>;
}
const _AppearanceTab: React.FC<_AppearanceTabProps> = ({
theme,
onThemeChange,
currentLanguage,
onLanguageChange,
isSavingLanguage,
languageError,
availableLanguages,
}) => {
const { t } = useLanguage();
return (
<Panel variant="card" title={t('Darstellung')}>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Theme')}</label><p className={styles.settingDescription}>{t('Wählen zwischen Hell- und Dunkelmodus')}</p></div>
<div className={styles.settingControl}>
<div className={styles.themeToggle}>
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => onThemeChange('light')}>{t('Thema Hell')}</button>
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => onThemeChange('dark')}>{t('Thema Dunkel')}</button>
</div>
</div>
</div>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Anzeigesprache')}</label><p className={styles.settingDescription}>{t('Sprachbeschreibung')}{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
<div className={styles.settingControl}>
<select className={styles.select} value={currentLanguage} onChange={(e) => onLanguageChange(e.target.value)} disabled={isSavingLanguage}>
{availableLanguages.map((l) => (
<option key={l.code} value={l.code}>
{l.label || l.code}
</option>
))}
</select>
{isSavingLanguage && <span className={styles.savingIndicator}>{t('Speichern')}</span>}
</div>
</div>
</Panel>
);
};
const _PrivacyTab: React.FC = () => {
const { t } = useLanguage();
return (
<>
<Panel variant="card" title={t('Datenschutz')}>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
{t('Datenschutzbeschreibung')}
</p>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('GDPR Datenschutz')}</label><p className={styles.settingDescription}>{t('Datenexport, Portabilität und Kontolöschung')}</p></div>
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('GDPR öffnen')}</Link></div>
</div>
</Panel>
<NeutralizationMappingsTab />
</>
); );
}; };
@ -618,9 +716,7 @@ export const SettingsPage: React.FC = () => {
const { user: currentUser, refetch: refetchUser } = useCurrentUser(); const { user: currentUser, refetch: refetchUser } = useCurrentUser();
const { updateUser } = useUser(); const { updateUser } = useUser();
const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'); const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light');
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [isSavingLanguage, setIsSavingLanguage] = useState(false); const [isSavingLanguage, setIsSavingLanguage] = useState(false);
const [languageError, setLanguageError] = useState<string | null>(null); const [languageError, setLanguageError] = useState<string | null>(null);
@ -661,110 +757,79 @@ export const SettingsPage: React.FC = () => {
if (newLanguage !== currentLanguage) setLanguage(newLanguage); if (newLanguage !== currentLanguage) setLanguage(newLanguage);
if (refetchUser) await refetchUser(); if (refetchUser) await refetchUser();
window.dispatchEvent(new CustomEvent('userInfoUpdated')); window.dispatchEvent(new CustomEvent('userInfoUpdated'));
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]); }, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage, t]);
const settingsTabs: LayoutTabItem[] = useMemo(() => [
{
id: 'profile',
label: t('Profil'),
render: () => (
<_ProfileTab
currentUser={currentUser}
refetchUser={refetchUser}
onSave={handleProfileSave}
/>
),
},
{
id: 'appearance',
label: t('Darstellung'),
render: () => (
<_AppearanceTab
theme={theme}
onThemeChange={handleThemeChange}
currentLanguage={currentLanguage}
onLanguageChange={handleLanguageChange}
isSavingLanguage={isSavingLanguage}
languageError={languageError}
availableLanguages={availableLanguages}
/>
),
},
{
id: 'voice',
label: t('Stimme & Sprache'),
render: () => <VoiceSettingsTab />,
},
{
id: 'security',
label: t('Sicherheit'),
render: () => <MfaSettingsTab />,
},
{
id: 'privacy',
label: t('Datenschutz'),
render: () => <_PrivacyTab />,
},
], [
t,
currentUser,
refetchUser,
handleProfileSave,
theme,
handleThemeChange,
currentLanguage,
handleLanguageChange,
isSavingLanguage,
languageError,
availableLanguages,
]);
return ( return (
<div className={styles.settings}> <StackLayout variant="scroll">
<header className={styles.header}> <StackLayout.Header>
<h1>{t('Einstellungen')}</h1> <h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Einstellungen')}</h1>
<p className={styles.subtitle}>{t('Persönliche Einstellungen und Präferenzen')}</p> <p className={styles.subtitle}>{t('Persönliche Einstellungen und Präferenzen')}</p>
</header> </StackLayout.Header>
<StackLayout.Body>
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}> <LayoutTabs
{_getTabs(t).map(tab => ( items={settingsTabs}
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{ urlParam="tab"
padding: '10px 20px', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--primary-color, #2563eb)' : '2px solid transparent', defaultTab="profile"
background: 'none', cursor: 'pointer', fontSize: 14, fontWeight: activeTab === tab.key ? 600 : 400, lazy
color: activeTab === tab.key ? 'var(--primary-color, #2563eb)' : 'var(--text-secondary, #888)', />
}}> </StackLayout.Body>
{tab.label} </StackLayout>
</button>
))}
</nav>
<main className={styles.content}>
{activeTab === 'profile' && (
<>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Konto')}</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>{t('Profil bearbeiten')}</label>
<p className={styles.settingDescription}>{t('Ändern Sie Ihren Namen und')}</p>
</div>
<div className={styles.settingControl}>
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>{t('Profil öffnen')}</button>
</div>
</div>
{currentUser && (
<div className={styles.userInfoCard}>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Benutzername')}</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Name')}</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('E-Mail')}</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
</div>
)}
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Applikation')}</h2>
<div className={styles.infoCard}>
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Version')}</span><span className={styles.infoValue}>2.0.0</span></div>
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Build')}</span><span className={styles.infoValue}>2026.03.23</span></div>
</div>
</section>
</>
)}
{activeTab === 'appearance' && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Darstellung')}</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Theme')}</label><p className={styles.settingDescription}>{t('Wählen zwischen Hell- und Dunkelmodus')}</p></div>
<div className={styles.settingControl}>
<div className={styles.themeToggle}>
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>{t('Thema Hell')}</button>
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>{t('Thema Dunkel')}</button>
</div>
</div>
</div>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Anzeigesprache')}</label><p className={styles.settingDescription}>{t('Sprachbeschreibung')}{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
<div className={styles.settingControl}>
<select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value)} disabled={isSavingLanguage}>
{availableLanguages.map((l) => (
<option key={l.code} value={l.code}>
{l.label || l.code}
</option>
))}
</select>
{isSavingLanguage && <span className={styles.savingIndicator}>{t('Speichern')}</span>}
</div>
</div>
</section>
)}
{activeTab === 'voice' && <VoiceSettingsTab />}
{activeTab === 'security' && <MfaSettingsTab />}
{activeTab === 'privacy' && (
<>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
{t('Datenschutzbeschreibung')}
</p>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('GDPR Datenschutz')}</label><p className={styles.settingDescription}>{t('Datenexport, Portabilität und Kontolöschung')}</p></div>
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('GDPR öffnen')}</Link></div>
</div>
</section>
<NeutralizationMappingsTab />
</>
)}
</main>
<ProfileEditModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} />
</div>
); );
}; };

View file

@ -13,6 +13,8 @@ import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import { useStore, _storeActionKey } from '../hooks/useStore'; import { useStore, _storeActionKey } from '../hooks/useStore';
import type { StoreFeature, UserMandate } from '../api/storeApi'; import type { StoreFeature, UserMandate } from '../api/storeApi';
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize'; import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
import { StackLayout } from '../components/Layout/StackLayout';
import { Panel } from '../components/Layout/Panel';
import styles from './Store.module.css'; import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = { const FEATURE_ICONS: Record<string, React.ReactNode> = {
@ -23,7 +25,6 @@ const FEATURE_ICONS: Record<string, React.ReactNode> = {
trustee: <FaShieldAlt />, trustee: <FaShieldAlt />,
}; };
/** Fallback when GET /store/features omits description (German i18n keys). */
const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = { const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = {
automation: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.', automation: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.',
teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.', teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
@ -139,76 +140,89 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
); );
}; };
const StorePage: React.FC = () => { export const StorePage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore(); const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
return ( return (
<div className={styles.store}> <StackLayout variant="dashboard">
<div className={styles.header}> <StackLayout.Header>
<h1>{t('Feature Store')}</h1> <h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Feature Store')}</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
{t('Aktiviere Features für dein Konto. Deine Daten sind isoliert und nur für dich sichtbar.')} {t('Aktiviere Features für dein Konto. Deine Daten sind isoliert und nur für dich sichtbar.')}
</p> </p>
</div> </StackLayout.Header>
<StackLayout.Body>
{subscriptionInfo && subscriptionInfo.plan && (
<Panel variant="card">
<div className={styles.subscriptionBanner}>
<span>{t('Plan:')} <strong>{subscriptionInfo.plan}</strong></span>
<span className={styles.bannerSeparator}>
{subscriptionInfo.maxFeatureInstances != null
? <>{t('Module')}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances}</>
: <>{subscriptionInfo.currentFeatureInstances} {t('Module aktiv')}
{subscriptionInfo.includedModules != null && subscriptionInfo.includedModules > 0 && (
<> ({subscriptionInfo.includedModules} {t('inklusive')})</>
)}
</>
}
</span>
{subscriptionInfo.maxDataVolumeMB != null && (
<span className={styles.bannerSeparator}>
{t('Speicher')}:{' '}
{formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
</span>
)}
{subscriptionInfo.budgetAiPerUserCHF != null && subscriptionInfo.budgetAiPerUserCHF > 0 && (
<span className={styles.bannerSeparator}>
{t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} {t('CHF / Benutzer')}
</span>
)}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
<span className={styles.bannerSeparator}>
{t('Testphase endet am')}: {new Date(Number(subscriptionInfo.trialEndsAt) * 1000).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</span>
)}
</div>
</Panel>
)}
{subscriptionInfo && subscriptionInfo.plan && ( {error && (
<div className={styles.subscriptionBanner}> <Panel variant="card">
<span>{t('Plan:')} <strong>{subscriptionInfo.plan}</strong></span> <div className={styles.error}>{error}</div>
<span className={styles.bannerSeparator}> </Panel>
{subscriptionInfo.maxFeatureInstances != null )}
? <>{t('Module')}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances}</>
: <>{subscriptionInfo.currentFeatureInstances} {t('Module aktiv')}
{subscriptionInfo.includedModules != null && subscriptionInfo.includedModules > 0 && (
<> ({subscriptionInfo.includedModules} {t('inklusive')})</>
)}
</>
}
</span>
{subscriptionInfo.maxDataVolumeMB != null && (
<span className={styles.bannerSeparator}>
{t('Speicher')}:{' '}
{formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
</span>
)}
{subscriptionInfo.budgetAiPerUserCHF != null && subscriptionInfo.budgetAiPerUserCHF > 0 && (
<span className={styles.bannerSeparator}>
{t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} {t('CHF / Benutzer')}
</span>
)}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
<span className={styles.bannerSeparator}>
{t('Testphase endet am')}: {new Date(Number(subscriptionInfo.trialEndsAt) * 1000).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</span>
)}
</div>
)}
{error && <div className={styles.error}>{error}</div>} {loading ? (
<Panel variant="card">
{loading ? ( <div className={styles.loading}>
<div className={styles.loading}> {t('Lade Features…')}
{t('Lade Features…')} </div>
</div> </Panel>
) : features.length === 0 ? ( ) : features.length === 0 ? (
<div className={styles.empty}> <Panel variant="card">
{t('Keine Features im Store verfügbar.')} <div className={styles.empty}>
</div> {t('Keine Features im Store verfügbar.')}
) : ( </div>
<div className={styles.grid}> </Panel>
{features.map((feature) => ( ) : (
<FeatureCard <Panel variant="dashboard">
key={feature.featureCode} <div className={styles.grid}>
feature={feature} {features.map((feature) => (
mandates={mandates} <FeatureCard
actionLoading={actionLoading} key={feature.featureCode}
onActivate={activate} feature={feature}
onDeactivate={deactivate} mandates={mandates}
/> actionLoading={actionLoading}
))} onActivate={activate}
</div> onDeactivate={deactivate}
)} />
</div> ))}
</div>
</Panel>
)}
</StackLayout.Body>
</StackLayout>
); );
}; };

View file

@ -17,6 +17,8 @@ import {
} from '../../hooks/useFeatureAccess'; } from '../../hooks/useFeatureAccess';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates'; import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FaBuilding, FaCube, FaUsers, FaCogs, FaSync, FaChartBar, FaLink, FaList, FaSitemap } from 'react-icons/fa'; import { FaBuilding, FaCube, FaUsers, FaCogs, FaSync, FaChartBar, FaLink, FaList, FaSitemap } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import api from '../../api'; import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
@ -306,31 +308,37 @@ export const AccessManagementHub: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={styles.adminPage}> <StackLayout variant="scroll">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}> {t('Fehler')}: {error}
<FaSync /> {t('Erneut versuchen')} </p>
</button> <button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={styles.adminPage}> <>
<div className={styles.pageHeader}> <StackLayout variant="scroll">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Zugriffsverwaltung')}</h1> <h1 className={styles.pageTitle}>{t('Zugriffsverwaltung')}</h1>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>
{t('Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten')} {t('Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten')}
</p> </p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={hubStyles.filters}> <div className={hubStyles.filters}>
{/* Filter dropdowns only shown in list view - hierarchy shows everything */} {/* Filter dropdowns only shown in list view - hierarchy shows everything */}
{viewMode === 'list' && ( {viewMode === 'list' && (
@ -419,6 +427,7 @@ export const AccessManagementHub: React.FC = () => {
<FaUsers /> {t('Mandant-Benutzer')} <FaUsers /> {t('Mandant-Benutzer')}
</Link> </Link>
</div> </div>
</Panel>
{viewMode === 'hierarchy' ? ( {viewMode === 'hierarchy' ? (
<InstanceHierarchyView <InstanceHierarchyView
@ -432,6 +441,7 @@ export const AccessManagementHub: React.FC = () => {
onOpenDetail={handleOpenDetail} onOpenDetail={handleOpenDetail}
/> />
) : !selectedMandateId ? ( ) : !selectedMandateId ? (
<Panel variant="card">
<div className={styles.emptyState}> <div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} /> <FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3> <h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
@ -439,8 +449,10 @@ export const AccessManagementHub: React.FC = () => {
{t('Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.')} {t('Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.')}
</p> </p>
</div> </div>
</Panel>
) : ( ) : (
<> <>
<Panel variant="dashboard">
<div className={hubStyles.overviewRow}> <div className={hubStyles.overviewRow}>
<div className={hubStyles.statsCard}> <div className={hubStyles.statsCard}>
<FaChartBar className={hubStyles.statsIcon} /> <FaChartBar className={hubStyles.statsIcon} />
@ -493,7 +505,9 @@ export const AccessManagementHub: React.FC = () => {
</div> </div>
)} )}
</div> </div>
</Panel>
<Panel variant="dashboard">
<section className={hubStyles.section}> <section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>{t('Feature-Instanzen')}</h2> <h2 className={hubStyles.sectionTitle}>{t('Feature-Instanzen')}</h2>
{loading && filteredInstances.length === 0 ? ( {loading && filteredInstances.length === 0 ? (
@ -560,8 +574,11 @@ export const AccessManagementHub: React.FC = () => {
</div> </div>
)} )}
</section> </section>
</Panel>
</> </>
)} )}
</StackLayout.Body>
</StackLayout>
{detailInstance && ( {detailInstance && (
<InstanceDetailModal <InstanceDetailModal
@ -583,7 +600,7 @@ export const AccessManagementHub: React.FC = () => {
onComplete={handleWizardComplete} onComplete={handleWizardComplete}
/> />
)} )}
</div> </>
); );
}; };

View file

@ -4,38 +4,6 @@
* Common styles for all admin pages using FormGeneratorTable * Common styles for all admin pages using FormGeneratorTable
*/ */
.adminPage {
padding: 1.5rem;
/* Default: grow with content → scroll on MainLayout .outletShell (expandable panels, long pages). */
flex: 0 0 auto;
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
/*
* FormGeneratorTable expects a bounded height chain (height:100% / flex:1).
* With default .adminPage (flex:0 0 auto), .tableContainer flex:1 collapses empty table.
* Use together: className={`${styles.adminPage} ${styles.adminPageFill}`}
*/
.adminPage.adminPageFill {
flex: 1 1 auto;
min-height: 0;
/* visible: let the table's min-height overflow to the scroll ancestor on
short viewports (scrollbar instead of clipped table). */
overflow: visible;
}
.pageHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-shrink: 0;
min-height: 0;
}
.pageTitle { .pageTitle {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
@ -213,15 +181,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
.tableContainer {
flex: 1;
min-height: 0;
/* visible: see .adminPageFill — table min-height must reach the scrollbar. */
overflow: visible;
display: flex;
flex-direction: column;
}
.loadingContainer { .loadingContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -731,12 +690,6 @@
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.pageHeader {
align-items: flex-start;
flex-direction: column;
gap: 0.75rem;
}
.headerActions { .headerActions {
width: 100%; width: 100%;
flex-wrap: wrap; flex-wrap: wrap;
@ -753,30 +706,7 @@
} }
} }
/* scrollMode: document — table grows to natural height, page scrolls */
:global(html[data-scroll-mode="document"]) .adminPage.adminPageFill {
flex: 0 0 auto;
overflow: visible;
}
:global(html[data-scroll-mode="document"]) .tableContainer {
overflow: visible;
min-height: 300px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.adminPage {
padding: 0.75rem;
}
.pageHeader {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.pageTitle { .pageTitle {
font-size: 1.1rem; font-size: 1.1rem;
} }
@ -798,10 +728,6 @@
min-height: 32px; min-height: 32px;
gap: 0.3rem; gap: 0.3rem;
} }
.tableContainer {
min-height: 0;
}
} }
/* ============================================== */ /* ============================================== */

View file

@ -19,7 +19,10 @@ import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import { Tabs } from '../../components/UiComponents/Tabs/Tabs'; import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { LayoutTabs } from '../../components/Layout/LayoutTabs';
import type { LayoutTabItem } from '../../components/Layout/types';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
@ -311,8 +314,8 @@ const StatsTab: React.FC = () => {
], [t, databases]); ], [t, databases]);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}> <>
{/* Controls */} <Panel variant="toolbar">
<div className={styles.filterSection}> <div className={styles.filterSection}>
<div className={styles.filterGroup}> <div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label> <label className={styles.filterLabel}>{t('Datenbank')}</label>
@ -329,8 +332,9 @@ const StatsTab: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')} <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
</div> </div>
</Panel>
{/* Summary */} <Panel variant="card">
<div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}> <div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
<span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span> <span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span>
<span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span> <span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span>
@ -338,11 +342,13 @@ const StatsTab: React.FC = () => {
<span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span> <span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span>
<span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span> <span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span>
</div> </div>
</Panel>
<div className={styles.tableContainer}> <Panel variant="table">
<FormGeneratorTable <FormGeneratorTable
data={visibleData} data={visibleData}
columns={columns} columns={columns}
filterScopeKey="admin"
loading={loading} loading={loading}
sortable={true} sortable={true}
searchable={true} searchable={true}
@ -357,8 +363,8 @@ const StatsTab: React.FC = () => {
}} }}
emptyMessage={t('Keine Tabellen gefunden')} emptyMessage={t('Keine Tabellen gefunden')}
/> />
</div> </Panel>
</div> </>
); );
}; };
@ -625,10 +631,10 @@ const OrphansTab: React.FC = () => {
], [t, databases]); ], [t, databases]);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}> <>
<ConfirmDialog /> <ConfirmDialog />
{/* Controls */} <Panel variant="toolbar">
<div className={styles.filterSection}> <div className={styles.filterSection}>
<div className={styles.filterGroup}> <div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label> <label className={styles.filterLabel}>{t('Datenbank')}</label>
@ -671,8 +677,10 @@ const OrphansTab: React.FC = () => {
)} )}
</div> </div>
</div> </div>
</Panel>
{totalOrphans > 0 && ( {totalOrphans > 0 && (
<Panel variant="card">
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}> <div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} /> <FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
{t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', { {t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', {
@ -680,12 +688,14 @@ const OrphansTab: React.FC = () => {
relations: allOrphans.filter(o => o.orphanCount > 0).length, relations: allOrphans.filter(o => o.orphanCount > 0).length,
})} })}
</div> </div>
</Panel>
)} )}
<div className={styles.tableContainer}> <Panel variant="table">
<FormGeneratorTable <FormGeneratorTable
data={visibleData} data={visibleData}
columns={columns} columns={columns}
filterScopeKey="admin"
loading={loading} loading={loading}
sortable={true} sortable={true}
searchable={true} searchable={true}
@ -718,8 +728,8 @@ const OrphansTab: React.FC = () => {
}} }}
emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')} emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')}
/> />
</div> </Panel>
</div> </>
); );
}; };
@ -1157,10 +1167,10 @@ const MigrationTab: React.FC = () => {
}; };
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto', gap: '2rem', padding: '0.5rem 0' }}> <>
<ConfirmDialog /> <ConfirmDialog />
{/* ---- BACKUP SECTION ---- */} <Panel variant="card" title={t('Backup')}>
<section> <section>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaDownload /> {t('Backup')} <FaDownload /> {t('Backup')}
@ -1240,17 +1250,10 @@ const MigrationTab: React.FC = () => {
</> </>
)} )}
</section> </section>
</Panel>
{/* ---- DIVIDER ---- */} <Panel variant="card" title={t('Restore')}>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: 0 }} />
{/* ---- RESTORE SECTION ---- */}
<section> <section>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaUpload /> {t('Restore')}
</h2>
{/* File upload zone */}
{!uploadedFile ? ( {!uploadedFile ? (
<div <div
onDrop={_onDrop} onDrop={_onDrop}
@ -1467,7 +1470,8 @@ const MigrationTab: React.FC = () => {
</div> </div>
)} )}
</section> </section>
</div> </Panel>
</>
); );
}; };
@ -1661,9 +1665,10 @@ const LegacyCleanupTab: React.FC = () => {
], [t, databases, selected]); ], [t, databases, selected]);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}> <>
<ConfirmDialog /> <ConfirmDialog />
<Panel variant="toolbar">
<div className={styles.filterSection}> <div className={styles.filterSection}>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchLegacy} disabled={loading}> <button className={styles.secondaryButton} onClick={_fetchLegacy} disabled={loading}>
@ -1681,20 +1686,24 @@ const LegacyCleanupTab: React.FC = () => {
)} )}
</div> </div>
</div> </div>
</Panel>
{allLegacy.length > 0 && ( {allLegacy.length > 0 && (
<Panel variant="card">
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}> <div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} /> <FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
{t('{count} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', { {t('{count} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', {
count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size), count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size),
})} })}
</div> </div>
</Panel>
)} )}
<div className={styles.tableContainer}> <Panel variant="table">
<FormGeneratorTable <FormGeneratorTable
data={visibleData} data={visibleData}
columns={columns} columns={columns}
filterScopeKey="admin"
loading={loading} loading={loading}
sortable={true} sortable={true}
searchable={true} searchable={true}
@ -1718,8 +1727,8 @@ const LegacyCleanupTab: React.FC = () => {
}} }}
emptyMessage={t('Keine Legacy-Tabellen gefunden')} emptyMessage={t('Keine Legacy-Tabellen gefunden')}
/> />
</div> </Panel>
</div> </>
); );
}; };
@ -1731,40 +1740,41 @@ const LegacyCleanupTab: React.FC = () => {
export const AdminDatabaseHealthPage: React.FC = () => { export const AdminDatabaseHealthPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const tabs = useMemo(() => [ const tabs: LayoutTabItem[] = useMemo(() => [
{ {
id: 'stats', id: 'stats',
label: t('Statistiken'), label: t('Statistiken'),
content: <StatsTab />, render: () => <StatsTab />,
}, },
{ {
id: 'orphans', id: 'orphans',
label: t('Orphan Cleanup'), label: t('Orphan Cleanup'),
content: <OrphansTab />, render: () => <OrphansTab />,
}, },
{ {
id: 'legacy', id: 'legacy',
label: t('Legacy Cleanup'), label: t('Legacy Cleanup'),
content: <LegacyCleanupTab />, render: () => <LegacyCleanupTab />,
}, },
{ {
id: 'migration', id: 'migration',
label: t('Migration'), label: t('Migration'),
content: <MigrationTab />, render: () => <MigrationTab />,
}, },
], [t]); ], [t]);
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.pageHeader}> <StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1> <h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p> <p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
<Tabs tabs={tabs} defaultTabId="stats" /> <LayoutTabs items={tabs} urlParam="tab" defaultTab="stats" lazy />
</div> </StackLayout.Body>
</StackLayout>
); );
}; };

View file

@ -9,6 +9,8 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { FaPlay, FaTrash, FaSync, FaCubes, FaCopy, FaKey } from 'react-icons/fa'; import { FaPlay, FaTrash, FaSync, FaCubes, FaCopy, FaKey } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import api from '../../api'; import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import demoStyles from './AdminDemoConfigPage.module.css'; import demoStyles from './AdminDemoConfigPage.module.css';
@ -111,77 +113,88 @@ export const AdminDemoConfigPage: React.FC = () => {
}; };
return ( return (
<div className={styles.adminPage}> <StackLayout variant="dashboard">
<div className={styles.pageHeader}> <StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Demo-Konfigurationen')}</h1> <h1 className={styles.pageTitle}>{t('Demo-Konfigurationen')}</h1>
<p className={styles.pageSubtitle}>{t('Demo-Umgebungen für Präsentationen und Tests laden oder entfernen.')}</p> <p className={styles.pageSubtitle}>{t('Demo-Umgebungen für Präsentationen und Tests laden oder entfernen.')}</p>
</div> </div>
<div className={styles.headerActions}> </StackLayout.Header>
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}> <StackLayout.Body>
<FaSync /> {t('Aktualisieren')} <Panel variant="toolbar">
</button> <div className={styles.headerActions}>
</div> <button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
</div> <FaSync /> {t('Aktualisieren')}
</button>
</div>
</Panel>
{error && <div className={demoStyles.errorBanner}>{error}</div>} {error && (
<Panel variant="card">
<div className={demoStyles.errorBanner}>{error}</div>
</Panel>
)}
{lastResult && ( {lastResult && (
<div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}> <Panel variant="card">
<strong>{lastResult.action === 'load' ? t('Geladen') : t('Entfernt')}:</strong>{' '} <div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}>
{lastResult.status === 'ok' ? ( <strong>{lastResult.action === 'load' ? t('Geladen') : t('Entfernt')}:</strong>{' '}
<_SummaryDisplay summary={lastResult.summary} /> {lastResult.status === 'ok' ? (
) : ( <_SummaryDisplay summary={lastResult.summary} />
<span>{lastResult.error}</span> ) : (
)} <span>{lastResult.error}</span>
{lastResult.status === 'ok' && lastResult.action === 'load' && lastResult.credentials && lastResult.credentials.length > 0 && ( )}
<_CredentialsBox credentials={lastResult.credentials} /> {lastResult.status === 'ok' && lastResult.action === 'load' && lastResult.credentials && lastResult.credentials.length > 0 && (
)} <_CredentialsBox credentials={lastResult.credentials} />
</div> )}
)}
{loading && configs.length === 0 ? (
<div className={demoStyles.loadingState}>{t('Lade…')}</div>
) : configs.length === 0 ? (
<div className={demoStyles.emptyState}>{t('Keine Demo-Konfigurationen gefunden.')}</div>
) : (
<div className={demoStyles.configGrid}>
{configs.map((cfg) => (
<div key={cfg.code} className={demoStyles.configCard}>
<div className={demoStyles.cardIcon}><FaCubes /></div>
<div className={demoStyles.cardContent}>
<h3 className={demoStyles.cardTitle}>{cfg.label}</h3>
<p className={demoStyles.cardDescription}>{cfg.description}</p>
<span className={demoStyles.cardCode}>{cfg.code}</span>
{cfg.credentials && cfg.credentials.length > 0 && (
<_CredentialsBox credentials={cfg.credentials} compact />
)}
</div>
<div className={demoStyles.cardActions}>
<button
className={demoStyles.loadButton}
onClick={() => _handleLoad(cfg.code)}
disabled={actionInProgress !== null}
>
{actionInProgress === cfg.code ? <FaSync className={demoStyles.spin} /> : <FaPlay />}
{t('Laden')}
</button>
<button
className={demoStyles.removeButton}
onClick={() => _handleRemove(cfg.code)}
disabled={actionInProgress !== null}
>
<FaTrash />
{t('Entfernen')}
</button>
</div>
</div> </div>
))} </Panel>
</div> )}
)}
<Panel variant="dashboard">
{loading && configs.length === 0 ? (
<div className={demoStyles.loadingState}>{t('Lade…')}</div>
) : configs.length === 0 ? (
<div className={demoStyles.emptyState}>{t('Keine Demo-Konfigurationen gefunden.')}</div>
) : (
<div className={demoStyles.configGrid}>
{configs.map((cfg) => (
<div key={cfg.code} className={demoStyles.configCard}>
<div className={demoStyles.cardIcon}><FaCubes /></div>
<div className={demoStyles.cardContent}>
<h3 className={demoStyles.cardTitle}>{cfg.label}</h3>
<p className={demoStyles.cardDescription}>{cfg.description}</p>
<span className={demoStyles.cardCode}>{cfg.code}</span>
{cfg.credentials && cfg.credentials.length > 0 && (
<_CredentialsBox credentials={cfg.credentials} compact />
)}
</div>
<div className={demoStyles.cardActions}>
<button
className={demoStyles.loadButton}
onClick={() => _handleLoad(cfg.code)}
disabled={actionInProgress !== null}
>
{actionInProgress === cfg.code ? <FaSync className={demoStyles.spin} /> : <FaPlay />}
{t('Laden')}
</button>
<button
className={demoStyles.removeButton}
onClick={() => _handleRemove(cfg.code)}
disabled={actionInProgress !== null}
>
<FaTrash />
{t('Entfernen')}
</button>
</div>
</div>
))}
</div>
)}
</Panel>
</StackLayout.Body>
<ConfirmDialog /> <ConfirmDialog />
</div> </StackLayout>
); );
}; };

View file

@ -14,6 +14,8 @@ import { useFeatureStore } from '../../stores/featureStore';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa'; import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import api from '../../api'; import api from '../../api';
import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { resolveColumnTypes } from '../../utils/columnTypeResolver';
@ -262,29 +264,34 @@ export const AdminFeatureAccessPage: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}>Fehler: {error}</p> <div className={styles.errorContainer}>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}> <span className={styles.errorIcon}></span>
<FaSync /> {t('Erneut versuchen')} <p className={styles.errorMessage}>{t('Fehler')}: {error}</p>
</button> <button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <>
<div className={styles.pageHeader}> <StackLayout variant="table">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Feature-Instanzen')}</h1> <h1 className={styles.pageTitle}>{t('Feature-Instanzen')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie Feature-Instanzen für jeden')}</p> <p className={styles.pageSubtitle}>{t('Verwalten Sie Feature-Instanzen für jeden')}</p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
{/* Mandate Selector */} <Panel variant="toolbar">
<div className={styles.filterSection}> <div className={styles.filterSection}>
<div className={styles.filterGroup}> <div className={styles.filterGroup}>
<label className={styles.filterLabel}> <label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} /> <FaBuilding style={{ marginRight: 8 }} />
@ -327,11 +334,12 @@ export const AdminFeatureAccessPage: React.FC = () => {
</button> </button>
</div> </div>
)} )}
</div> </div>
</Panel>
{/* Available Features Info / Empty Features Warning */}
{features.length > 0 ? ( {features.length > 0 ? (
<div className={styles.infoBox}> <Panel variant="card">
<div className={styles.infoBox}>
<FaCube style={{ marginRight: 8 }} /> <FaCube style={{ marginRight: 8 }} />
<span>{t('Verfügbare Features')} </span> <span>{t('Verfügbare Features')} </span>
{features.map((f, i) => ( {features.map((f, i) => (
@ -341,7 +349,9 @@ export const AdminFeatureAccessPage: React.FC = () => {
</span> </span>
))} ))}
</div> </div>
</Panel>
) : selectedMandateId && !loading ? ( ) : selectedMandateId && !loading ? (
<Panel variant="card">
<div className={styles.infoBox} style={{ borderColor: 'var(--error-color, #dc3545)', backgroundColor: 'var(--error-bg, rgba(220, 53, 69, 0.1))' }}> <div className={styles.infoBox} style={{ borderColor: 'var(--error-color, #dc3545)', backgroundColor: 'var(--error-bg, rgba(220, 53, 69, 0.1))' }}>
<FaCube style={{ marginRight: 8 }} /> <FaCube style={{ marginRight: 8 }} />
<span> <span>
@ -358,23 +368,26 @@ export const AdminFeatureAccessPage: React.FC = () => {
<FaSync /> {t('Features erneut laden')} <FaSync /> {t('Features erneut laden')}
</button> </button>
</div> </div>
</Panel>
) : null} ) : null}
{/* Content */}
{!selectedMandateId ? ( {!selectedMandateId ? (
<div className={styles.emptyState}> <Panel variant="card">
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} /> <FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3> <h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}> <p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.')} {t('Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.')}
</p> </p>
</div> </div>
</Panel>
) : ( ) : (
<div className={styles.tableContainer}> <Panel variant="table">
<FormGeneratorTable <FormGeneratorTable
data={instances} data={instances}
columns={columns} columns={columns}
apiEndpoint="/api/features/instances" apiEndpoint="/api/features/instances"
filterScopeKey={selectedMandateId || 'admin'}
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
@ -419,8 +432,10 @@ export const AdminFeatureAccessPage: React.FC = () => {
}} }}
emptyMessage={t('Keine Feature-Instanzen gefunden')} emptyMessage={t('Keine Feature-Instanzen gefunden')}
/> />
</div> </Panel>
)} )}
</StackLayout.Body>
</StackLayout>
{/* Create Instance Modal */} {/* Create Instance Modal */}
{showCreateModal && ( {showCreateModal && (
@ -563,7 +578,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -13,6 +13,8 @@ import { useUserMandates } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa'; import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useFeatureStore } from '../../stores/featureStore'; import { useFeatureStore } from '../../stores/featureStore';
import api from '../../api'; import api from '../../api';
@ -403,31 +405,36 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
if (error && !selectedCombinedKey) { if (error && !selectedCombinedKey) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}> {t('Fehler')}: {error}
<FaSync /> {t('Erneut versuchen')} </p>
</button> <button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <>
<div className={styles.pageHeader}> <StackLayout variant="table">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Feature-Instanz-Benutzer')}</h1> <h1 className={styles.pageTitle}>{t('Feature-Instanz-Benutzer')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie Benutzerzugriffe auf Feature-Instanzen')}</p> <p className={styles.pageSubtitle}>{t('Verwalten Sie Benutzerzugriffe auf Feature-Instanzen')}</p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
{/* Combined Selector: Mandate + Feature Instance */} <Panel variant="toolbar">
<div className={styles.filterSection}> <div className={styles.filterSection}>
<div className={styles.filterGroup} style={{ flex: 1, maxWidth: 500 }}> <div className={styles.filterGroup} style={{ flex: 1, maxWidth: 500 }}>
<label className={styles.filterLabel}> <label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} /> <FaCube style={{ marginRight: 8 }} />
@ -481,11 +488,12 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
</button> </button>
</div> </div>
)} )}
</div> </div>
</Panel>
{/* Info box when instance is selected */}
{selectedOption && ( {selectedOption && (
<div className={styles.infoBox}> <Panel variant="card">
<div className={styles.infoBox}>
<FaBuilding style={{ marginRight: 8 }} /> <FaBuilding style={{ marginRight: 8 }} />
<span> <span>
{t('Mandant')}: <strong>{selectedOption.mandateName}</strong> {t('Mandant')}: <strong>{selectedOption.mandateName}</strong>
@ -496,11 +504,12 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{t('Instanz')}: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode}) {t('Instanz')}: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode})
</span> </span>
</div> </div>
</Panel>
)} )}
{/* Roles info box */}
{selectedInstance && instanceRoles.length > 0 && ( {selectedInstance && instanceRoles.length > 0 && (
<div className={styles.infoBox}> <Panel variant="card">
<div className={styles.infoBox}>
<span>{t('Verfügbare Rollen')} </span> <span>{t('Verfügbare Rollen')} </span>
{instanceRoles.map((r, i) => ( {instanceRoles.map((r, i) => (
<span key={r.id}> <span key={r.id}>
@ -509,19 +518,21 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
</span> </span>
))} ))}
</div> </div>
</Panel>
)} )}
{/* Warning if no roles available */}
{selectedInstance && instanceRoles.length === 0 && !usersLoading && ( {selectedInstance && instanceRoles.length === 0 && !usersLoading && (
<Panel variant="card">
<div className={styles.infoBox} style={{ borderColor: 'var(--warning-color, #d69e2e)', backgroundColor: 'var(--warning-bg, rgba(214, 158, 46, 0.12))' }}> <div className={styles.infoBox} style={{ borderColor: 'var(--warning-color, #d69e2e)', backgroundColor: 'var(--warning-bg, rgba(214, 158, 46, 0.12))' }}>
<span> </span> <span> </span>
<span>{t('Diese Instanz hat noch keine')}</span> <span>{t('Diese Instanz hat noch keine')}</span>
</div> </div>
</Panel>
)} )}
{/* Content */}
{!selectedCombinedKey ? ( {!selectedCombinedKey ? (
<div className={styles.emptyState}> <Panel variant="card">
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} /> <FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Keine Feature-Instanz ausgewählt')}</h3> <h3 className={styles.emptyTitle}>{t('Keine Feature-Instanz ausgewählt')}</h3>
<p className={styles.emptyDescription}> <p className={styles.emptyDescription}>
@ -530,12 +541,14 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
: t('Wählen Sie eine Feature-Instanz aus')} : t('Wählen Sie eine Feature-Instanz aus')}
</p> </p>
</div> </div>
</Panel>
) : ( ) : (
<div className={styles.tableContainer}> <Panel variant="table">
<FormGeneratorTable <FormGeneratorTable
data={instanceUsers} data={instanceUsers}
columns={columns} columns={columns}
apiEndpoint={selectedInstanceId ? `/api/features/instances/${selectedInstanceId}/users` : undefined} apiEndpoint={selectedInstanceId ? `/api/features/instances/${selectedInstanceId}/users` : undefined}
filterScopeKey={selectedMandateId || 'admin'}
loading={usersLoading} loading={usersLoading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
@ -570,8 +583,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
}} }}
emptyMessage={t('Keine Benutzer gefunden')} emptyMessage={t('Keine Benutzer gefunden')}
/> />
</div> </Panel>
)} )}
</StackLayout.Body>
</StackLayout>
{/* Add User Modal */} {/* Add User Modal */}
{showAddModal && ( {showAddModal && (
@ -635,7 +650,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -17,6 +17,8 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { AccessRulesEditor } from '../../components/AccessRules'; import { AccessRulesEditor } from '../../components/AccessRules';
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa'; import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import api from '../../api'; import api from '../../api';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
@ -284,97 +286,106 @@ export const AdminFeatureRolesPage: React.FC = () => {
if (error && !selectedFeatureCode) { if (error && !selectedFeatureCode) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}>{error}</p> <div className={styles.errorContainer}>
<button className={styles.secondaryButton} onClick={() => window.location.reload()}> <span className={styles.errorIcon}></span>
<FaSync /> {t('Erneut versuchen')} <p className={styles.errorMessage}>{error}</p>
</button> <button className={styles.secondaryButton} onClick={() => window.location.reload()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <>
<div className={styles.pageHeader}> <StackLayout variant="table">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Feature-Rollen-Rechte')}</h1> <h1 className={styles.pageTitle}>{t('Feature-Rollen-Rechte')}</h1>
<p className={styles.pageSubtitle}>{t('Template-Rollen und deren Berechtigungen für')}</p> <p className={styles.pageSubtitle}>{t('Template-Rollen und deren Berechtigungen für')}</p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
{t('Feature:')}
</label>
<select
className={styles.filterSelect}
value={selectedFeatureCode}
onChange={(e) => setSelectedFeatureCode(e.target.value)}
>
<option value="">{t('Feature wählen')}</option>
{features.map(f => {
const featureCode = f.code || f.featureCode || '';
return (
<option key={featureCode} value={featureCode}>
{getFeatureName(f)} ({featureCode})
</option>
);
})}
</select>
</div>
{/* Feature Selector */} {selectedFeatureCode && (
<div className={styles.filterSection}> <div className={styles.headerActions}>
<div className={styles.filterGroup}> <button
<label className={styles.filterLabel}> className={styles.secondaryButton}
<FaCube style={{ marginRight: 8 }} /> onClick={() => fetchRoles()}
{t('Feature:')} disabled={loading}
</label> >
<select <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
className={styles.filterSelect} </button>
value={selectedFeatureCode} <button
onChange={(e) => setSelectedFeatureCode(e.target.value)} className={styles.primaryButton}
> onClick={() => setShowCreateModal(true)}
<option value="">{t('Feature wählen')}</option> >
{features.map(f => { <FaPlus /> {t('Neue Feature-Rolle')}
const featureCode = f.code || f.featureCode || ''; </button>
return ( </div>
<option key={featureCode} value={featureCode}> )}
{getFeatureName(f)} ({featureCode}) </div>
</option> </Panel>
);
})}
</select>
</div>
{selectedFeatureCode && ( {selectedFeatureCode && (
<div className={styles.headerActions}> <Panel variant="card">
<button <div className={styles.infoBox}>
className={styles.secondaryButton} <FaUserShield style={{ marginRight: 8 }} />
onClick={() => fetchRoles()} <span>
disabled={loading} <strong>{t('Feature-Template-Rollen')}</strong>{' '}
> {t('werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert. Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus.')}
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')} </span>
</button> </div>
<button </Panel>
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neue Feature-Rolle')}
</button>
</div>
)} )}
</div>
{/* Info Box */} {!selectedFeatureCode ? (
{selectedFeatureCode && ( <Panel variant="card">
<div className={styles.infoBox}> <div className={styles.emptyState}>
<FaUserShield style={{ marginRight: 8 }} /> <FaCube className={styles.emptyIcon} />
<span> <h3 className={styles.emptyTitle}>{t('Kein Feature ausgewählt')}</h3>
<strong>{t('Feature-Template-Rollen')}</strong>{' '} <p className={styles.emptyDescription}>
{t('werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert. Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus.')} {t('Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.')}
</span> </p>
</div> </div>
)} </Panel>
) : (
{/* Content */} <Panel variant="table">
{!selectedFeatureCode ? ( <FormGeneratorTable
<div className={styles.emptyState}> data={roles}
<FaCube className={styles.emptyIcon} /> columns={columns}
<h3 className={styles.emptyTitle}>{t('Kein Feature ausgewählt')}</h3> apiEndpoint="/api/features/templates/roles"
<p className={styles.emptyDescription}> filterScopeKey={selectedFeatureCode || 'admin'}
{t('Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.')} loading={loading}
</p>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/features/templates/roles"
loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
searchable={true} searchable={true}
@ -407,9 +418,11 @@ export const AdminFeatureRolesPage: React.FC = () => {
handleDelete: handleDeleteRole, handleDelete: handleDeleteRole,
}} }}
emptyMessage={t('Keine Feature-Rollen gefunden')} emptyMessage={t('Keine Feature-Rollen gefunden')}
/> />
</div> </Panel>
)} )}
</StackLayout.Body>
</StackLayout>
{/* Create Role Modal */} {/* Create Role Modal */}
{showCreateModal && ( {showCreateModal && (
@ -511,7 +524,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -13,6 +13,8 @@ import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMan
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa'; import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi'; import { fetchAttributes } from '../../api/attributesApi';
@ -202,104 +204,112 @@ export const AdminInvitationsPage: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}> {t('Fehler')}: {error}
<FaSync /> {t('Erneut versuchen')} </p>
</button> <button className={styles.secondaryButton} onClick={() => fetchMandates()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <>
<div className={styles.pageHeader}> <StackLayout variant="table">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Einladungen')}</h1> <h1 className={styles.pageTitle}>{t('Einladungen')}</h1>
<p className={styles.pageSubtitle}>{t('Erstellen und verwalten Sie Einladungen')}</p> <p className={styles.pageSubtitle}>{t('Erstellen und verwalten Sie Einladungen')}</p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
{t('Mandant')}:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{mandateDisplayLabel(m)}
</option>
))}
</select>
</div>
{/* Mandate Selector and Filters */} <div className={styles.filterGroup}>
<div className={styles.filterSection}> <label className={styles.checkboxLabel}>
<div className={styles.filterGroup}> <input
<label className={styles.filterLabel}> type="checkbox"
<FaBuilding style={{ marginRight: 8 }} /> checked={showExpired}
{t('Mandant')}: onChange={(e) => setShowExpired(e.target.checked)}
</label> />
<select {t('Abgelaufene anzeigen')}
className={styles.filterSelect} </label>
value={selectedMandateId} <label className={styles.checkboxLabel}>
onChange={(e) => setSelectedMandateId(e.target.value)} <input
> type="checkbox"
<option value="">{t('Mandant wählen')}</option> checked={showUsed}
{mandates.map(m => ( onChange={(e) => setShowUsed(e.target.checked)}
<option key={m.id} value={m.id}> />
{mandateDisplayLabel(m)} {t('Verwendete anzeigen')}
</option> </label>
))} </div>
</select>
</div>
<div className={styles.filterGroup}> {selectedMandateId && (
<label className={styles.checkboxLabel}> <div className={styles.headerActions}>
<input <button
type="checkbox" className={styles.secondaryButton}
checked={showExpired} onClick={() => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed })}
onChange={(e) => setShowExpired(e.target.checked)} disabled={loading}
/> >
{t('Abgelaufene anzeigen')} <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</label> </button>
<label className={styles.checkboxLabel}> <button
<input className={styles.primaryButton}
type="checkbox" onClick={() => setShowCreateModal(true)}
checked={showUsed} >
onChange={(e) => setShowUsed(e.target.checked)} <FaPlus /> {t('Neue Einladung')}
/> </button>
{t('Verwendete anzeigen')} </div>
</label> )}
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed })}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neue Einladung')}
</button>
</div> </div>
)} </Panel>
</div>
{/* Content */} {!selectedMandateId ? (
{!selectedMandateId ? ( <Panel variant="card">
<div className={styles.emptyState}> <div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} /> <FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3> <h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}> <p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.')} {t('Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.')}
</p> </p>
</div> </div>
) : ( </Panel>
<div className={styles.tableContainer}> ) : (
<FormGeneratorTable <Panel variant="table">
data={invitations} <FormGeneratorTable
columns={columns} data={invitations}
apiEndpoint="/api/invitations/" columns={columns}
loading={loading} apiEndpoint="/api/invitations/"
filterScopeKey={selectedMandateId || 'admin'}
loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
searchable={true} searchable={true}
@ -326,9 +336,11 @@ export const AdminInvitationsPage: React.FC = () => {
pagination, pagination,
}} }}
emptyMessage={t('Keine Einladungen gefunden')} emptyMessage={t('Keine Einladungen gefunden')}
/> />
</div> </Panel>
)} )}
</StackLayout.Body>
</StackLayout>
{/* Create Invitation Modal */} {/* Create Invitation Modal */}
{showCreateModal && ( {showCreateModal && (
@ -429,7 +441,7 @@ export const AdminInvitationsPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -16,6 +16,8 @@ import type { AttributeDefinition } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
type LangRow = { type LangRow = {
id: string; id: string;
@ -869,61 +871,66 @@ export const AdminLanguagesPage: React.FC = () => {
const isBusy = progress !== null; const isBusy = progress !== null;
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem', position: 'relative' }}> <StackLayout variant="table" className="" style={{ position: 'relative' }}>
<header> <StackLayout.Header>
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1> <div>
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p> <h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
{error && !progress && ( <p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
<p style={{ color: 'var(--error-color, #c53030)' }}> {error && !progress && (
{error} <p style={{ color: 'var(--error-color, #c53030)' }}>
</p> {error}
)} </p>
</header> )}
</div>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
<button type="button" className={styles.secondaryButton} onClick={_load} disabled={isBusy || loading} title={t('Daten neu laden')}>
<FaSync style={loading ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('Suche…')}
style={{ padding: '0.35rem 0.5rem', minWidth: 140, maxWidth: 200 }}
/>
<button type="button" className={styles.primaryButton} onClick={_updateAll} disabled={isBusy}>
{t('Alle aktualisieren')}
</button>
<button type="button" className={styles.secondaryButton} onClick={_exportAll} disabled={isBusy}>
<FaFileExport /> {t('Export')}
</button>
<button type="button" className={styles.secondaryButton} onClick={_importFile} disabled={isBusy}>
<FaFileImport /> {t('Import')}
</button>
<span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} />
<span style={{ opacity: 0.7 }}>{t('Neue Sprache')}</span>
<select
value={addCode}
onChange={(e) => setAddCode(e.target.value)}
style={{ padding: '0.35rem 0.5rem' }}
disabled={isBusy}
>
{addChoices.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
<button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0 || isBusy}>
{t('Hinzufügen')}
</button>
</div>
</Panel>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}> <Panel variant="table" className="" style={{ position: 'relative' }}>
<button type="button" className={styles.secondaryButton} onClick={_load} disabled={isBusy || loading} title={t('Daten neu laden')}> <FormGeneratorTable
<FaSync style={loading ? { animation: 'spin 1s linear infinite' } : undefined} /> data={displayRows}
</button> columns={columns}
<input filterScopeKey="admin"
type="text" loading={loading}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('Suche…')}
style={{ padding: '0.35rem 0.5rem', minWidth: 140, maxWidth: 200 }}
/>
<button type="button" className={styles.primaryButton} onClick={_updateAll} disabled={isBusy}>
{t('Alle aktualisieren')}
</button>
<button type="button" className={styles.secondaryButton} onClick={_exportAll} disabled={isBusy}>
<FaFileExport /> {t('Export')}
</button>
<button type="button" className={styles.secondaryButton} onClick={_importFile} disabled={isBusy}>
<FaFileImport /> {t('Import')}
</button>
<span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} />
<span style={{ opacity: 0.7 }}>{t('Neue Sprache')}</span>
<select
value={addCode}
onChange={(e) => setAddCode(e.target.value)}
style={{ padding: '0.35rem 0.5rem' }}
disabled={isBusy}
>
{addChoices.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
<button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0 || isBusy}>
{t('Hinzufügen')}
</button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<FormGeneratorTable
data={displayRows}
columns={columns}
loading={loading}
pagination={false} pagination={false}
selectable={false} selectable={false}
searchable={false} searchable={false}
@ -960,11 +967,11 @@ export const AdminLanguagesPage: React.FC = () => {
emptyMessage={t('Keine Einträge')} emptyMessage={t('Keine Einträge')}
/> />
{progress && <_ProgressOverlay progress={progress} onAbort={_abortRunning} />} {progress && <_ProgressOverlay progress={progress} onAbort={_abortRunning} />}
</div> </Panel>
</StackLayout.Body>
<ConfirmDialog /> <ConfirmDialog />
</div> </StackLayout>
); );
}; };

View file

@ -10,6 +10,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { FaSync, FaDownload } from 'react-icons/fa'; import { FaSync, FaDownload } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import api from '../../api'; import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import logStyles from './AdminLogsPage.module.css'; import logStyles from './AdminLogsPage.module.css';
@ -107,8 +109,8 @@ export const AdminLogsPage: React.FC = () => {
}; };
return ( return (
<div className={styles.adminPage}> <StackLayout variant="scroll">
<div className={styles.pageHeader}> <StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Gateway-Logs')}</h1> <h1 className={styles.pageTitle}>{t('Gateway-Logs')}</h1>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>
@ -126,88 +128,95 @@ export const AdminLogsPage: React.FC = () => {
<FaDownload /> {t('Download')} <FaDownload /> {t('Download')}
</button> </button>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
<div className={logStyles.controls}> <Panel variant="toolbar">
<div className={logStyles.loadGroup}> <div className={logStyles.controls}>
<label className={logStyles.controlLabel}>{t('Letzte')}</label> <div className={logStyles.loadGroup}>
<input <label className={logStyles.controlLabel}>{t('Letzte')}</label>
type="number" <input
className={logStyles.countInput} type="number"
value={countInput} className={logStyles.countInput}
onChange={(e) => setCountInput(e.target.value)} value={countInput}
onKeyDown={_handleKeyDown} onChange={(e) => setCountInput(e.target.value)}
min={1} onKeyDown={_handleKeyDown}
max={50000} min={1}
/> max={50000}
<label className={logStyles.controlLabel}>{t('Einträge')}</label> />
<button <label className={logStyles.controlLabel}>{t('Einträge')}</label>
className={styles.primaryButton} <button
onClick={_handleLoad} className={styles.primaryButton}
disabled={loading} onClick={_handleLoad}
> disabled={loading}
<FaSync className={loading ? 'spinning' : ''} /> {t('Laden')} >
</button> <FaSync className={loading ? 'spinning' : ''} /> {t('Laden')}
</div> </button>
<div className={logStyles.refreshGroup}>
<label className={logStyles.toggleLabel}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
{t('Auto-Refresh (5s)')}
</label>
</div>
</div>
{error && (
<div
className={styles.infoBox}
style={{
background: 'var(--warning-bg, #fffbeb)',
borderColor: 'var(--warning-color, #d69e2e)',
}}
>
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
{error}
</div>
)}
<div
ref={logContainerRef}
className={logStyles.logContainer}
onScroll={_handleScroll}
>
{lines.length === 0 && !loading && (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📋</div>
<p className={styles.emptyTitle}>{t('Keine Logs geladen')}</p>
<p className={styles.emptyDescription}>{t('Gib die gewünschte Anzahl Einträge ein und klicke auf „Laden“.')}</p>
</div>
)}
{loading && lines.length === 0 && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Logs werden geladen')}</span>
</div>
)}
{lines.map((line, idx) => {
const level = _parseLogLevel(line);
const color = level ? LOG_LEVEL_COLORS[level] : undefined;
return (
<div
key={idx}
className={logStyles.logLine}
style={color ? { color } : undefined}
>
{line}
</div> </div>
);
})} <div className={logStyles.refreshGroup}>
</div> <label className={logStyles.toggleLabel}>
</div> <input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
{t('Auto-Refresh (5s)')}
</label>
</div>
</div>
</Panel>
{error && (
<Panel variant="card">
<div
className={styles.infoBox}
style={{
background: 'var(--warning-bg, #fffbeb)',
borderColor: 'var(--warning-color, #d69e2e)',
}}
>
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
{error}
</div>
</Panel>
)}
<Panel variant="editor">
<div
ref={logContainerRef}
className={logStyles.logContainer}
onScroll={_handleScroll}
>
{lines.length === 0 && !loading && (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📋</div>
<p className={styles.emptyTitle}>{t('Keine Logs geladen')}</p>
<p className={styles.emptyDescription}>{t('Gib die gewünschte Anzahl Einträge ein und klicke auf „Laden“.')}</p>
</div>
)}
{loading && lines.length === 0 && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Logs werden geladen')}</span>
</div>
)}
{lines.map((line, idx) => {
const level = _parseLogLevel(line);
const color = level ? LOG_LEVEL_COLORS[level] : undefined;
return (
<div
key={idx}
className={logStyles.logLine}
style={color ? { color } : undefined}
>
{line}
</div>
);
})}
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
}; };

View file

@ -35,6 +35,8 @@ import {
FaExclamationTriangle, FaExclamationTriangle,
FaCheckCircle FaCheckCircle
} from 'react-icons/fa'; } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
@ -225,24 +227,28 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
if (error) { if (error) {
return ( return (
<div className={styles.adminPage}> <StackLayout variant="scroll">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler beim Laden')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button className={styles.secondaryButton} onClick={handleRefresh}> {t('Fehler beim Laden')}: {error}
<FaSync /> {t('Erneut versuchen')} </p>
</button> <button className={styles.secondaryButton} onClick={handleRefresh}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={styles.adminPage}> <>
{/* Header */} <StackLayout variant="scroll">
<div className={styles.pageHeader}> <StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}> <h1 className={styles.pageTitle}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} /> <FaShieldAlt style={{ marginRight: '0.5rem' }} />
@ -269,10 +275,10 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')} <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
{/* Filters */} <Panel variant="toolbar">
<div className={styles.filterBar}> <div className={styles.filterBar}>
<div className={styles.filterGroup}> <div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Mandant')}</label> <label className={styles.filterLabel}>{t('Mandant')}</label>
<select <select
@ -303,8 +309,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</select> </select>
</div> </div>
</div> </div>
</Panel>
{/* Info Box */} <Panel variant="card">
<div className={styles.infoBox}> <div className={styles.infoBox}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} /> <FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span> <span>
@ -314,17 +321,19 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')} <strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
</span> </span>
</div> </div>
</Panel>
{/* Loading State */}
{loading && ( {loading && (
<Panel variant="card">
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner} /> <div className={styles.spinner} />
<span>{t('Lade Rollen')}</span> <span>{t('Lade Rollen')}</span>
</div> </div>
</Panel>
)} )}
{/* Empty State */}
{!loading && roles.length === 0 && ( {!loading && roles.length === 0 && (
<Panel variant="card">
<div className={styles.emptyState}> <div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} /> <FaUserShield className={styles.emptyIcon} />
<p>{t('Keine Rollen gefunden')}</p> <p>{t('Keine Rollen gefunden')}</p>
@ -336,10 +345,11 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
: t('Es gibt noch keine Rollen')} : t('Es gibt noch keine Rollen')}
</p> </p>
</div> </div>
</Panel>
)} )}
{/* Roles List */}
{!loading && roles.length > 0 && ( {!loading && roles.length > 0 && (
<Panel variant="card">
<div className={styles.rolesList}> <div className={styles.rolesList}>
{roles.map(role => ( {roles.map(role => (
<div key={role.id} className={styles.roleCard}> <div key={role.id} className={styles.roleCard}>
@ -387,7 +397,10 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</div> </div>
))} ))}
</div> </div>
</Panel>
)} )}
</StackLayout.Body>
</StackLayout>
{/* Cleanup Duplicates Modal */} {/* Cleanup Duplicates Modal */}
{showCleanupModal && ( {showCleanupModal && (
@ -598,7 +611,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -22,6 +22,8 @@ import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaShieldAlt, FaCube } from 'react-icons/fa'; import { FaPlus, FaSync, FaUserShield, FaBuilding, FaShieldAlt, FaCube } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi'; import { fetchAttributes } from '../../api/attributesApi';
@ -247,23 +249,28 @@ export const AdminMandateRolesPage: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}> {t('Fehler')}: {error}
<FaSync /> {t('Erneut versuchen')} </p>
</button> <button className={styles.secondaryButton} onClick={() => fetchMandates()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <>
<div className={styles.pageHeader}> <StackLayout variant="table">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Rollen')}</h1> <h1 className={styles.pageTitle}>{t('Rollen')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie systemweite und globale')}</p> <p className={styles.pageSubtitle}>{t('Verwalten Sie systemweite und globale')}</p>
@ -284,91 +291,95 @@ export const AdminMandateRolesPage: React.FC = () => {
<FaCube /> {t('Feature Rollen & Rechte')} <FaCube /> {t('Feature Rollen & Rechte')}
</button> </button>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
{t('Mandant')}:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{mandateDisplayLabel(m)}
</option>
))}
</select>
</div>
{/* Mandate Selector and Filters */} <div className={styles.filterGroup}>
<div className={styles.filterSection}> <label className={styles.filterLabel}>{t('Filter')}</label>
<div className={styles.filterGroup}> <select
<label className={styles.filterLabel}> className={styles.filterSelect}
<FaBuilding style={{ marginRight: 8 }} /> value={scopeFilter}
{t('Mandant')}: onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
</label> style={{ minWidth: 150 }}
<select >
className={styles.filterSelect} <option value="mandate">{t('Mandanten-Rollen')}</option>
value={selectedMandateId} <option value="all">{t('Alle inkl. Templates')}</option>
onChange={(e) => setSelectedMandateId(e.target.value)} <option value="global">{t('Nur Templates')}</option>
> </select>
<option value="">{t('Mandant wählen')}</option> </div>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{mandateDisplayLabel(m)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}> {selectedMandateId && (
<label className={styles.filterLabel}>{t('Filter')}</label> <div className={styles.headerActions}>
<select <button
className={styles.filterSelect} className={styles.secondaryButton}
value={scopeFilter} onClick={() => fetchRoles(selectedMandateId, { scopeFilter })}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')} disabled={loading}
style={{ minWidth: 150 }} >
> <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
<option value="mandate">{t('Mandanten-Rollen')}</option> </button>
<option value="all">{t('Alle inkl. Templates')}</option> <button
<option value="global">{t('Nur Templates')}</option> className={styles.primaryButton}
</select> onClick={() => setShowCreateModal(true)}
</div> >
<FaPlus /> {t('Neue Rolle')}
</button>
</div>
)}
</div>
</Panel>
{selectedMandateId && ( {selectedMandateId && (
<div className={styles.headerActions}> <Panel variant="card">
<button <div className={styles.infoBox}>
className={styles.secondaryButton} <FaUserShield style={{ marginRight: 8 }} />
onClick={() => fetchRoles(selectedMandateId, { scopeFilter })} <span>
disabled={loading} <strong>{t('System-Templates')}</strong>{' '}
> {t('(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. Templates selbst können nicht gelöscht werden.')}{' '}
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')} <strong>{t('Mandanten-Rollen')}</strong>{' '}
</button> {t('gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.')}
<button </span>
className={styles.primaryButton} </div>
onClick={() => setShowCreateModal(true)} </Panel>
>
<FaPlus /> {t('Neue Rolle')}
</button>
</div>
)} )}
</div>
{/* Info Box */} {!selectedMandateId ? (
{selectedMandateId && ( <Panel variant="card">
<div className={styles.infoBox}> <div className={styles.emptyState}>
<FaUserShield style={{ marginRight: 8 }} /> <FaBuilding className={styles.emptyIcon} />
<span> <h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<strong>{t('System-Templates')}</strong>{' '} <p className={styles.emptyDescription}>
{t('(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. Templates selbst können nicht gelöscht werden.')}{' '} {t('Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.')}
<strong>{t('Mandanten-Rollen')}</strong>{' '} </p>
{t('gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.')} </div>
</span> </Panel>
</div> ) : (
)} <Panel variant="table">
<FormGeneratorTable
{/* Content */} data={roles}
{!selectedMandateId ? ( columns={columns}
<div className={styles.emptyState}> apiEndpoint="/api/rbac/roles"
<FaBuilding className={styles.emptyIcon} /> filterScopeKey={selectedMandateId || 'admin'}
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3> loading={loading}
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.')}
</p>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/rbac/roles"
loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
searchable={true} searchable={true}
@ -395,9 +406,11 @@ export const AdminMandateRolesPage: React.FC = () => {
handleDelete: handleDeleteRole, handleDelete: handleDeleteRole,
}} }}
emptyMessage={t('Keine Rollen gefunden')} emptyMessage={t('Keine Rollen gefunden')}
/> />
</div> </Panel>
)} )}
</StackLayout.Body>
</StackLayout>
{/* Create Role Modal */} {/* Create Role Modal */}
{showCreateModal && ( {showCreateModal && (
@ -481,7 +494,7 @@ export const AdminMandateRolesPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -20,6 +20,8 @@ import { usePrompt } from '../../hooks/usePrompt';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa'; import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { getUserDataCache } from '../../utils/userCache'; import { getUserDataCache } from '../../utils/userCache';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
@ -182,23 +184,28 @@ export const AdminMandatesPage: React.FC = () => {
if (error) { if (error) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler beim Laden der Mandanten')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button className={styles.secondaryButton} onClick={() => refetch()}> {t('Fehler beim Laden der Mandanten')}: {error}
<FaSync /> {t('Erneut versuchen')} </p>
</button> <button className={styles.secondaryButton} onClick={() => refetch()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <>
<div className={styles.pageHeader}> <StackLayout variant="table">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Mandanten')}</h1> <h1 className={styles.pageTitle}>{t('Mandanten')}</h1>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>
@ -209,37 +216,40 @@ export const AdminMandatesPage: React.FC = () => {
)} )}
</p> </p>
</div> </div>
<div className={styles.headerActions}> </StackLayout.Header>
<button <StackLayout.Body>
type="button" <Panel variant="toolbar">
className={styles.secondaryButton} <div className={styles.headerActions}>
onClick={() => navigate('/admin/user-mandates')}
>
<FaUsers /> {t('Benutzer-Zuweisungen')}
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<button <button
className={styles.primaryButton} type="button"
onClick={() => setShowCreateModal(true)} className={styles.secondaryButton}
onClick={() => navigate('/admin/user-mandates')}
> >
<FaPlus /> {t('Neuer Mandant')} <FaUsers /> {t('Benutzer-Zuweisungen')}
</button> </button>
)} <button
</div> className={styles.secondaryButton}
</div> onClick={() => refetch()}
disabled={loading}
<div className={styles.tableContainer}> >
<FormGeneratorTable <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neuer Mandant')}
</button>
)}
</div>
</Panel>
<Panel variant="table">
<FormGeneratorTable
data={mandates} data={mandates}
columns={columns} columns={columns}
apiEndpoint="/api/mandates/" apiEndpoint="/api/mandates/"
filterScopeKey="admin"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
@ -281,7 +291,9 @@ export const AdminMandatesPage: React.FC = () => {
}} }}
emptyMessage={t('Keine Mandanten gefunden')} emptyMessage={t('Keine Mandanten gefunden')}
/> />
</div> </Panel>
</StackLayout.Body>
</StackLayout>
{/* Create Modal */} {/* Create Modal */}
{showCreateModal && ( {showCreateModal && (
@ -382,7 +394,7 @@ export const AdminMandatesPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -7,8 +7,12 @@
* Shows what pages a user can see and what data they can access. * Shows what pages a user can see and what data they can access.
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { FaSync, FaUserShield, FaEye, FaDatabase, FaCube, FaChevronDown, FaChevronRight, FaCheckCircle, FaTimesCircle, FaInfoCircle } from 'react-icons/fa'; import { FaSync, FaUserShield, FaEye, FaDatabase, FaCube, FaChevronDown, FaChevronRight, FaCheckCircle, FaTimesCircle, FaInfoCircle } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { LayoutTabs } from '../../components/Layout/LayoutTabs';
import type { LayoutTabItem } from '../../components/Layout/types';
import api from '../../api'; import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
@ -83,7 +87,7 @@ interface UserAccessOverview {
type TabId = 'overview' | 'ui' | 'data' | 'resources'; type TabId = 'overview' | 'ui' | 'data' | 'resources';
export const AdminUserAccessOverviewPage: React.FC = () => { export const AdminUserAccessOverviewPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const [users, setUsers] = useState<UserOption[]>([]); const [users, setUsers] = useState<UserOption[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>(''); const [selectedUserId, setSelectedUserId] = useState<string>('');
@ -91,7 +95,6 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(true); const [loadingUsers, setLoadingUsers] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [expandedRoles, setExpandedRoles] = useState<Set<string>>(new Set()); const [expandedRoles, setExpandedRoles] = useState<Set<string>>(new Set());
const [expandedMandates, setExpandedMandates] = useState<Set<string>>(new Set()); const [expandedMandates, setExpandedMandates] = useState<Set<string>>(new Set());
@ -579,158 +582,149 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
); );
}; };
const accessTabs: LayoutTabItem[] = useMemo(() => {
if (!overview) return [];
return [
{
id: 'overview',
label: t('Übersicht'),
render: () => <Panel variant="card">{renderOverviewTab()}</Panel>,
},
{
id: 'ui',
label: `${t('UI-Zugriff')} (${overview.uiAccess.length})`,
render: () => <Panel variant="card">{renderUiAccessTab()}</Panel>,
},
{
id: 'data',
label: `${t('Daten-Zugriff')} (${overview.dataAccess.length})`,
render: () => <Panel variant="card">{renderDataAccessTab()}</Panel>,
},
{
id: 'resources',
label: `${t('Ressourcen')} (${overview.resourceAccess.length})`,
render: () => <Panel variant="card">{renderResourceAccessTab()}</Panel>,
},
];
}, [overview, t, expandedRoles, expandedMandates]);
if (error && !overview) { if (error && !overview) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="scroll">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button {t('Fehler')}: {error}
className={styles.secondaryButton} </p>
onClick={() => window.location.reload()} <button
> className={styles.secondaryButton}
<FaSync /> {t('Erneut versuchen')} onClick={() => window.location.reload()}
</button> >
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="scroll">
<div className={styles.pageHeader}> <StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Benutzerzugriffsübersicht')}</h1> <h1 className={styles.pageTitle}>{t('Benutzerzugriffsübersicht')}</h1>
<p className={styles.pageSubtitle}>{t('Zeigt alle Berechtigungen eines Benutzers')}</p> <p className={styles.pageSubtitle}>{t('Zeigt alle Berechtigungen eines Benutzers')}</p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaUserShield style={{ marginRight: '0.5rem' }} />
{t('Benutzer auswählen')}:
</label>
<select
className={styles.filterSelect}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
disabled={loadingUsers}
style={{ minWidth: '300px' }}
>
<option value="">{t('Benutzer wählen')}</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.fullName || user.username} ({user.email})
{user.isSysAdmin && ` [${t('SysAdmin')}]`}
{user.isPlatformAdmin && ` [${t('PlatformAdmin')}]`}
</option>
))}
</select>
</div>
{/* User Selection */} {selectedUserId && (
<div className={styles.filterSection}> <button
<div className={styles.filterGroup}> className={styles.secondaryButton}
<label className={styles.filterLabel}> onClick={() => setSelectedUserId(selectedUserId)}
<FaUserShield style={{ marginRight: '0.5rem' }} /> disabled={loading}
{t('Benutzer auswählen')}: >
</label> <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
<select </button>
className={styles.filterSelect}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
disabled={loadingUsers}
style={{ minWidth: '300px' }}
>
<option value="">{t('Benutzer wählen')}</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.fullName || user.username} ({user.email})
{user.isSysAdmin && ` [${t('SysAdmin')}]`}
{user.isPlatformAdmin && ` [${t('PlatformAdmin')}]`}
</option>
))}
</select>
</div>
{selectedUserId && (
<button
className={styles.secondaryButton}
onClick={() => setSelectedUserId(selectedUserId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
)}
</div>
{/* Content */}
{!selectedUserId ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Benutzer auswählen')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Benutzer aus, um dessen Zugriffsberechtigungen anzuzeigen.')}
</p>
</div>
) : loading ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Zugriffsübersicht')}</span>
</div>
) : overview ? (
<>
{/* User Info */}
<div className={styles.infoBox} style={{ marginBottom: '1rem', flexShrink: 0 }}>
<strong>{overview.user.fullName || overview.user.username}</strong>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span>{overview.user.email}</span>
{overview.isSysAdmin && (
<>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span className={styles.badge} style={{ background: '#f59e0b', color: 'white' }}>
{t('SysAdmin')}
</span>
</>
)}
{overview.isPlatformAdmin && (
<>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span className={styles.badge} style={{ background: '#3b82f6', color: 'white' }}>
{t('PlatformAdmin')}
</span>
</>
)} )}
</div> </div>
</Panel>
{/* Tabs */} {!selectedUserId ? (
<div style={{ <Panel variant="card">
display: 'flex', <div className={styles.emptyState}>
gap: '0.5rem', <FaUserShield className={styles.emptyIcon} />
marginBottom: '1rem', <h3 className={styles.emptyTitle}>{t('Benutzer auswählen')}</h3>
borderBottom: '1px solid var(--border-color)', <p className={styles.emptyDescription}>
paddingBottom: '0.5rem', {t('Wählen Sie einen Benutzer aus, um dessen Zugriffsberechtigungen anzuzeigen.')}
flexShrink: 0 </p>
}}> </div>
<button </Panel>
className={activeTab === 'overview' ? styles.primaryButton : styles.secondaryButton} ) : loading ? (
onClick={() => setActiveTab('overview')} <Panel variant="card">
style={{ padding: '0.5rem 1rem' }} <div className={styles.loadingContainer}>
> <div className={styles.spinner} />
<FaUserShield /> {t('Übersicht')} <span>{t('Lade Zugriffsübersicht')}</span>
</button> </div>
<button </Panel>
className={activeTab === 'ui' ? styles.primaryButton : styles.secondaryButton} ) : overview ? (
onClick={() => setActiveTab('ui')} <>
style={{ padding: '0.5rem 1rem' }} <Panel variant="card">
> <div className={styles.infoBox}>
<FaEye /> {t('UI-Zugriff')} ({overview.uiAccess.length}) <strong>{overview.user.fullName || overview.user.username}</strong>
</button> <span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<button <span>{overview.user.email}</span>
className={activeTab === 'data' ? styles.primaryButton : styles.secondaryButton} {overview.isSysAdmin && (
onClick={() => setActiveTab('data')} <>
style={{ padding: '0.5rem 1rem' }} <span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
> <span className={styles.badge} style={{ background: '#f59e0b', color: 'white' }}>
<FaDatabase /> {t('Daten-Zugriff')} ({overview.dataAccess.length}) {t('SysAdmin')}
</button> </span>
<button </>
className={activeTab === 'resources' ? styles.primaryButton : styles.secondaryButton} )}
onClick={() => setActiveTab('resources')} {overview.isPlatformAdmin && (
style={{ padding: '0.5rem 1rem' }} <>
> <span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<FaCube /> {t('Ressourcen')} ({overview.resourceAccess.length}) <span className={styles.badge} style={{ background: '#3b82f6', color: 'white' }}>
</button> {t('PlatformAdmin')}
</div> </span>
</>
)}
</div>
</Panel>
{/* Tab Content */} <LayoutTabs items={accessTabs} urlParam="tab" defaultTab="overview" />
<div className={styles.tableContainer}> </>
{activeTab === 'overview' && renderOverviewTab()} ) : null}
{activeTab === 'ui' && renderUiAccessTab()} </StackLayout.Body>
{activeTab === 'data' && renderDataAccessTab()} </StackLayout>
{activeTab === 'resources' && renderResourceAccessTab()}
</div>
</>
) : null}
</div>
); );
}; };

View file

@ -12,6 +12,8 @@ import { useUserMandates, type MandateUser, type Mandate, type Role, type Pagina
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa'; import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi'; import { fetchAttributes } from '../../api/attributesApi';
@ -254,86 +256,94 @@ export const AdminUserMandatesPage: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}> {t('Fehler')}: {error}
<FaSync /> {t('Erneut versuchen')} </p>
</button> <button className={styles.secondaryButton} onClick={() => fetchMandates()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <>
<div className={styles.pageHeader}> <StackLayout variant="table">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Mandanten-Mitglieder')}</h1> <h1 className={styles.pageTitle}>{t('Mandanten-Mitglieder')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie, welche Benutzer Zugriff')}</p> <p className={styles.pageSubtitle}>{t('Verwalten Sie, welche Benutzer Zugriff')}</p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
{t('Mandant auswählen')}:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{mandateDisplayLabel(m)}
</option>
))}
</select>
</div>
{/* Mandate Selector */} {selectedMandateId && (
<div className={styles.filterSection}> <div className={styles.headerActions}>
<div className={styles.filterGroup}> <button
<label className={styles.filterLabel}> className={styles.secondaryButton}
<FaBuilding style={{ marginRight: 8 }} /> onClick={() => fetchMandateUsers(selectedMandateId)}
{t('Mandant auswählen')}: disabled={loading}
</label> >
<select <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
className={styles.filterSelect} </button>
value={selectedMandateId} <button
onChange={(e) => setSelectedMandateId(e.target.value)} className={styles.primaryButton}
> onClick={() => setShowAddModal(true)}
<option value="">{t('Mandant wählen')}</option> disabled={availableUsers.length === 0}
{mandates.map(m => ( >
<option key={m.id} value={m.id}> <FaPlus /> {t('Benutzer hinzufügen')}
{mandateDisplayLabel(m)} </button>
</option> </div>
))} )}
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchMandateUsers(selectedMandateId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0}
>
<FaPlus /> {t('Benutzer hinzufügen')}
</button>
</div> </div>
)} </Panel>
</div>
{/* Content */} {!selectedMandateId ? (
{!selectedMandateId ? ( <Panel variant="card">
<div className={styles.emptyState}> <div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} /> <FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3> <h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}> <p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.')} {t('Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.')}
</p> </p>
</div> </div>
) : ( </Panel>
<div className={styles.tableContainer}> ) : (
<FormGeneratorTable <Panel variant="table">
data={users} <FormGeneratorTable
columns={columns} data={users}
apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined} columns={columns}
loading={loading} apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined}
filterScopeKey={selectedMandateId || 'admin'}
loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
searchable={true} searchable={true}
@ -366,9 +376,11 @@ export const AdminUserMandatesPage: React.FC = () => {
}, },
}} }}
emptyMessage={t('Keine Mitglieder gefunden')} emptyMessage={t('Keine Mitglieder gefunden')}
/> />
</div> </Panel>
)} )}
</StackLayout.Body>
</StackLayout>
{/* Add User Modal */} {/* Add User Modal */}
{showAddModal && ( {showAddModal && (
@ -435,7 +447,7 @@ export const AdminUserMandatesPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -12,6 +12,8 @@ import { useOrgUsers, useUserOperations } from '../../hooks/useUsers';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa'; import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { getUserDataCache } from '../../utils/userCache'; import { getUserDataCache } from '../../utils/userCache';
@ -159,65 +161,73 @@ export const AdminUsersPage: React.FC = () => {
if (error) { if (error) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <StackLayout variant="table">
<div className={styles.errorContainer}> <StackLayout.Body>
<span className={styles.errorIcon}></span> <Panel variant="card">
<p className={styles.errorMessage}> <div className={styles.errorContainer}>
{t('Fehler beim Laden der Benutzer')}: {error} <span className={styles.errorIcon}></span>
</p> <p className={styles.errorMessage}>
<button className={styles.secondaryButton} onClick={() => refetch()}> {t('Fehler beim Laden der Benutzer')}: {error}
<FaSync /> {t('Erneut versuchen')} </p>
</button> <button className={styles.secondaryButton} onClick={() => refetch()}>
</div> <FaSync /> {t('Erneut versuchen')}
</div> </button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
); );
} }
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <>
<div className={styles.pageHeader}> <StackLayout variant="table">
<StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Benutzer')}</h1> <h1 className={styles.pageTitle}>{t('Benutzer')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie alle Benutzer im')}</p> <p className={styles.pageSubtitle}>{t('Verwalten Sie alle Benutzer im')}</p>
</div> </div>
<div className={styles.headerActions}> </StackLayout.Header>
<button <StackLayout.Body>
type="button" <Panel variant="toolbar">
className={styles.secondaryButton} <div className={styles.headerActions}>
onClick={() => navigate('/admin/user-access-overview')}
>
<FaUserShield /> {t('Zugriffsübersicht')}
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/invitations')}
>
<FaEnvelopeOpenText /> {t('Einladungen')}
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<button <button
className={styles.primaryButton} type="button"
onClick={() => setShowCreateModal(true)} className={styles.secondaryButton}
onClick={() => navigate('/admin/user-access-overview')}
> >
<FaPlus /> {t('Neuer Benutzer')} <FaUserShield /> {t('Zugriffsübersicht')}
</button> </button>
)} <button
</div> type="button"
</div> className={styles.secondaryButton}
onClick={() => navigate('/admin/invitations')}
<div className={styles.tableContainer}> >
<FormGeneratorTable <FaEnvelopeOpenText /> {t('Einladungen')}
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neuer Benutzer')}
</button>
)}
</div>
</Panel>
<Panel variant="table">
<FormGeneratorTable
data={users} data={users}
columns={columns} columns={columns}
apiEndpoint="/api/users/" apiEndpoint="/api/users/"
filterScopeKey="admin"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
@ -256,7 +266,9 @@ export const AdminUsersPage: React.FC = () => {
}} }}
emptyMessage={t('Keine Benutzer gefunden')} emptyMessage={t('Keine Benutzer gefunden')}
/> />
</div> </Panel>
</StackLayout.Body>
</StackLayout>
{/* Create Modal */} {/* Create Modal */}
{showCreateModal && ( {showCreateModal && (
@ -326,7 +338,7 @@ export const AdminUsersPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View file

@ -9,7 +9,8 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useFeatureAccess, type FeatureInstance, type FeatureAccessUser } from '../../hooks/useFeatureAccess'; import { useFeatureAccess, type FeatureInstance, type FeatureAccessUser } from '../../hooks/useFeatureAccess';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { Tabs } from '../../components/UiComponents/Tabs'; import { LayoutTabs } from '../../components/Layout/LayoutTabs';
import type { LayoutTabItem } from '../../components/Layout/types';
import { FaSync } from 'react-icons/fa'; import { FaSync } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import api from '../../api'; import api from '../../api';
@ -224,11 +225,11 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
[roleOptions, t] [roleOptions, t]
); );
const tabs = [ const tabItems: LayoutTabItem[] = useMemo(() => [
{ {
id: 'users', id: 'users',
label: t('Benutzer'), label: t('Benutzer'),
content: ( render: () => (
<div className={modalStyles.tabContent}> <div className={modalStyles.tabContent}>
{loading ? ( {loading ? (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
@ -250,7 +251,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
{ {
id: 'roles', id: 'roles',
label: t('Rollen'), label: t('Rollen'),
content: ( render: () => (
<div className={modalStyles.tabContent}> <div className={modalStyles.tabContent}>
<p className={modalStyles.rolesIntro}> <p className={modalStyles.rolesIntro}>
{t('Rollen werden von der Feature-Vorlage übernommen. Mit „Synchronisieren“ können Sie fehlende Rollen nachziehen.')} {t('Rollen werden von der Feature-Vorlage übernommen. Mit „Synchronisieren“ können Sie fehlende Rollen nachziehen.')}
@ -274,7 +275,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
{ {
id: 'settings', id: 'settings',
label: t('Einstellungen'), label: t('Einstellungen'),
content: ( render: () => (
<div className={modalStyles.tabContent}> <div className={modalStyles.tabContent}>
<FormGeneratorForm <FormGeneratorForm
attributes={[ attributes={[
@ -291,7 +292,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
</div> </div>
), ),
}, },
]; ], [t, loading, users, roles, syncing, instance, handleEditUser, handleRemoveUser, handleSyncRoles, handleUpdateInstance]);
return ( return (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay}>
@ -308,7 +309,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<Tabs tabs={tabs} defaultTabId="users" /> <LayoutTabs items={tabItems} urlParam="modalTab" defaultTab="users" />
</div> </div>
</div> </div>

View file

@ -9,6 +9,7 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { FaChevronDown, FaChevronRight, FaUsers } from 'react-icons/fa'; import { FaChevronDown, FaChevronRight, FaUsers } from 'react-icons/fa';
import { Panel } from '../../components/Layout/Panel';
import type { Feature } from '../../hooks/useFeatureAccess'; import type { Feature } from '../../hooks/useFeatureAccess';
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess'; import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
import type { InstanceWithStats } from './AccessManagementHub'; import type { InstanceWithStats } from './AccessManagementHub';
@ -189,29 +190,27 @@ export const InstanceHierarchyView: React.FC<InstanceHierarchyViewProps> = ({
if (loading) { if (loading) {
return ( return (
<section className={hubStyles.section}> <Panel variant="card" title={t('Hierarchie')}>
<div className={hierarchyStyles.hierarchyLoading}> <div className={hierarchyStyles.hierarchyLoading}>
<span className={hierarchyStyles.spinner} /> <span className={hierarchyStyles.spinner} />
<span>{t('Lade Hierarchie und Benutzer')}</span> <span>{t('Lade Hierarchie und Benutzer')}</span>
</div> </div>
</section> </Panel>
); );
} }
if (mandates.length === 0) { if (mandates.length === 0) {
return ( return (
<section className={hubStyles.section}> <Panel variant="card" title={t('Hierarchie')}>
<h2 className={hubStyles.sectionTitle}>{t('Hierarchie')}</h2>
<div className={hierarchyStyles.emptyHierarchy}> <div className={hierarchyStyles.emptyHierarchy}>
{t('Keine Mandanten vorhanden. Legen Sie unter „Mandanten verwalten“ einen Mandanten an.')} {t('Keine Mandanten vorhanden. Legen Sie unter „Mandanten verwalten“ einen Mandanten an.')}
</div> </div>
</section> </Panel>
); );
} }
return ( return (
<section className={hubStyles.section}> <Panel variant="card" title={t('Hierarchie')}>
<h2 className={hubStyles.sectionTitle}>{t('Hierarchie')}</h2>
<div className={hierarchyStyles.hierarchyRoot}> <div className={hierarchyStyles.hierarchyRoot}>
{mandates.map((mandate) => { {mandates.map((mandate) => {
const mandateId = mandate.id; const mandateId = mandate.id;
@ -257,7 +256,7 @@ export const InstanceHierarchyView: React.FC<InstanceHierarchyViewProps> = ({
); );
})} })}
</div> </div>
</section> </Panel>
); );
}; };

View file

@ -8,6 +8,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { FaEdit, FaTrash } from 'react-icons/fa'; import { FaEdit, FaTrash } from 'react-icons/fa';
import { Panel } from '../../components/Layout/Panel';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess'; import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess'; import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess';
@ -51,16 +52,19 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({ users,
if (roles.length === 0) { if (roles.length === 0) {
return ( return (
<div className={matrixStyles.empty}> <Panel variant="card">
<p>{t('Keine Rollen in dieser Instanz')}</p> <div className={matrixStyles.empty}>
</div> <p>{t('Keine Rollen in dieser Instanz')}</p>
</div>
</Panel>
); );
} }
return ( return (
<div className={matrixStyles.wrapper}> <>
<div className={matrixStyles.tableWrap}> <Panel variant="table">
<table className={matrixStyles.table}> <div className={matrixStyles.tableWrap}>
<table className={matrixStyles.table}>
<thead> <thead>
<tr> <tr>
<th className={matrixStyles.cellUser}>{t('Benutzer')}</th> <th className={matrixStyles.cellUser}>{t('Benutzer')}</th>
@ -134,19 +138,22 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({ users,
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className={matrixStyles.footer}> </Panel>
<button <Panel variant="toolbar">
type="button" <div className={matrixStyles.footer}>
className={styles.primaryButton} <button
onClick={onAddUser} type="button"
disabled={disabled} className={styles.primaryButton}
> onClick={onAddUser}
+ {t('Benutzer hinzufügen')} disabled={disabled}
</button> >
</div> + {t('Benutzer hinzufügen')}
</button>
</div>
</Panel>
<ConfirmDialog /> <ConfirmDialog />
</div> </>
); );
}; };

View file

@ -15,6 +15,8 @@ import { useInvitations } from '../../../hooks/useInvitations';
import { useUserMandates, type Mandate, type Role } from '../../../hooks/useUserMandates'; import { useUserMandates, type Mandate, type Role } from '../../../hooks/useUserMandates';
import { useFeatureAccess, type FeatureInstance, type FeatureInstanceRole } from '../../../hooks/useFeatureAccess'; import { useFeatureAccess, type FeatureInstance, type FeatureInstanceRole } from '../../../hooks/useFeatureAccess';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { StackLayout } from '../../../components/Layout/StackLayout';
import { Panel } from '../../../components/Layout/Panel';
import styles from '../Admin.module.css'; import styles from '../Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -308,17 +310,18 @@ export const AdminInvitationWizardPage: React.FC = () => {
// ========================================================================== // ==========================================================================
return ( return (
<div className={styles.adminPage} style={{ overflow: 'auto' }}> <StackLayout variant="form">
<div className={styles.pageHeader}> <StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Einladungs-Wizard')}</h1> <h1 className={styles.pageTitle}>{t('Einladungs-Wizard')}</h1>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>
{t('Benutzer zu Mandant oder Feature-Instanz einladen')} {t('Benutzer zu Mandant oder Feature-Instanz einladen')}
</p> </p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
{error && ( {error && (
<Panel variant="card">
<div className={styles.errorContainer} style={{ <div className={styles.errorContainer} style={{
flexDirection: 'row', padding: '12px 16px', marginBottom: '16px', flexDirection: 'row', padding: '12px 16px', marginBottom: '16px',
background: '#fef2f2', borderRadius: '8px', fontSize: '13px', justifyContent: 'flex-start', background: '#fef2f2', borderRadius: '8px', fontSize: '13px', justifyContent: 'flex-start',
@ -326,10 +329,11 @@ export const AdminInvitationWizardPage: React.FC = () => {
{error} {error}
<button onClick={() => setError(null)} style={{ marginLeft: '8px', cursor: 'pointer', background: 'none', border: 'none', fontWeight: 600 }}>&times;</button> <button onClick={() => setError(null)} style={{ marginLeft: '8px', cursor: 'pointer', background: 'none', border: 'none', fontWeight: 600 }}>&times;</button>
</div> </div>
</Panel>
)} )}
{/* Step indicator */}
{!dispatchResults && ( {!dispatchResults && (
<Panel variant="toolbar">
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', marginBottom: '24px', flexWrap: 'wrap' }}>
{stepLabels.map((label, idx) => { {stepLabels.map((label, idx) => {
const s = idx + 1; const s = idx + 1;
@ -352,8 +356,10 @@ export const AdminInvitationWizardPage: React.FC = () => {
); );
})} })}
</div> </div>
</Panel>
)} )}
<Panel variant="wizard">
{/* ── STEP 1: Invite type ── */} {/* ── STEP 1: Invite type ── */}
{step === 1 && ( {step === 1 && (
<div style={_cardStyle}> <div style={_cardStyle}>
@ -745,7 +751,9 @@ export const AdminInvitationWizardPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </Panel>
</StackLayout.Body>
</StackLayout>
); );
}; };

View file

@ -21,6 +21,8 @@ import { createMandate, type MandateCreateData } from '../../../api/mandateApi';
import { updateSettingsAdmin } from '../../../api/billingApi'; import { updateSettingsAdmin } from '../../../api/billingApi';
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge'; import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import { StackLayout } from '../../../components/Layout/StackLayout';
import { Panel } from '../../../components/Layout/Panel';
import styles from '../Admin.module.css'; import styles from '../Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -668,28 +670,32 @@ export const AdminMandateWizardPage: React.FC = () => {
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
return ( return (
<div className={styles.adminPage} style={{ overflow: 'auto' }}> <StackLayout variant="form">
<div className={styles.pageHeader}> <StackLayout.Header>
<div> <div>
<h1 className={styles.pageTitle}>{t('Mandanten-Verwaltung')}</h1> <h1 className={styles.pageTitle}>{t('Mandanten-Verwaltung')}</h1>
<p className={styles.pageSubtitle}>{t('Schritt-für-Schritt-Wizard zur Mandantenkonfiguration')}</p> <p className={styles.pageSubtitle}>{t('Schritt-für-Schritt-Wizard zur Mandantenkonfiguration')}</p>
</div> </div>
</div> </StackLayout.Header>
<StackLayout.Body>
{error && ( {error && (
<Panel variant="card">
<div style={{ <div style={{
padding: '12px 16px', background: 'var(--error-bg, #fef2f2)', color: 'var(--danger-color, #dc2626)', padding: '12px 16px', background: 'var(--error-bg, #fef2f2)', color: 'var(--danger-color, #dc2626)',
borderRadius: '8px', marginBottom: '16px', fontSize: '13px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderRadius: '8px', fontSize: '13px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
border: '1px solid var(--danger-color, #fecaca)', border: '1px solid var(--danger-color, #fecaca)',
}}> }}>
{error} {error}
<button onClick={() => setError(null)} style={{ background: 'none', border: 'none', fontWeight: 600, cursor: 'pointer', color: 'inherit', fontSize: '16px' }}>&times;</button> <button onClick={() => setError(null)} style={{ background: 'none', border: 'none', fontWeight: 600, cursor: 'pointer', color: 'inherit', fontSize: '16px' }}>&times;</button>
</div> </div>
</Panel>
)} )}
<Panel variant="toolbar">
{renderStepIndicator()} {renderStepIndicator()}
</Panel>
{/* ── STEP 1: MANDATE ── */} <Panel variant="wizard">
{step === 1 && ( {step === 1 && (
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px', marginTop: 0 }}>{t('Mandant auswählen oder erstellen')}</h3> <h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px', marginTop: 0 }}>{t('Mandant auswählen oder erstellen')}</h3>
@ -1070,7 +1076,9 @@ export const AdminMandateWizardPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </Panel>
</StackLayout.Body>
</StackLayout>
); );
}; };

View file

@ -12,6 +12,7 @@ import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api'; import api from '../../../api';
import type { Mandate } from '../../../hooks/useUserMandates'; import type { Mandate } from '../../../hooks/useUserMandates';
import type { Feature } from '../../../hooks/useFeatureAccess'; import type { Feature } from '../../../hooks/useFeatureAccess';
import { Panel } from '../../../components/Layout/Panel';
import styles from '../Admin.module.css'; import styles from '../Admin.module.css';
import wizardStyles from './FeatureInstanceWizard.module.css'; import wizardStyles from './FeatureInstanceWizard.module.css';
@ -156,6 +157,7 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
</button> </button>
</div> </div>
<Panel variant="toolbar">
<div className={wizardStyles.steps}> <div className={wizardStyles.steps}>
{steps.map((s, i) => ( {steps.map((s, i) => (
<div <div
@ -167,8 +169,10 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
</div> </div>
))} ))}
</div> </div>
</Panel>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<Panel variant="wizard">
{currentStepId === 'create' && ( {currentStepId === 'create' && (
<div className={wizardStyles.stepContent}> <div className={wizardStyles.stepContent}>
<div className={wizardStyles.fieldGroup}> <div className={wizardStyles.fieldGroup}>
@ -344,6 +348,7 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
</div> </div>
</div> </div>
)} )}
</Panel>
</div> </div>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show more