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 | | |
| `realestate` | Immobilien | | | ☐ ja ☐ nein | | |
| `chatbot` | Chatbot | | | ☐ ja ☐ nein | | |
| `chatworkflow` | Workflow | | | ☐ ja ☐ nein | | |
| `automation` | Automatisierung | | | ☐ ja ☐ nein | | |
| `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | |
| `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | |
@ -144,12 +143,6 @@ Viele **Views** sind Kandidaten für „Basic / Pro“ oder Add-ons (technisch:
- [ ] `dashboard` — …
- [ ] `instance-roles` (adminOnly) — …
### `chatworkflow`
- [ ] `dashboard` — …
- [ ] `runs` — …
- [ ] `files` — …
**Paket-Entscheid (freies Feld):**
| 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 |
| Immobilien (`realestate`) | Karte / Mandantenfähigkeit |
| Chatbot (`chatbot`) | Konversationen, Konfiguration |
| Workflow (`chatworkflow`) | Überblicke, Runs, Dateien |
| Workflow-Automation (Systemkomponente) | Workflows, Editor, Durchläufe |
| Automatisierung (`automation`) | Definitionen, Vorlagen, Logs |
| Teams Bot (`teamsbot`) | Dashboard, Sessions, Settings |
| 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 = {
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) {
return [
{ 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 { FloatingPortal } from '../../UiComponents/FloatingPortal';
import {
FaPlay,
FaSpinner,
@ -146,13 +147,13 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
const badge = statusBadge[currentStatus] || statusBadge.draft;
const [newMenuOpen, setNewMenuOpen] = useState(false);
const newMenuRef = useRef<HTMLDivElement>(null);
const newMenuAnchorRef = useRef<HTMLDivElement>(null);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null);
const templateMenuAnchorRef = useRef<HTMLDivElement>(null);
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const zoomMenuRef = useRef<HTMLDivElement>(null);
const zoomMenuAnchorRef = useRef<HTMLButtonElement>(null);
const [zoomInputDraft, setZoomInputDraft] = useState('');
useEffect(() => {
@ -160,16 +161,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
if (zp !== undefined) setZoomInputDraft(String(zp));
}, [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(
() =>
({
@ -237,7 +228,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
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}>
<Button
type="button"
@ -250,34 +241,43 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
aria-label={t('Neuer leerer Workflow')}
/>
{onNewFromTemplate && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaCaretDown}
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Aus Vorlage…')}
aria-label={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
/>
<div ref={newMenuAnchorRef}>
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaCaretDown}
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Aus Vorlage…')}
aria-label={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
/>
</div>
)}
</div>
{newMenuOpen && onNewFromTemplate && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => {
onNewFromTemplate();
setNewMenuOpen(false);
}}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
</div>
{onNewFromTemplate && (
<FloatingPortal
open={newMenuOpen}
anchorRef={newMenuAnchorRef}
onClose={() => setNewMenuOpen(false)}
placement="bottom"
>
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => {
onNewFromTemplate();
setNewMenuOpen(false);
}}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
</div>
</FloatingPortal>
)}
</div>
<select
@ -329,7 +329,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
title={_runTitle}
/>
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<div ref={templateMenuAnchorRef} className={styles.canvasHeaderNewSplit}>
<Button
type="button"
variant={_tb}
@ -344,7 +344,12 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
>
{t('Als Vorlage')}
</Button>
{templateMenuOpen && (
<FloatingPortal
open={templateMenuOpen}
anchorRef={templateMenuAnchorRef}
onClose={() => setTemplateMenuOpen(false)}
placement="bottom"
>
<div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => (
<button
@ -361,7 +366,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
</button>
))}
</div>
)}
</FloatingPortal>
</div>
)}
@ -387,7 +392,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
role="toolbar"
aria-label={t('Canvas bearbeiten')}
>
<div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}>
<div className={styles.canvasHeaderZoomCombo}>
<div className={styles.canvasHeaderZoomInputWrap}>
<input
type="text"
@ -410,6 +415,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
</span>
</div>
<button
ref={zoomMenuAnchorRef}
type="button"
className={styles.canvasHeaderZoomChevronBtn}
onClick={() => setZoomMenuOpen((p) => !p)}
@ -420,7 +426,13 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
>
<FaCaretDown aria-hidden />
</button>
{zoomMenuOpen && (
<FloatingPortal
open={zoomMenuOpen}
anchorRef={zoomMenuAnchorRef}
onClose={() => setZoomMenuOpen(false)}
placement="bottom"
align="end"
>
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
@ -459,7 +471,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
</button>
))}
</div>
)}
</FloatingPortal>
</div>
<button
type="button"

View file

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

View file

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

View file

@ -56,7 +56,7 @@
*
* 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 { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorTable.module.css';
@ -73,6 +73,7 @@ import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
import { FormGeneratorControls } from '../FormGeneratorControls';
import { FilterSearchInput } from '../FilterSearchInput';
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
import { FloatingPortal } from '../../UiComponents/FloatingPortal';
import {
isDateTimeType,
isCheckboxType,
@ -93,6 +94,12 @@ import {
type TableListViewRow,
type TableViewConfig,
} from '../../../api/tableViewApi';
import { useVisibilityRemeasure } from '../../../hooks/useVisibilityRemeasure';
import {
buildTableFilterStorageKey,
loadTableFilterState,
saveTableFilterState,
} from '../../../utils/tableFilterPersistence';
function groupLevelsFromViewConfig(raw: unknown): GroupByLevelSpec[] {
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`.
*/
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
* (requires hookData.fetchGroupSectionSummaries + refetchForSection).
@ -708,6 +720,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onRowDragStart,
compact = false,
tableContextKey,
filterScopeKey,
tableGroupLayoutMode = 'inline',
localDataMode = false,
viewKeyForQueries,
@ -933,6 +946,34 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Multi-column sorting: array of sort configs in order of priority
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>(initialSort ?? []);
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>>({});
// Actions column width - resizable, default based on number of buttons
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 [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const filterAnchorRef = useRef<HTMLButtonElement>(null);
const reloadViews = useCallback(async () => {
if (!tableContextKey) return;
@ -1151,41 +1192,6 @@ export function FormGeneratorTable<T extends Record<string, any>>({
[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
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
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)
const shouldWrapActionButtons = containerWidth > 0 && currentActionsWidth > containerWidth * 0.20;
const _updateContainerWidth = useCallback(() => {
const container = tableContainerRef.current;
if (container) {
setContainerWidth(container.clientWidth);
}
}, []);
// Track container width changes
useEffect(() => {
const container = tableContainerRef.current;
if (!container) return;
const updateContainerWidth = () => {
setContainerWidth(container.clientWidth);
};
// Initial measurement
updateContainerWidth();
// Observe resize
const resizeObserver = new ResizeObserver(updateContainerWidth);
_updateContainerWidth();
const resizeObserver = new ResizeObserver(_updateContainerWidth);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, []);
}, [_updateContainerWidth]);
useVisibilityRemeasure(tableContainerRef, _updateContainerWidth);
const resizingColumn = useRef<string | null>(null);
const startX = useRef<number>(0);
const startWidth = useRef<number>(0);
@ -1771,20 +1780,6 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return [];
}, [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
const toggleFilterDropdown = useCallback((columnKey: string, event: React.MouseEvent) => {
event.stopPropagation(); // Prevent sort from triggering
@ -2933,6 +2928,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{/* Filter icon */}
{filterable && column.filterable !== false && (
<button
ref={openFilterColumn === column.key ? filterAnchorRef : undefined}
className={`${styles.filterIcon} ${column.key in filters ? styles.filterActive : ''}`}
onClick={(e) => toggleFilterDropdown(column.key, e)}
title={column.key in filters
@ -2980,8 +2976,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{/* Filter dropdown */}
{openFilterColumn === column.key && (
<div
ref={filterDropdownRef}
<FloatingPortal
open
anchorRef={filterAnchorRef}
onClose={() => setOpenFilterColumn(null)}
placement="auto"
>
<div
className={styles.filterDropdown}
onClick={(e) => e.stopPropagation()}
>
@ -3152,6 +3153,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
})()}
</div>
</div>
</FloatingPortal>
)}
{resizable && (

View file

@ -543,6 +543,14 @@
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 */
.embeddedPicker {
display: flex;

View file

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

View file

@ -42,10 +42,6 @@
}
.popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 4200;
min-width: min(360px, calc(100vw - 24px));
padding: 14px 14px 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 { FiChevronsDown, FiChevronsUp } from 'react-icons/fi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { FloatingPortal } from '../../UiComponents/FloatingPortal';
import styles from './TableViewsBar.module.css';
export interface TableViewOption {
@ -93,26 +94,18 @@ export function TableViewsBar({
}: TableViewsBarProps) {
const { t } = useLanguage();
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const groupTriggerRef = useRef<HTMLButtonElement>(null);
const [saveOpen, setSaveOpen] = useState(false);
const [newName, setNewName] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
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) => {
if (e.key === 'Escape') setGroupMenuOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
return () => document.removeEventListener('keydown', onKey);
}, [groupMenuOpen]);
const levelsForUi = useMemo(
@ -196,8 +189,9 @@ export function TableViewsBar({
return (
<div className={styles.toolbar}>
<div ref={wrapRef} className={styles.popoverAnchor}>
<div className={styles.popoverAnchor}>
<button
ref={groupTriggerRef}
type="button"
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
onClick={() => setGroupMenuOpen((o) => !o)}
@ -207,7 +201,12 @@ export function TableViewsBar({
>
<FaLayerGroup className={styles.groupIcon} aria-hidden />
</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.popoverTitle}>{t('Gruppieren nach')}</div>
<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')}
</button>
</div>
)}
</FloatingPortal>
</div>
<span className={styles.activeSummary} title={summary}>

View file

@ -219,6 +219,26 @@
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 ---------- */
:global(.dark-theme) .tabBar {

View file

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

View file

@ -4,7 +4,7 @@
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
border-radius: 8px;
background: var(--bg-primary, #fff);
overflow: hidden;
overflow: clip;
}
/* --- Variant: table — fills available height, bounded scroll --- */
@ -43,12 +43,30 @@
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 --- */
.panel[data-variant="editor"] {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: visible;
}
.panel[data-variant="editor"] .body {
@ -57,6 +75,7 @@
padding: 0;
display: flex;
flex-direction: column;
overflow: visible;
}
/* --- Variant: wizard — step container --- */

View file

@ -29,6 +29,7 @@ export const Panel: FC<PanelProps> = ({
defaultCollapsed = false,
collapseKey,
className = '',
fill = false,
children,
}) => {
const [collapsed, setCollapsed] = useState(() => _loadCollapsed(collapseKey, defaultCollapsed));
@ -47,6 +48,7 @@ export const Panel: FC<PanelProps> = ({
<div
className={`${styles.panel} ${collapsed ? styles.panelCollapsed : ''} ${className}`}
data-variant={variant}
data-fill={fill ? 'true' : undefined}
>
{hasHeader && (
<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;
}
/* 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 {
overflow-y: auto;
flex: 0 0 auto;
overflow: visible;
display: flex;
flex-direction: column;
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
// 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 { useScrollMode } from '../../hooks/useScrollMode';
import { useScrollRestoration } from '../../hooks/useScrollRestoration';
import styles from './StackLayout.module.css';
// ---------------------------------------------------------------------------
@ -63,9 +64,12 @@ const _StackLayoutRoot: FC<StackLayoutProps> = ({
children,
}) => {
const scrollMode = useScrollMode();
const rootRef = useRef<HTMLDivElement>(null);
useScrollRestoration(rootRef);
return (
<div
ref={rootRef}
className={`${styles.root} ${className}`}
data-scroll-mode={scrollMode}
data-variant={variant}

View file

@ -1,36 +1,74 @@
// Copyright (c) 2026 PowerOn AG
// 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 type { ViewMode, ViewStackProps, ViewProps } from './types';
import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext';
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(
searchParams: URLSearchParams,
viewParam: string,
entityParam: string | undefined,
defaultView: ViewMode
): ViewMode {
defaultView: ViewMode,
registeredViews: ViewMode[],
): ViewResolution {
const rawView = searchParams.get(viewParam) as ViewMode | 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';
}
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;
}
return resolved;
return { activeView: resolved, sanitized };
}
function _findActiveChild(
children: React.ReactNode,
activeView: ViewMode
activeView: ViewMode,
): ReactElement<ViewProps> | null {
let match: ReactElement<ViewProps> | null = null;
@ -48,7 +86,7 @@ function _buildBackParams(
searchParams: URLSearchParams,
viewParam: string,
entityParam: string | undefined,
defaultView: ViewMode
defaultView: ViewMode,
): URLSearchParams {
const next = new URLSearchParams(searchParams);
@ -65,6 +103,23 @@ function _buildBackParams(
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) {
return <>{children}</>;
}
@ -76,8 +131,33 @@ function ViewStack({
children,
}: ViewStackProps) {
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);
if (!activeChild) return null;

View file

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

View file

@ -41,6 +41,14 @@ export interface LayoutTabsProps {
collapseKey?: string;
/** Start collapsed when no persisted state exists. */
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;
collapseKey?: 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;
}
@ -104,3 +118,30 @@ export interface LayoutPersistenceAdapter {
load: <T>(key: string) => T | null;
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 api from '../../api';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useSidebar } from '../../layouts/SidebarContext';
import styles from './MandateNavigation.module.css';
type NavTranslateFn = (key: string, params?: Record<string, string | number>) => string;
@ -210,6 +211,7 @@ const EmptyState: React.FC = () => {
export const MandateNavigation: React.FC = () => {
const { t } = useLanguage();
const { collapsed } = useSidebar();
const { blocks, loading, refresh } = useNavigation();
const { prompt, PromptDialog } = usePrompt();
const { showWarning } = useToast();
@ -332,6 +334,7 @@ export const MandateNavigation: React.FC = () => {
<TreeNavigation
items={navigationItems}
autoExpandActive={true}
collapsed={collapsed}
/>
) : (
<EmptyState />

View file

@ -345,3 +345,82 @@
background: var(--primary-color, #2563eb);
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[];
/** Whether to auto-expand nodes when their path is active */
autoExpandActive?: boolean;
/** Icon-only rail mode for collapsed sidebar */
collapsed?: boolean;
/** Callback when a node is clicked */
onNodeClick?: (node: TreeNodeItem) => void;
/** Maximum depth to render (0 = unlimited) */
@ -122,6 +124,34 @@ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem {
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
// =============================================================================
@ -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
// =============================================================================
@ -351,6 +420,7 @@ const TreeSection: React.FC<TreeSectionProps> = ({
export const TreeNavigation: React.FC<TreeNavigationProps> = ({
items,
autoExpandActive = true,
collapsed = false,
onNodeClick,
maxDepth = 0,
className = '',
@ -358,6 +428,22 @@ export const TreeNavigation: React.FC<TreeNavigationProps> = ({
const location = useLocation();
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 (
<nav className={`${styles.treeNavigation} ${className}`}>
{items.map((item, index) => {

View file

@ -10,6 +10,14 @@
padding: 0.5rem;
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
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 */
@ -36,6 +44,13 @@
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.userButtonCollapsed {
flex: 0 0 auto;
justify-content: center;
padding: 0.375rem;
width: 100%;
}
.avatar {
flex-shrink: 0;
width: 36px;
@ -82,23 +97,20 @@
/* Menu */
.menu {
position: absolute;
bottom: 100%;
left: 0.5rem;
right: 0.5rem;
margin-bottom: 0.25rem;
padding: 0.25rem;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
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) {
.menu {
left: 0.25rem;
right: 0.25rem;
max-height: min(60dvh, 420px);
overflow-y: auto;
}

View file

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

View file

@ -52,16 +52,13 @@
/* Dropdown */
.dropdown {
position: fixed;
bottom: 80px;
left: 290px;
width: 360px;
max-height: 480px;
max-width: min(360px, calc(100vw - 16px));
max-height: min(480px, 70vh);
background: var(--card-bg, white);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 9999;
animation: slideIn 0.2s ease;
}
@ -367,12 +364,3 @@
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

@ -2,7 +2,7 @@
// All rights reserved.
/**
* NotificationBell Component
*
*
* Displays a bell icon with unread count badge.
* Clicking opens a dropdown with recent notifications.
*/
@ -10,16 +10,16 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
import { useNotifications, UserNotification } from '../../hooks/useNotifications';
import { FloatingPortal } from '../UiComponents/FloatingPortal';
import styles from './NotificationBell.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// Icon mapping for notification types
const typeIcons: Record<string, React.ReactNode> = {
invitation: <FaEnvelope />,
system: <FaCog />,
workflow: <FaCog />,
mention: <FaExclamationTriangle />
mention: <FaExclamationTriangle />,
};
interface NotificationBellProps {
@ -28,6 +28,7 @@ interface NotificationBellProps {
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
const { t } = useLanguage();
const bellButtonRef = useRef<HTMLButtonElement>(null);
const formatRelativeTime = useCallback((timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) {
@ -59,61 +60,41 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
executeAction,
dismissNotification,
startPolling,
stopPolling
stopPolling,
} = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Start polling on mount
useEffect(() => {
startPolling(30000); // Poll every 30 seconds
startPolling(30000);
return () => stopPolling();
}, [startPolling, stopPolling]);
// Fetch notifications when dropdown opens
useEffect(() => {
if (isOpen) {
fetchNotifications({ limit: 10 });
}
}, [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 (
notification: UserNotification,
notification: UserNotification,
actionId: string,
event: React.MouseEvent
event: React.MouseEvent,
) => {
event.stopPropagation();
setActionLoading(`${notification.id}-${actionId}`);
const result = await executeAction(notification.id, actionId);
setActionLoading(null);
if (result) {
setActionSuccess(notification.id);
// Reload sidebar when accepting an invitation (grants new mandate/feature access)
if (actionId === 'accept' && notification.referenceType === 'Invitation') {
window.dispatchEvent(new CustomEvent('features-changed'));
}
// Clear success state after animation
setTimeout(() => {
setActionSuccess(null);
fetchNotifications({ limit: 10 });
@ -121,31 +102,30 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
}
}, [executeAction, fetchNotifications]);
// Handle dismiss
const handleDismiss = useCallback(async (
notification: UserNotification,
event: React.MouseEvent
event: React.MouseEvent,
) => {
event.stopPropagation();
await dismissNotification(notification.id);
}, [dismissNotification]);
// Handle notification click (mark as read)
const handleNotificationClick = useCallback(async (notification: UserNotification) => {
if (notification.status === 'unread') {
await markAsRead(notification.id);
}
}, [markAsRead]);
// Filter out dismissed notifications
const visibleNotifications = notifications.filter(n => n.status !== 'dismissed');
return (
<div className={`${styles.notificationBell} ${className || ''}`} ref={dropdownRef}>
{/* Bell Button */}
<button
<div className={`${styles.notificationBell} ${className || ''}`}>
<button
ref={bellButtonRef}
type="button"
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')}
>
<FaBell className={styles.bellIcon} />
@ -156,14 +136,19 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
)}
</button>
{/* Dropdown */}
{isOpen && (
<FloatingPortal
open={isOpen}
anchorRef={bellButtonRef}
onClose={() => setIsOpen(false)}
placement="auto"
align="end"
>
<div className={styles.dropdown}>
{/* Header */}
<div className={styles.header}>
<h3>{t('Benachrichtigungen')}</h3>
{visibleNotifications.some(n => n.status === 'unread') && (
<button
<button
type="button"
className={styles.markAllRead}
onClick={() => markAllAsRead()}
>
@ -172,16 +157,15 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
)}
</div>
{/* Content */}
<div className={styles.content}>
{loading && visibleNotifications.length === 0 && (
<div className={styles.loading}>{t('Lade')}</div>
)}
{error && (
<div className={styles.error}>{error}</div>
)}
{!loading && !error && visibleNotifications.length === 0 && (
<div className={styles.empty}>
<FaBell className={styles.emptyIcon} />
@ -190,40 +174,37 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
)}
{visibleNotifications.map(notification => (
<div
<div
key={notification.id}
className={`
${styles.notification}
${styles.notification}
${notification.status === 'unread' ? styles.unread : ''}
${actionSuccess === notification.id ? styles.success : ''}
`}
onClick={() => handleNotificationClick(notification)}
>
{/* Success overlay */}
{actionSuccess === notification.id && (
<div className={styles.successOverlay}>
<FaCheckCircle />
<span>{notification.actionResult || t('Erfolgreich')}</span>
</div>
)}
{/* Icon */}
<div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}>
{typeIcons[notification.type] || <FaBell />}
</div>
{/* Content */}
<div className={styles.notificationContent}>
<div className={styles.title}>{notification.title}</div>
<div className={styles.message}>{notification.message}</div>
<div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div>
{/* Actions */}
{notification.actions && notification.status !== 'actioned' && (
<div className={styles.actions}>
{notification.actions.map(action => (
<button
key={action.actionId}
type="button"
className={`${styles.actionButton} ${styles[`action_${action.style}`]}`}
onClick={(e) => handleAction(notification, action.actionId, e)}
disabled={actionLoading === `${notification.id}-${action.actionId}`}
@ -241,18 +222,17 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
))}
</div>
)}
{/* Action result */}
{notification.actionTaken && (
<div className={styles.actionResult}>
{notification.actionResult}
</div>
)}
</div>
{/* Dismiss button */}
{notification.status !== 'actioned' && (
<button
<button
type="button"
className={styles.dismissButton}
onClick={(e) => handleDismiss(notification, e)}
aria-label={t('Schließen')}
@ -264,7 +244,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
))}
</div>
</div>
)}
</FloatingPortal>
</div>
);
};

View file

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

View file

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

View file

@ -7,7 +7,7 @@
* 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 PeriodPickerCalendar from './PeriodPickerCalendar';
import {
@ -192,7 +192,6 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
const footerMax = clampIsoDate(undefined, constraints, 'max');
// Keyboard: Esc cancels, Enter applies
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const _onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
@ -202,38 +201,8 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
return () => window.removeEventListener('keydown', _onKey);
}, [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 (
<div ref={popRef} className={styles.popover}>
<div className={styles.popover}>
<div className={styles.body}>
{/* Column 1: Presets */}
<div className={styles.colPresets}>

View file

@ -75,18 +75,13 @@
font-size: 1.1rem;
}
/* Dropdown Content - opens upward */
/* Dropdown content — positioned by FloatingPortal */
.dropdownContent {
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
z-index: 1000;
padding: 8px;
background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,9 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef } from 'react';
import { IconType } from 'react-icons';
import { IoChevronDown, IoClose } from 'react-icons/io5';
import { FloatingPortal } from '../FloatingPortal';
import styles from './DropdownSelect.module.css';
import { ButtonVariant, ButtonSize } from '../Button/ButtonTypes';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -61,24 +62,7 @@ function DropdownSelect<T = any>({
const resolvedPlaceholder = placeholder ?? t('Element auswählen');
const resolvedEmptyMessage = emptyMessage ?? t('Keine Einträge verfügbar');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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]);
const triggerRef = useRef<HTMLButtonElement>(null);
// Find selected item
const selectedItem = selectedItemId !== null && selectedItemId !== undefined
@ -174,8 +158,7 @@ function DropdownSelect<T = any>({
};
return (
<div ref={dropdownRef} className={styles.dropdownContainer} style={{ minWidth }}>
{/* Show clear button if item is selected and showClearButton is enabled */}
<div className={styles.dropdownContainer} style={{ minWidth }}>
{selectedItem && showClearButton ? (
renderClearButtonContent()
) : renderButton ? (
@ -184,23 +167,31 @@ function DropdownSelect<T = any>({
</div>
) : (
<button
ref={triggerRef}
type="button"
className={buttonClasses}
onClick={toggleDropdown}
disabled={disabled || loading}
aria-expanded={isOpen}
>
{renderDefaultButton()}
</button>
)}
{isOpen && (
<div className={styles.dropdownMenu} style={{ maxHeight }}>
<FloatingPortal
open={isOpen}
anchorRef={triggerRef}
onClose={() => setIsOpen(false)}
placement="bottom"
align="start"
>
<div className={styles.dropdownMenu} style={{ maxHeight, minWidth }}>
{headerText && (
<div className={styles.dropdownHeader}>
{headerText}
</div>
)}
{items.length === 0 ? (
<div className={styles.dropdownEmpty}>
{resolvedEmptyMessage}
@ -209,7 +200,7 @@ function DropdownSelect<T = any>({
<div className={styles.dropdownItems}>
{items.map((item) => {
const isSelected = selectedItemId === item.id;
if (renderItem) {
return (
<div
@ -237,7 +228,7 @@ function DropdownSelect<T = any>({
</div>
)}
</div>
)}
</FloatingPortal>
</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 type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
export * from './AutoScroll';
export * from './Tabs';
export type { TabsProps, Tab } from './Tabs';
export * from './AccordionList';
export * from './Toast';
export * from './VoiceLanguageSelect';
export * from './Modal';
export * from './FloatingPortal';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,8 @@ import type { KeepAliveEntry } from '../types/keepAlive.types';
import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage';
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
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 { WorkflowAutomationPage } from '../pages/workflowAutomation/WorkflowAutomationHubPage';
@ -31,6 +33,19 @@ export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
shellOverflowHidden: false,
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',
pathRegex: /\/admin\/languages(?:$|\/)/,

View file

@ -136,7 +136,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.neutralization': <FaShieldAlt />,
'feature.trustee': <FaBriefcase />,
'feature.realestate': <FaBuilding />,
'feature.chatworkflow': <FaPlay />,
'feature.teamsbot': <FaHeadset />,
// 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';
}
// =============================================================================
// 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
// =============================================================================
export function useNavigation(): UseNavigationReturn {
const [blocks, setBlocks] = useState<NavigationBlock[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [, setRevision] = useState(0);
const fetchNavigation = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await api.get<NavigationResponse>('/api/navigation');
setBlocks(response.data.blocks || []);
} catch (err: unknown) {
const errorMsg = err instanceof Error
? err.message
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|| 'Fehler beim Laden der Navigation';
setError(errorMsg);
setBlocks([]);
} finally {
setLoading(false);
}
useEffect(() => {
_ensureNavigationChangeListeners();
const listener = () => setRevision((revision) => revision + 1);
listeners.add(listener);
void _fetchNavigationShared();
return () => {
listeners.delete(listener);
};
}, []);
useEffect(() => {
fetchNavigation();
}, [fetchNavigation]);
const refresh = useCallback(() => _fetchNavigationShared(true), []);
useEffect(() => {
const onFeaturesChanged = () => {
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;
const staticBlocks = sharedBlocks.filter(isStaticBlock);
const dynamicBlock = sharedBlocks.find(isDynamicBlock) || null;
return {
blocks,
blocks: sharedBlocks,
staticBlocks,
dynamicBlock,
loading,
error,
refresh: fetchNavigation,
loading: sharedLoading,
error: sharedError,
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 {
display: flex;
flex-direction: column;
position: relative;
flex-shrink: 0;
width: 280px;
min-width: 280px;
height: 100%;
@ -31,13 +33,25 @@
z-index: 1200;
}
.sidebarCollapsed {
overflow: visible;
}
/* Logo */
.logoContainer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.25rem 1rem;
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 {
@ -46,6 +60,55 @@
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 {
font-size: 1.5rem;
font-weight: 700;
@ -94,6 +157,7 @@
min-height: 0;
position: relative;
--mobile-topbar-height: 0px;
--content-inset: 16px;
display: flex;
flex-direction: column;
/* Let child components handle their own scrolling for sticky headers */
@ -113,6 +177,8 @@
overflow-x: auto;
overflow-y: auto;
-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 */
@ -169,6 +235,17 @@
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 {
color: var(--text-primary-dark, #ffffff);
}
@ -224,13 +301,20 @@
top: 0;
left: 0;
bottom: 0;
width: 280px;
min-width: 280px;
height: 100dvh;
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);
border-right: 1px solid var(--border-color, #e0e0e0);
}
.collapseToggle,
.resizeHandle {
display: none;
}
@supports not (height: 100dvh) {
.sidebar {
height: 100vh;
@ -259,6 +343,7 @@
.content {
--mobile-topbar-height: 57px;
--content-inset: 8px;
}
.mobileBackdrop {

View file

@ -7,8 +7,9 @@
* 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 { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
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 { isKeepAliveScoped } from '../types/keepAlive.types';
import { useScrollMode } from '../hooks/useScrollMode';
import { SidebarContext } from './SidebarContext';
import styles from './MainLayout.module.css';
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 => ({
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
left: 'var(--content-inset, 16px)',
right: 'var(--content-inset, 16px)',
bottom: 0,
...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}),
});
@ -111,8 +145,42 @@ const MainLayoutInner: React.FC = () => {
const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation();
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 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
useEffect(() => {
if (!initialized && !loading) {
@ -126,7 +194,9 @@ const MainLayoutInner: React.FC = () => {
useEffect(() => {
const handleResize = () => {
if (window.innerWidth > 1024) {
const desktop = _isDesktopViewport();
setIsDesktop(desktop);
if (desktop) {
setIsMobileSidebarOpen(false);
}
};
@ -135,65 +205,170 @@ const MainLayoutInner: React.FC = () => {
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 (
<div className={styles.mainLayout}>
{isMobileSidebarOpen && (
<button
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}>
<SidebarContext.Provider value={sidebarContextValue}>
<div className={styles.mainLayout}>
{isMobileSidebarOpen && (
<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>
className={styles.mobileBackdrop}
onClick={() => setIsMobileSidebarOpen(false)}
aria-label={t('Navigation schließen')}
/>
)}
{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 }}
{/* Sidebar */}
<aside
ref={sidebarRef}
className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''} ${effectiveCollapsed ? styles.sidebarCollapsed : ''}`}
style={isDesktop ? sidebarStyle : undefined}
>
<Outlet />
</div>
</main>
<div className={styles.logoContainer}>
<img
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 />
</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 />
{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 { useSearchParams } from 'react-router-dom';
import {
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, BarChart, Bar, PieChart, Pie, Cell,
} from 'recharts';
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
import { FaDownload, FaEye, FaTrash } from 'react-icons/fa';
import api from '../api';
import { useApiRequest } from '../hooks/useApi';
import { fetchAttributes } from '../api/attributesApi';
@ -29,6 +30,11 @@ import {
resolvePeriod,
type PeriodValue,
} 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 { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
@ -130,7 +136,7 @@ interface Mandate {
label?: string;
}
interface ContentModalData {
interface ContentDetailData {
row: any;
contentInputFull?: string;
contentOutputFull?: string;
@ -146,6 +152,7 @@ const _NEUT_PAGE_SIZE = 100;
export const ComplianceAuditPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [searchParams, setSearchParams] = useSearchParams();
const [aiAuditAttrs, setAiAuditAttrs] = useState<AttributeDefinition[]>([]);
const [auditLogAttrs, setAuditLogAttrs] = useState<AttributeDefinition[]>([]);
const [neutAttrs, setNeutAttrs] = useState<AttributeDefinition[]>([]);
@ -161,7 +168,7 @@ export const ComplianceAuditPage: React.FC = () => {
const [mandates, setMandates] = useState<Mandate[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
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 ──
const [aiEntries, setAiEntries] = useState<any[]>([]);
@ -186,10 +193,7 @@ export const ComplianceAuditPage: React.FC = () => {
const [neutPagination, setNeutPagination] = useState<any>(undefined);
const [neutLoading, setNeutLoading] = useState(false);
// ── Content View Modal state ──
const [contentModal, setContentModal] = useState<ContentModalData | null>(null);
const [contentModalLoading, setContentModalLoading] = useState(false);
const [contentModalTab, setContentModalTab] = useState<'input' | 'output'>('input');
const selectedEntryId = searchParams.get('entryId');
// ── Mandate loader ──
@ -373,29 +377,14 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Content view handler (modal) ──
const _handleContentView = useCallback(async (row: any) => {
const _handleContentView = useCallback((row: any) => {
if (!selectedMandateId || !row?.id) return;
setContentModalLoading(true);
setContentModalTab('input');
setContentModal({ row, neutralizationMappings: [] });
try {
const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, {
headers: _mandateHeaders(),
});
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
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('entryId', row.id);
return next;
}, { replace: true });
}, [selectedMandateId, setSearchParams]);
// ── Content download handler ──
@ -435,17 +424,107 @@ export const ComplianceAuditPage: React.FC = () => {
}
}, [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(() => {
const map = new Map<string, NeutMapping>();
if (contentModal?.neutralizationMappings) {
for (const m of contentModal.neutralizationMappings) {
map.set(m.id, m);
useEffect(() => {
if (!selectedMandateId || !selectedEntryId) return;
let cancelled = false;
(async () => {
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;
}, [contentModal?.neutralizationMappings]);
return map;
}, [detail?.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 ──
@ -623,372 +702,337 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Render ──
const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats'];
const auditTabs: LayoutTabItem[] = useMemo(() => {
if (!selectedMandateId) return [];
return (
<div className={styles.wrap}>
<h2 className={styles.pageTitle}>{t('Compliance & AI-Audit')}</h2>
<p className={styles.pageDesc}>
{t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')}
</p>
{/* Mandate selector */}
<div className={styles.mandateSelector}>
<label className={styles.mandateLabel}>{t('Mandant auswählen')}</label>
<select
className={styles.mandateSelect}
value={selectedMandateId || ''}
onChange={e => setSelectedMandateId(e.target.value || null)}
disabled={mandatesLoading}
>
<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,
},
const statsPanel = (
<Panel variant="dashboard">
<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>
)}
{/* ── Tab B: Audit Log ── */}
{activeTab === 'audit-log' && (
<div className={styles.tabContent}>
<FormGeneratorTable
key={`audit-log-${selectedMandateId}`}
data={auditEntries}
columns={auditLogColumns}
loading={auditLoading}
pagination={true}
pageSize={_AUDIT_LOG_PAGE_SIZE}
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>
{statsLoading ? (
<p className={styles.loadingText}>{t('Lade Statistiken…')}</p>
) : !stats ? (
<p className={styles.emptyText}>{t('Keine Daten verfügbar.')}</p>
) : (
<>
<div className={styles.kpiGrid}>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{stats.totalCalls}</p>
<p className={styles.kpiLabel}>{t('AI-Aufrufe')}</p>
</div>
{/* Charts row 1: Calls/Day + Cost/Day */}
<div className={styles.chartRow}>
<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 className={styles.kpiCard}>
<p className={styles.kpiValue}>{stats.neutralizationPercent}%</p>
<p className={styles.kpiLabel}>{t('Neutralisierungsquote')}</p>
</div>
{/* Charts row 2: By Model (pie) + By Feature (bar) */}
<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>
</ResponsiveContainer>
)}
</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>
{/* Top Users */}
{Object.keys(stats.topUsers).length > 0 && (
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Top-Nutzer nach AI-Aufrufen')}</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={Object.entries(stats.topUsers).map(([name, value]) => ({ name, value }))}
layout="vertical" margin={{ left: 8, right: 16 }}
>
<div className={styles.chartRow}>
<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 type="number" allowDecimals={false} />
<YAxis type="category" dataKey="name" width={140} tick={{ fontSize: 10 }} />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
<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>
</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>
<button
className={styles.modalClose}
onClick={() => setContentModal(null)}
title={t('Schliessen')}
>
<FaTimes />
</button>
</div>
{contentModal.neutralizationMappings.length > 0 && (
<div className={styles.modalMappingBar}>
<span className={styles.modalMappingLabel}>
{t('{n} Platzhalter aufgelöst', { n: String(contentModal.neutralizationMappings.length) })}
</span>
<span className={styles.modalMappingHint}>
{t('Hover über markierte Platzhalter für Originaltext')}
</span>
{Object.keys(stats.topUsers).length > 0 && (
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Top-Nutzer nach AI-Aufrufen')}</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={Object.entries(stats.topUsers).map(([name, value]) => ({ name, value }))}
layout="vertical" margin={{ left: 8, right: 16 }}
>
<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>
)}
</>
)}
</Panel>
);
<div className={styles.modalTabBar}>
<button
className={`${styles.modalTab} ${contentModalTab === 'input' ? styles.modalTabActive : ''}`}
onClick={() => setContentModalTab('input')}
>
{t('Input')}
</button>
<button
className={`${styles.modalTab} ${contentModalTab === 'output' ? styles.modalTabActive : ''}`}
onClick={() => setContentModalTab('output')}
>
{t('Output')}
</button>
</div>
return [
{
id: 'audit-log',
label: _tabLabel('audit-log', t),
render: () => (
<Panel variant="table">
<FormGeneratorTable
key={`audit-log-${selectedMandateId}`}
data={auditEntries}
columns={auditLogColumns}
loading={auditLoading}
pagination={true}
pageSize={_AUDIT_LOG_PAGE_SIZE}
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}>
{contentModalLoading ? (
<p className={styles.loadingText}>{t('Lade Inhalt…')}</p>
) : (
<div className={styles.modalTextContent}>
{contentModalTab === 'input' ? (
(() => {
const text = contentModal.contentInputFull
|| contentModal.contentInputPreview
|| t('(kein Input gespeichert)');
return _modalMappingLookup.size > 0
? _renderHighlightedText(text, _modalMappingLookup)
: text;
})()
) : (
(() => {
const text = contentModal.contentOutputFull
|| contentModal.contentOutputPreview
|| t('(kein Output gespeichert)');
return _modalMappingLookup.size > 0
? _renderHighlightedText(text, _modalMappingLookup)
: text;
})()
)}
</div>
)}
</div>
return (
<StackLayout variant="table">
<StackLayout.Header>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Compliance & AI-Audit')}</h1>
<p className={styles.pageDesc}>
{t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')}
</p>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={styles.mandateSelector}>
<label className={styles.mandateLabel}>{t('Mandant auswählen')}</label>
<select
className={styles.mandateSelect}
value={selectedMandateId || ''}
onChange={e => setSelectedMandateId(e.target.value || null)}
disabled={mandatesLoading}
>
<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>
</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 />
</div>
</StackLayout>
);
};

View file

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

View file

@ -5,9 +5,10 @@
.featureView {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.viewHeader {
@ -29,31 +30,8 @@
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
padding: 1.5rem;
}
/* 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);
min-width: 0;
overflow: hidden;
}
/* Not Found */
@ -92,12 +70,10 @@
}
:global(.dark-theme) .viewTitle,
:global(.dark-theme) .placeholder h2,
:global(.dark-theme) .notFound h2 {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .placeholder p,
:global(.dark-theme) .notFound p,
:global(.dark-theme) .accessDenied p {
color: var(--text-secondary-dark, #aaa);
@ -115,11 +91,6 @@
/* scrollMode: document — view grows with content, no internal scroll */
:global(html[data-scroll-mode="document"]) .featureView {
height: auto;
overflow: visible;
}
:global(html[data-scroll-mode="document"]) .viewContent {
overflow: visible;
flex: 0 0 auto;
overflow: visible;
}

View file

@ -48,42 +48,10 @@ import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, C
// Redmine Views
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 { 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
const NotFound: React.FC = () => {
const { t } = useLanguage();
@ -115,18 +83,12 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
trustee: {
dashboard: TrusteeDashboardView,
'data-tables': TrusteeDataTablesView,
solutions: SolutionsView,
'instance-roles': TrusteeInstanceRolesView,
'import-process': TrusteeImportProcessView,
settings: TrusteeAccountingSettingsView,
analyse: TrusteeAnalyseView,
abschluss: TrusteeAbschlussView,
},
chatworkflow: {
dashboard: ChatworkflowDashboard,
runs: ChatworkflowRuns,
files: ChatworkflowFiles,
},
realestate: {
dashboard: RealEstatePekView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
@ -212,9 +174,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return (
<div className={styles.featureView}>
<main className={styles.viewContent}>
<ViewComponent />
</main>
<ViewComponent />
</div>
);
};

View file

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

View file

@ -8,6 +8,8 @@
import React, { useMemo } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
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';
/** de-CH: 1'234'567 */
@ -211,20 +213,24 @@ export const IntegrationsOverviewPage: React.FC = () => {
}, [diagram?.dataLayerItems]);
return (
<div className={styles.pageRoot}>
<div className={styles.pageIntro}>
<h1 className={styles.pageHeading}>{t('Integrationen')}</h1>
<p className={styles.pageLead}>
{t('PORTA Architektur — Daten, Verarbeitung und Mandanten auf einen Blick.')}
</p>
</div>
<StackLayout variant="scroll">
<StackLayout.Header>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Integrationen')}</h1>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="card">
<p className={styles.pageLead} style={{ margin: 0 }}>
{t('PORTA Architektur — Daten, Verarbeitung und Mandanten auf einen Blick.')}
</p>
</Panel>
<h2 className={styles.srOnly}>
{t('PORTA Architektur v3: Drei separate Boxen in Schicht 2 — Infrastruktur, PORTA, Nutzen')}
</h2>
<Panel variant="card">
<h2 className={styles.srOnly}>
{t('PORTA Architektur v3: Drei separate Boxen in Schicht 2 — Infrastruktur, PORTA, Nutzen')}
</h2>
<div className={styles.diagramScroll}>
<div className={styles.arch}>
<div className={styles.diagramScroll}>
<div className={styles.arch}>
{loading && <div className={styles.loadingWrap}>{t('Laden…')}</div>}
{error && (
<div className={styles.errorWrap}>
@ -487,8 +493,10 @@ export const IntegrationsOverviewPage: React.FC = () => {
</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 { useLanguage } from '../providers/language/LanguageContext';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
// Key for storing pending invitation token
export const PENDING_INVITATION_KEY = 'pendingInvitationToken';
export const InvitePage: React.FC = () => {
const { t } = useLanguage();
useDocumentTitle(t('Einladung annehmen'));
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();

View file

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

View file

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

View file

@ -3,16 +3,6 @@
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 {
display: flex;
align-items: center;

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@
* Route: /settings
*/
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../providers/language/LanguageContext';
import { useCurrentUser, useUser } from '../hooks/useUsers';
@ -15,39 +15,31 @@ import type { AttributeDefinition } from '../components/FormGenerator/FormGenera
import { useApiRequest } from '../hooks/useApi';
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
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';
// =============================================================================
// 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 {
isOpen: boolean;
onClose: () => void;
userData: any;
onSave: (data: any) => Promise<void>;
interface _ProfileTabProps {
currentUser: ReturnType<typeof useCurrentUser>['user'];
refetchUser: ReturnType<typeof useCurrentUser>['refetch'];
onSave: (formData: any) => Promise<void>;
}
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const _ProfileTab: React.FC<_ProfileTabProps> = ({ currentUser, refetchUser, onSave }) => {
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 }));
@ -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 },
];
const handleSubmit = async (formData: any) => {
setIsSaving(true);
setError(null);
const _handleProfileSubmit = async (formData: any) => {
setIsSavingProfile(true);
setProfileError(null);
try {
await onSave(formData);
onClose();
setIsProfileEditing(false);
} catch (err: any) {
setError(err.message || t('Fehler beim Speichern des Profils'));
setProfileError(err.message || t('Fehler beim Speichern des Profils'));
} finally {
setIsSaving(false);
setIsSavingProfile(false);
}
};
if (!isOpen) return null;
return (
<div className={styles.modalOverlay}>
<div className={styles.modalContent}>
<div className={styles.modalHeader}>
<h2>{t('Profil bearbeiten')}</h2>
<button className={styles.closeButton} onClick={onClose}>&times;</button>
<>
<Panel variant="card" title={t('Konto')}>
{!isProfileEditing ? (
<>
<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 className={styles.modalBody}>
{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>
</Panel>
</>
);
};
@ -206,10 +232,10 @@ const VoiceSettingsTab: React.FC = () => {
return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code;
}, [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 (
<>
<Panel variant="card">
{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>}
@ -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 }}>
{saving ? t('Speichern') : t('Einstellungen speichern')}
</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);
};
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 (
<>
<Panel variant="card">
{error && <div className={styles.errorMessage}>{error}</div>}
<section className={styles.section}>
@ -419,7 +445,7 @@ const NeutralizationMappingsTab: React.FC = () => {
</table>
)}
</section>
</>
</Panel>
);
};
@ -507,11 +533,11 @@ const MfaSettingsTab: React.FC = () => {
};
if (loading) {
return <section className={styles.section}><p>{t('wird geladen…')}</p></section>;
return <Panel variant="card"><p>{t('wird geladen…')}</p></Panel>;
}
return (
<section className={styles.section}>
<Panel variant="card">
<h2 className={styles.sectionTitle}>{t('Zwei-Faktor-Authentifizierung (MFA)')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
{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>
)}
</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 { updateUser } = useUser();
const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light');
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
const [languageError, setLanguageError] = useState<string | null>(null);
@ -661,110 +757,79 @@ export const SettingsPage: React.FC = () => {
if (newLanguage !== currentLanguage) setLanguage(newLanguage);
if (refetchUser) await refetchUser();
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 (
<div className={styles.settings}>
<header className={styles.header}>
<h1>{t('Einstellungen')}</h1>
<StackLayout variant="scroll">
<StackLayout.Header>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Einstellungen')}</h1>
<p className={styles.subtitle}>{t('Persönliche Einstellungen und Präferenzen')}</p>
</header>
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}>
{_getTabs(t).map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
padding: '10px 20px', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--primary-color, #2563eb)' : '2px solid transparent',
background: 'none', cursor: 'pointer', fontSize: 14, fontWeight: activeTab === tab.key ? 600 : 400,
color: activeTab === tab.key ? 'var(--primary-color, #2563eb)' : 'var(--text-secondary, #888)',
}}>
{tab.label}
</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>
</StackLayout.Header>
<StackLayout.Body>
<LayoutTabs
items={settingsTabs}
urlParam="tab"
defaultTab="profile"
lazy
/>
</StackLayout.Body>
</StackLayout>
);
};

View file

@ -13,6 +13,8 @@ import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import { useStore, _storeActionKey } from '../hooks/useStore';
import type { StoreFeature, UserMandate } from '../api/storeApi';
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
import { StackLayout } from '../components/Layout/StackLayout';
import { Panel } from '../components/Layout/Panel';
import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = {
@ -23,7 +25,6 @@ const FEATURE_ICONS: Record<string, React.ReactNode> = {
trustee: <FaShieldAlt />,
};
/** Fallback when GET /store/features omits description (German i18n keys). */
const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = {
automation: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.',
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 { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
return (
<div className={styles.store}>
<div className={styles.header}>
<h1>{t('Feature Store')}</h1>
<StackLayout variant="dashboard">
<StackLayout.Header>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Feature Store')}</h1>
<p className={styles.subtitle}>
{t('Aktiviere Features für dein Konto. Deine Daten sind isoliert und nur für dich sichtbar.')}
</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 && (
<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>
)}
{error && (
<Panel variant="card">
<div className={styles.error}>{error}</div>
</Panel>
)}
{error && <div className={styles.error}>{error}</div>}
{loading ? (
<div className={styles.loading}>
{t('Lade Features…')}
</div>
) : features.length === 0 ? (
<div className={styles.empty}>
{t('Keine Features im Store verfügbar.')}
</div>
) : (
<div className={styles.grid}>
{features.map((feature) => (
<FeatureCard
key={feature.featureCode}
feature={feature}
mandates={mandates}
actionLoading={actionLoading}
onActivate={activate}
onDeactivate={deactivate}
/>
))}
</div>
)}
</div>
{loading ? (
<Panel variant="card">
<div className={styles.loading}>
{t('Lade Features…')}
</div>
</Panel>
) : features.length === 0 ? (
<Panel variant="card">
<div className={styles.empty}>
{t('Keine Features im Store verfügbar.')}
</div>
</Panel>
) : (
<Panel variant="dashboard">
<div className={styles.grid}>
{features.map((feature) => (
<FeatureCard
key={feature.featureCode}
feature={feature}
mandates={mandates}
actionLoading={actionLoading}
onActivate={activate}
onDeactivate={deactivate}
/>
))}
</div>
</Panel>
)}
</StackLayout.Body>
</StackLayout>
);
};

View file

@ -17,6 +17,8 @@ import {
} from '../../hooks/useFeatureAccess';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
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 api from '../../api';
import styles from './Admin.module.css';
@ -306,31 +308,37 @@ export const AccessManagementHub: React.FC = () => {
if (error && !selectedMandateId) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
<StackLayout variant="scroll">
<StackLayout.Body>
<Panel variant="card">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<>
<StackLayout variant="scroll">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Zugriffsverwaltung')}</h1>
<p className={styles.pageSubtitle}>
{t('Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten')}
</p>
</div>
</div>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={hubStyles.filters}>
{/* Filter dropdowns only shown in list view - hierarchy shows everything */}
{viewMode === 'list' && (
@ -419,6 +427,7 @@ export const AccessManagementHub: React.FC = () => {
<FaUsers /> {t('Mandant-Benutzer')}
</Link>
</div>
</Panel>
{viewMode === 'hierarchy' ? (
<InstanceHierarchyView
@ -432,6 +441,7 @@ export const AccessManagementHub: React.FC = () => {
onOpenDetail={handleOpenDetail}
/>
) : !selectedMandateId ? (
<Panel variant="card">
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<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.')}
</p>
</div>
</Panel>
) : (
<>
<Panel variant="dashboard">
<div className={hubStyles.overviewRow}>
<div className={hubStyles.statsCard}>
<FaChartBar className={hubStyles.statsIcon} />
@ -493,7 +505,9 @@ export const AccessManagementHub: React.FC = () => {
</div>
)}
</div>
</Panel>
<Panel variant="dashboard">
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>{t('Feature-Instanzen')}</h2>
{loading && filteredInstances.length === 0 ? (
@ -560,8 +574,11 @@ export const AccessManagementHub: React.FC = () => {
</div>
)}
</section>
</Panel>
</>
)}
</StackLayout.Body>
</StackLayout>
{detailInstance && (
<InstanceDetailModal
@ -583,7 +600,7 @@ export const AccessManagementHub: React.FC = () => {
onComplete={handleWizardComplete}
/>
)}
</div>
</>
);
};

View file

@ -4,38 +4,6 @@
* 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 {
font-size: 1.5rem;
font-weight: 600;
@ -213,15 +181,6 @@
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 {
display: flex;
flex-direction: column;
@ -731,12 +690,6 @@
}
@media (max-width: 1024px) {
.pageHeader {
align-items: flex-start;
flex-direction: column;
gap: 0.75rem;
}
.headerActions {
width: 100%;
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) {
.adminPage {
padding: 0.75rem;
}
.pageHeader {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.pageTitle {
font-size: 1.1rem;
}
@ -798,10 +728,6 @@
min-height: 32px;
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 { useToast } from '../../contexts/ToastContext';
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';
@ -311,8 +314,8 @@ const StatsTab: React.FC = () => {
], [t, databases]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* Controls */}
<>
<Panel variant="toolbar">
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label>
@ -329,8 +332,9 @@ const StatsTab: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</Panel>
{/* Summary */}
<Panel variant="card">
<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('{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('Index {size}', { size: _formatBytes(totals.idx) })}</span>
</div>
</Panel>
<div className={styles.tableContainer}>
<Panel variant="table">
<FormGeneratorTable
data={visibleData}
columns={columns}
filterScopeKey="admin"
loading={loading}
sortable={true}
searchable={true}
@ -357,8 +363,8 @@ const StatsTab: React.FC = () => {
}}
emptyMessage={t('Keine Tabellen gefunden')}
/>
</div>
</div>
</Panel>
</>
);
};
@ -625,10 +631,10 @@ const OrphansTab: React.FC = () => {
], [t, databases]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<>
<ConfirmDialog />
{/* Controls */}
<Panel variant="toolbar">
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label>
@ -671,8 +677,10 @@ const OrphansTab: React.FC = () => {
)}
</div>
</div>
</Panel>
{totalOrphans > 0 && (
<Panel variant="card">
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
{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,
})}
</div>
</Panel>
)}
<div className={styles.tableContainer}>
<Panel variant="table">
<FormGeneratorTable
data={visibleData}
columns={columns}
filterScopeKey="admin"
loading={loading}
sortable={true}
searchable={true}
@ -718,8 +728,8 @@ const OrphansTab: React.FC = () => {
}}
emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')}
/>
</div>
</div>
</Panel>
</>
);
};
@ -1157,10 +1167,10 @@ const MigrationTab: React.FC = () => {
};
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto', gap: '2rem', padding: '0.5rem 0' }}>
<>
<ConfirmDialog />
{/* ---- BACKUP SECTION ---- */}
<Panel variant="card" title={t('Backup')}>
<section>
<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')}
@ -1240,17 +1250,10 @@ const MigrationTab: React.FC = () => {
</>
)}
</section>
</Panel>
{/* ---- DIVIDER ---- */}
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: 0 }} />
{/* ---- RESTORE SECTION ---- */}
<Panel variant="card" title={t('Restore')}>
<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 ? (
<div
onDrop={_onDrop}
@ -1467,7 +1470,8 @@ const MigrationTab: React.FC = () => {
</div>
)}
</section>
</div>
</Panel>
</>
);
};
@ -1661,9 +1665,10 @@ const LegacyCleanupTab: React.FC = () => {
], [t, databases, selected]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<>
<ConfirmDialog />
<Panel variant="toolbar">
<div className={styles.filterSection}>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchLegacy} disabled={loading}>
@ -1681,20 +1686,24 @@ const LegacyCleanupTab: React.FC = () => {
)}
</div>
</div>
</Panel>
{allLegacy.length > 0 && (
<Panel variant="card">
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
{t('{count} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', {
count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size),
})}
</div>
</Panel>
)}
<div className={styles.tableContainer}>
<Panel variant="table">
<FormGeneratorTable
data={visibleData}
columns={columns}
filterScopeKey="admin"
loading={loading}
sortable={true}
searchable={true}
@ -1718,8 +1727,8 @@ const LegacyCleanupTab: React.FC = () => {
}}
emptyMessage={t('Keine Legacy-Tabellen gefunden')}
/>
</div>
</div>
</Panel>
</>
);
};
@ -1731,40 +1740,41 @@ const LegacyCleanupTab: React.FC = () => {
export const AdminDatabaseHealthPage: React.FC = () => {
const { t } = useLanguage();
const tabs = useMemo(() => [
const tabs: LayoutTabItem[] = useMemo(() => [
{
id: 'stats',
label: t('Statistiken'),
content: <StatsTab />,
render: () => <StatsTab />,
},
{
id: 'orphans',
label: t('Orphan Cleanup'),
content: <OrphansTab />,
render: () => <OrphansTab />,
},
{
id: 'legacy',
label: t('Legacy Cleanup'),
content: <LegacyCleanupTab />,
render: () => <LegacyCleanupTab />,
},
{
id: 'migration',
label: t('Migration'),
content: <MigrationTab />,
render: () => <MigrationTab />,
},
], [t]);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<StackLayout variant="table">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p>
</div>
</div>
<Tabs tabs={tabs} defaultTabId="stats" />
</div>
</StackLayout.Header>
<StackLayout.Body>
<LayoutTabs items={tabs} urlParam="tab" defaultTab="stats" lazy />
</StackLayout.Body>
</StackLayout>
);
};

View file

@ -9,6 +9,8 @@
import React, { useState, useEffect, useCallback } from 'react';
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 styles from './Admin.module.css';
import demoStyles from './AdminDemoConfigPage.module.css';
@ -111,77 +113,88 @@ export const AdminDemoConfigPage: React.FC = () => {
};
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<StackLayout variant="dashboard">
<StackLayout.Header>
<div>
<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>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
<FaSync /> {t('Aktualisieren')}
</button>
</div>
</div>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
<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 && (
<div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}>
<strong>{lastResult.action === 'load' ? t('Geladen') : t('Entfernt')}:</strong>{' '}
{lastResult.status === 'ok' ? (
<_SummaryDisplay summary={lastResult.summary} />
) : (
<span>{lastResult.error}</span>
)}
{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>
{lastResult && (
<Panel variant="card">
<div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}>
<strong>{lastResult.action === 'load' ? t('Geladen') : t('Entfernt')}:</strong>{' '}
{lastResult.status === 'ok' ? (
<_SummaryDisplay summary={lastResult.summary} />
) : (
<span>{lastResult.error}</span>
)}
{lastResult.status === 'ok' && lastResult.action === 'load' && lastResult.credentials && lastResult.credentials.length > 0 && (
<_CredentialsBox credentials={lastResult.credentials} />
)}
</div>
))}
</div>
)}
</Panel>
)}
<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 />
</div>
</StackLayout>
);
};

View file

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

View file

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

View file

@ -17,6 +17,8 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { AccessRulesEditor } from '../../components/AccessRules';
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 api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
@ -284,97 +286,106 @@ export const AdminFeatureRolesPage: React.FC = () => {
if (error && !selectedFeatureCode) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{error}</p>
<button className={styles.secondaryButton} onClick={() => window.location.reload()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
<StackLayout variant="table">
<StackLayout.Body>
<Panel variant="card">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{error}</p>
<button className={styles.secondaryButton} onClick={() => window.location.reload()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<>
<StackLayout variant="table">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Feature-Rollen-Rechte')}</h1>
<p className={styles.pageSubtitle}>{t('Template-Rollen und deren Berechtigungen für')}</p>
</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 */}
<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>
{selectedFeatureCode && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neue Feature-Rolle')}
</button>
{selectedFeatureCode && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neue Feature-Rolle')}
</button>
</div>
)}
</div>
</Panel>
{selectedFeatureCode && (
<Panel variant="card">
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<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.')}
</span>
</div>
</Panel>
)}
</div>
{/* Info Box */}
{selectedFeatureCode && (
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<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.')}
</span>
</div>
)}
{/* Content */}
{!selectedFeatureCode ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Feature ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.')}
</p>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/features/templates/roles"
loading={loading}
{!selectedFeatureCode ? (
<Panel variant="card">
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Feature ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.')}
</p>
</div>
</Panel>
) : (
<Panel variant="table">
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/features/templates/roles"
filterScopeKey={selectedFeatureCode || 'admin'}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
@ -407,9 +418,11 @@ export const AdminFeatureRolesPage: React.FC = () => {
handleDelete: handleDeleteRole,
}}
emptyMessage={t('Keine Feature-Rollen gefunden')}
/>
</div>
)}
/>
</Panel>
)}
</StackLayout.Body>
</StackLayout>
{/* Create Role Modal */}
{showCreateModal && (
@ -511,7 +524,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
</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 { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
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 { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
@ -202,104 +204,112 @@ export const AdminInvitationsPage: React.FC = () => {
if (error && !selectedMandateId) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
<StackLayout variant="table">
<StackLayout.Body>
<Panel variant="card">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<>
<StackLayout variant="table">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Einladungen')}</h1>
<p className={styles.pageSubtitle}>{t('Erstellen und verwalten Sie Einladungen')}</p>
</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.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>
<div className={styles.filterGroup}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showExpired}
onChange={(e) => setShowExpired(e.target.checked)}
/>
{t('Abgelaufene anzeigen')}
</label>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showUsed}
onChange={(e) => setShowUsed(e.target.checked)}
/>
{t('Verwendete anzeigen')}
</label>
</div>
<div className={styles.filterGroup}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showExpired}
onChange={(e) => setShowExpired(e.target.checked)}
/>
{t('Abgelaufene anzeigen')}
</label>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showUsed}
onChange={(e) => setShowUsed(e.target.checked)}
/>
{t('Verwendete anzeigen')}
</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>
{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>
)}
</div>
</Panel>
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.')}
</p>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={invitations}
columns={columns}
apiEndpoint="/api/invitations/"
loading={loading}
{!selectedMandateId ? (
<Panel variant="card">
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.')}
</p>
</div>
</Panel>
) : (
<Panel variant="table">
<FormGeneratorTable
data={invitations}
columns={columns}
apiEndpoint="/api/invitations/"
filterScopeKey={selectedMandateId || 'admin'}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
@ -326,9 +336,11 @@ export const AdminInvitationsPage: React.FC = () => {
pagination,
}}
emptyMessage={t('Keine Einladungen gefunden')}
/>
</div>
)}
/>
</Panel>
)}
</StackLayout.Body>
</StackLayout>
{/* Create Invitation Modal */}
{showCreateModal && (
@ -429,7 +441,7 @@ export const AdminInvitationsPage: React.FC = () => {
</div>
</div>
)}
</div>
</>
);
};

View file

@ -16,6 +16,8 @@ import type { AttributeDefinition } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './Admin.module.css';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
type LangRow = {
id: string;
@ -869,61 +871,66 @@ export const AdminLanguagesPage: React.FC = () => {
const isBusy = progress !== null;
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem', position: 'relative' }}>
<header>
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
{error && !progress && (
<p style={{ color: 'var(--error-color, #c53030)' }}>
{error}
</p>
)}
</header>
<StackLayout variant="table" className="" style={{ position: 'relative' }}>
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
{error && !progress && (
<p style={{ color: 'var(--error-color, #c53030)' }}>
{error}
</p>
)}
</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' }}>
<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>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<FormGeneratorTable
data={displayRows}
columns={columns}
loading={loading}
<Panel variant="table" className="" style={{ position: 'relative' }}>
<FormGeneratorTable
data={displayRows}
columns={columns}
filterScopeKey="admin"
loading={loading}
pagination={false}
selectable={false}
searchable={false}
@ -960,11 +967,11 @@ export const AdminLanguagesPage: React.FC = () => {
emptyMessage={t('Keine Einträge')}
/>
{progress && <_ProgressOverlay progress={progress} onAbort={_abortRunning} />}
</div>
{progress && <_ProgressOverlay progress={progress} onAbort={_abortRunning} />}
</Panel>
</StackLayout.Body>
<ConfirmDialog />
</div>
</StackLayout>
);
};

View file

@ -10,6 +10,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { FaSync, FaDownload } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import api from '../../api';
import styles from './Admin.module.css';
import logStyles from './AdminLogsPage.module.css';
@ -107,8 +109,8 @@ export const AdminLogsPage: React.FC = () => {
};
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<StackLayout variant="scroll">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Gateway-Logs')}</h1>
<p className={styles.pageSubtitle}>
@ -126,88 +128,95 @@ export const AdminLogsPage: React.FC = () => {
<FaDownload /> {t('Download')}
</button>
</div>
</div>
<div className={logStyles.controls}>
<div className={logStyles.loadGroup}>
<label className={logStyles.controlLabel}>{t('Letzte')}</label>
<input
type="number"
className={logStyles.countInput}
value={countInput}
onChange={(e) => setCountInput(e.target.value)}
onKeyDown={_handleKeyDown}
min={1}
max={50000}
/>
<label className={logStyles.controlLabel}>{t('Einträge')}</label>
<button
className={styles.primaryButton}
onClick={_handleLoad}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Laden')}
</button>
</div>
<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}
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={logStyles.controls}>
<div className={logStyles.loadGroup}>
<label className={logStyles.controlLabel}>{t('Letzte')}</label>
<input
type="number"
className={logStyles.countInput}
value={countInput}
onChange={(e) => setCountInput(e.target.value)}
onKeyDown={_handleKeyDown}
min={1}
max={50000}
/>
<label className={logStyles.controlLabel}>{t('Einträge')}</label>
<button
className={styles.primaryButton}
onClick={_handleLoad}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Laden')}
</button>
</div>
);
})}
</div>
</div>
<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>
</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,
FaCheckCircle
} from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
@ -225,24 +227,28 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler beim Laden')}: {error}
</p>
<button className={styles.secondaryButton} onClick={handleRefresh}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
<StackLayout variant="scroll">
<StackLayout.Body>
<Panel variant="card">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler beim Laden')}: {error}
</p>
<button className={styles.secondaryButton} onClick={handleRefresh}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<div className={styles.adminPage}>
{/* Header */}
<div className={styles.pageHeader}>
<>
<StackLayout variant="scroll">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
@ -253,26 +259,26 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
<button
className={styles.secondaryButton}
onClick={_openCleanupModal}
disabled={loading}
title={t('Doppelte Regeln finden und bereinigen')}
>
<FaBroom /> {t('Duplikate bereinigen')}
</button>
<button
className={styles.secondaryButton}
<button
className={styles.secondaryButton}
onClick={handleRefresh}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
{/* Filters */}
<div className={styles.filterBar}>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar">
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Mandant')}</label>
<select
@ -303,8 +309,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</select>
</div>
</div>
</Panel>
{/* Info Box */}
<Panel variant="card">
<div className={styles.infoBox}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span>
@ -314,17 +321,19 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
</span>
</div>
</Panel>
{/* Loading State */}
{loading && (
<Panel variant="card">
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Rollen')}</span>
</div>
</Panel>
)}
{/* Empty State */}
{!loading && roles.length === 0 && (
<Panel variant="card">
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<p>{t('Keine Rollen gefunden')}</p>
@ -336,10 +345,11 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
: t('Es gibt noch keine Rollen')}
</p>
</div>
</Panel>
)}
{/* Roles List */}
{!loading && roles.length > 0 && (
<Panel variant="card">
<div className={styles.rolesList}>
{roles.map(role => (
<div key={role.id} className={styles.roleCard}>
@ -387,7 +397,10 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</div>
))}
</div>
</Panel>
)}
</StackLayout.Body>
</StackLayout>
{/* Cleanup Duplicates Modal */}
{showCleanupModal && (
@ -598,7 +611,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</div>
</div>
)}
</div>
</>
);
};

View file

@ -22,6 +22,8 @@ import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
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 { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
@ -247,23 +249,28 @@ export const AdminMandateRolesPage: React.FC = () => {
if (error && !selectedMandateId) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
<StackLayout variant="table">
<StackLayout.Body>
<Panel variant="card">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<>
<StackLayout variant="table">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Rollen')}</h1>
<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')}
</button>
</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.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>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Filter')}</label>
<select
className={styles.filterSelect}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
style={{ minWidth: 150 }}
>
<option value="mandate">{t('Mandanten-Rollen')}</option>
<option value="all">{t('Alle inkl. Templates')}</option>
<option value="global">{t('Nur Templates')}</option>
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Filter')}</label>
<select
className={styles.filterSelect}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
style={{ minWidth: 150 }}
>
<option value="mandate">{t('Mandanten-Rollen')}</option>
<option value="all">{t('Alle inkl. Templates')}</option>
<option value="global">{t('Nur Templates')}</option>
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles(selectedMandateId, { scopeFilter })}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neue Rolle')}
</button>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles(selectedMandateId, { scopeFilter })}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neue Rolle')}
</button>
</div>
)}
</div>
</Panel>
{selectedMandateId && (
<Panel variant="card">
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<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.')}{' '}
<strong>{t('Mandanten-Rollen')}</strong>{' '}
{t('gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.')}
</span>
</div>
</Panel>
)}
</div>
{/* Info Box */}
{selectedMandateId && (
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<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.')}{' '}
<strong>{t('Mandanten-Rollen')}</strong>{' '}
{t('gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.')}
</span>
</div>
)}
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<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}
{!selectedMandateId ? (
<Panel variant="card">
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.')}
</p>
</div>
</Panel>
) : (
<Panel variant="table">
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/rbac/roles"
filterScopeKey={selectedMandateId || 'admin'}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
@ -395,9 +406,11 @@ export const AdminMandateRolesPage: React.FC = () => {
handleDelete: handleDeleteRole,
}}
emptyMessage={t('Keine Rollen gefunden')}
/>
</div>
)}
/>
</Panel>
)}
</StackLayout.Body>
</StackLayout>
{/* Create Role Modal */}
{showCreateModal && (
@ -481,7 +494,7 @@ export const AdminMandateRolesPage: React.FC = () => {
</div>
</div>
)}
</div>
</>
);
};

View file

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

View file

@ -7,8 +7,12 @@
* 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 { 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 styles from './Admin.module.css';
@ -83,7 +87,7 @@ interface UserAccessOverview {
type TabId = 'overview' | 'ui' | 'data' | 'resources';
export const AdminUserAccessOverviewPage: React.FC = () => {
const { t } = useLanguage();
const { t } = useLanguage();
const [users, setUsers] = useState<UserOption[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>('');
@ -91,7 +95,6 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [expandedRoles, setExpandedRoles] = 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) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button
className={styles.secondaryButton}
onClick={() => window.location.reload()}
>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
<StackLayout variant="scroll">
<StackLayout.Body>
<Panel variant="card">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button
className={styles.secondaryButton}
onClick={() => window.location.reload()}
>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<StackLayout variant="scroll">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Benutzerzugriffsübersicht')}</h1>
<p className={styles.pageSubtitle}>{t('Zeigt alle Berechtigungen eines Benutzers')}</p>
</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 */}
<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>
{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>
</>
{selectedUserId && (
<button
className={styles.secondaryButton}
onClick={() => setSelectedUserId(selectedUserId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
)}
</div>
</Panel>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1rem',
borderBottom: '1px solid var(--border-color)',
paddingBottom: '0.5rem',
flexShrink: 0
}}>
<button
className={activeTab === 'overview' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('overview')}
style={{ padding: '0.5rem 1rem' }}
>
<FaUserShield /> {t('Übersicht')}
</button>
<button
className={activeTab === 'ui' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('ui')}
style={{ padding: '0.5rem 1rem' }}
>
<FaEye /> {t('UI-Zugriff')} ({overview.uiAccess.length})
</button>
<button
className={activeTab === 'data' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('data')}
style={{ padding: '0.5rem 1rem' }}
>
<FaDatabase /> {t('Daten-Zugriff')} ({overview.dataAccess.length})
</button>
<button
className={activeTab === 'resources' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('resources')}
style={{ padding: '0.5rem 1rem' }}
>
<FaCube /> {t('Ressourcen')} ({overview.resourceAccess.length})
</button>
</div>
{!selectedUserId ? (
<Panel variant="card">
<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>
</Panel>
) : loading ? (
<Panel variant="card">
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Zugriffsübersicht')}</span>
</div>
</Panel>
) : overview ? (
<>
<Panel variant="card">
<div className={styles.infoBox}>
<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>
</Panel>
{/* Tab Content */}
<div className={styles.tableContainer}>
{activeTab === 'overview' && renderOverviewTab()}
{activeTab === 'ui' && renderUiAccessTab()}
{activeTab === 'data' && renderDataAccessTab()}
{activeTab === 'resources' && renderResourceAccessTab()}
</div>
</>
) : null}
</div>
<LayoutTabs items={accessTabs} urlParam="tab" defaultTab="overview" />
</>
) : null}
</StackLayout.Body>
</StackLayout>
);
};

View file

@ -12,6 +12,8 @@ import { useUserMandates, type MandateUser, type Mandate, type Role, type Pagina
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
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 { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
@ -254,86 +256,94 @@ export const AdminUserMandatesPage: React.FC = () => {
if (error && !selectedMandateId) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
<StackLayout variant="table">
<StackLayout.Body>
<Panel variant="card">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<>
<StackLayout variant="table">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Mandanten-Mitglieder')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie, welche Benutzer Zugriff')}</p>
</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 */}
<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>
{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>
{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>
)}
</div>
</Panel>
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.')}
</p>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined}
loading={loading}
{!selectedMandateId ? (
<Panel variant="card">
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.')}
</p>
</div>
</Panel>
) : (
<Panel variant="table">
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined}
filterScopeKey={selectedMandateId || 'admin'}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
@ -366,9 +376,11 @@ export const AdminUserMandatesPage: React.FC = () => {
},
}}
emptyMessage={t('Keine Mitglieder gefunden')}
/>
</div>
)}
/>
</Panel>
)}
</StackLayout.Body>
</StackLayout>
{/* Add User Modal */}
{showAddModal && (
@ -435,7 +447,7 @@ export const AdminUserMandatesPage: React.FC = () => {
</div>
</div>
)}
</div>
</>
);
};

View file

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

View file

@ -9,7 +9,8 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useFeatureAccess, type FeatureInstance, type FeatureAccessUser } from '../../hooks/useFeatureAccess';
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 { useToast } from '../../contexts/ToastContext';
import api from '../../api';
@ -224,11 +225,11 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
[roleOptions, t]
);
const tabs = [
const tabItems: LayoutTabItem[] = useMemo(() => [
{
id: 'users',
label: t('Benutzer'),
content: (
render: () => (
<div className={modalStyles.tabContent}>
{loading ? (
<div className={styles.loadingContainer}>
@ -250,7 +251,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
{
id: 'roles',
label: t('Rollen'),
content: (
render: () => (
<div className={modalStyles.tabContent}>
<p className={modalStyles.rolesIntro}>
{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',
label: t('Einstellungen'),
content: (
render: () => (
<div className={modalStyles.tabContent}>
<FormGeneratorForm
attributes={[
@ -291,7 +292,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
</div>
),
},
];
], [t, loading, users, roles, syncing, instance, handleEditUser, handleRemoveUser, handleSyncRoles, handleUpdateInstance]);
return (
<div className={styles.modalOverlay}>
@ -308,7 +309,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
</button>
</div>
<div className={styles.modalContent}>
<Tabs tabs={tabs} defaultTabId="users" />
<LayoutTabs items={tabItems} urlParam="modalTab" defaultTab="users" />
</div>
</div>

View file

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

View file

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

View file

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

View file

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

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