Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d579df1c92 | |||
| e29c99a849 |
194 changed files with 11880 additions and 12608 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
gap: 10px;
|
||||
width: 100%;
|
||||
font-family: var(--font-family);
|
||||
min-height: 0;
|
||||
/* Floor for the bounded chain: below this the table stops shrinking and the
|
||||
nearest scroll ancestor (.viewContent / .outletShell) shows a scrollbar
|
||||
instead of squeezing the table to invisibility on short viewports. */
|
||||
min-height: var(--table-min-height, 280px);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
|
@ -325,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 {
|
||||
|
|
@ -960,6 +958,26 @@ tbody .actionsColumn {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* scrollMode: document — table grows to natural height, page scrolls */
|
||||
:global(html[data-scroll-mode="document"]) .formGeneratorTable {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
:global(html[data-scroll-mode="document"]) .tableWrapper {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:global(html[data-scroll-mode="document"]) .tableContainer {
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.formGeneratorTable {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
_updateContainerWidth();
|
||||
|
||||
// Initial measurement
|
||||
updateContainerWidth();
|
||||
|
||||
// Observe resize
|
||||
const resizeObserver = new ResizeObserver(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 && (
|
||||
<FloatingPortal
|
||||
open
|
||||
anchorRef={filterAnchorRef}
|
||||
onClose={() => setOpenFilterColumn(null)}
|
||||
placement="auto"
|
||||
>
|
||||
<div
|
||||
ref={filterDropdownRef}
|
||||
className={styles.filterDropdown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
@ -3152,6 +3153,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
|
||||
{resizable && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
325
src/components/Layout/LayoutTabs.module.css
Normal file
325
src/components/Layout/LayoutTabs.module.css
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/* Copyright (c) 2026 PowerOn AG */
|
||||
/* All rights reserved. */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---------- Tab bar ---------- */
|
||||
|
||||
.tabBar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border-color, #e0e0e0);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tabBar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- Group ---------- */
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #999);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.groupSeparator {
|
||||
width: 1px;
|
||||
margin: 0.5rem 0.25rem;
|
||||
background: var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
/* ---------- Tab button ---------- */
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary, #333);
|
||||
background: var(--surface-color, rgba(0, 0, 0, 0.025));
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #007bff);
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--primary-color, #007bff);
|
||||
border-bottom-color: var(--primary-color, #007bff);
|
||||
background: var(--primary-light, rgba(37, 99, 235, 0.08));
|
||||
font-weight: 600;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.tab[aria-disabled="true"] {
|
||||
color: var(--text-secondary, #999);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---------- Tab inner layout ---------- */
|
||||
|
||||
.tabIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ---------- Grouped layout (each group on its own row) ---------- */
|
||||
|
||||
.tabBarGrouped {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: visible;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Group label on its own row, tabs wrap below it. */
|
||||
.tabBarGrouped .group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.tabBarGrouped .group:last-child {
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
.tabBarGrouped .groupLabel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0 0.125rem;
|
||||
}
|
||||
|
||||
/* Tabs within a grouped row wrap naturally */
|
||||
.tabBarGrouped .group .tab {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-bottom: none;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.tabBarGrouped .group .tab.tabActive {
|
||||
background: var(--primary-light, rgba(37, 99, 235, 0.1));
|
||||
border-left: 3px solid var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
/* ---------- Tab bar row (holds tab bar + toggle) ---------- */
|
||||
|
||||
.tabBarRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabBarRowCollapsed {
|
||||
align-items: center;
|
||||
border-bottom: 2px solid var(--border-color, #e0e0e0);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.collapsedLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* ---------- Collapsible toggle ---------- */
|
||||
|
||||
.toggleBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--border-color, #d0d0d0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tabBarRowCollapsed .toggleBtn {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toggleBtn:hover {
|
||||
background: var(--primary-light, #e0e7ff);
|
||||
border-color: var(--primary-color, #2563eb);
|
||||
color: var(--primary-color, #2563eb);
|
||||
}
|
||||
|
||||
.toggleIcon {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- Tab panel ---------- */
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
border-bottom-color: var(--border-color, #3a3a3a);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .groupLabel {
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .groupSeparator {
|
||||
background: var(--border-color, #3a3a3a);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .tab {
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .tab:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
background: var(--surface-color, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
|
||||
:global(.dark-theme) .tabActive {
|
||||
color: var(--primary-color, #4da3ff);
|
||||
border-bottom-color: var(--primary-color, #4da3ff);
|
||||
background: rgba(77, 163, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .tabBarGrouped .group .tab.tabActive {
|
||||
background: rgba(77, 163, 255, 0.12);
|
||||
border-left-color: var(--primary-color, #4da3ff);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .tab[aria-disabled="true"] {
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .tabSubtitle {
|
||||
color: var(--text-secondary, #777);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .tabBarGrouped .group {
|
||||
border-bottom-color: var(--border-color, #3a3a3a);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .tabBarRowCollapsed {
|
||||
border-bottom-color: var(--border-color, #3a3a3a);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .collapsedLabel {
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .toggleBtn {
|
||||
background: var(--bg-dark, #121212);
|
||||
border-color: var(--border-dark, #444);
|
||||
color: var(--text-secondary-dark, #aaa);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .toggleBtn:hover {
|
||||
background: var(--primary-dark-bg, #1e3a5f);
|
||||
border-color: var(--primary-color, #4da3ff);
|
||||
color: var(--primary-light, #93c5fd);
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tabBar {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.tabBarGrouped .groupLabel {
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
327
src/components/Layout/LayoutTabs.tsx
Normal file
327
src/components/Layout/LayoutTabs.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
||||
import type { LayoutTabItem, LayoutTabsProps } from './types';
|
||||
import { _createLocalStorageAdapter } from './persistence';
|
||||
import styles from './LayoutTabs.module.css';
|
||||
|
||||
const _collapsePersistence = _createLocalStorageAdapter('layoutTabsCollapse');
|
||||
|
||||
function _resolveAlias(
|
||||
raw: string | null,
|
||||
aliasMap: Record<string, string> | undefined,
|
||||
): string | null {
|
||||
if (!raw) return null;
|
||||
return aliasMap?.[raw] ?? raw;
|
||||
}
|
||||
|
||||
function _findItem(
|
||||
items: LayoutTabItem[],
|
||||
id: string | null,
|
||||
): LayoutTabItem | undefined {
|
||||
if (!id) return undefined;
|
||||
return items.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function _enabledItems(items: LayoutTabItem[]): LayoutTabItem[] {
|
||||
return items.filter((item) => !item.disabled);
|
||||
}
|
||||
|
||||
interface _Group {
|
||||
key: string;
|
||||
label: string | undefined;
|
||||
items: LayoutTabItem[];
|
||||
}
|
||||
|
||||
function _buildGroups(items: LayoutTabItem[]): _Group[] {
|
||||
const groups: _Group[] = [];
|
||||
let current: _Group | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
const groupKey = item.group ?? '';
|
||||
if (!current || current.key !== groupKey) {
|
||||
current = { key: groupKey, label: item.group, items: [] };
|
||||
groups.push(current);
|
||||
}
|
||||
current.items.push(item);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function LayoutTabs({
|
||||
items,
|
||||
urlParam,
|
||||
defaultTab,
|
||||
preserveSearchParams = true,
|
||||
aliasMap,
|
||||
syncUrl,
|
||||
lazy = false,
|
||||
onTabChange,
|
||||
className,
|
||||
collapsible = false,
|
||||
collapseKey,
|
||||
defaultCollapsed = false,
|
||||
fill = true,
|
||||
}: LayoutTabsProps) {
|
||||
const shouldSyncUrl = syncUrl ?? !!urlParam;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const _initialTab = useMemo(() => {
|
||||
if (urlParam) {
|
||||
const raw = searchParams.get(urlParam);
|
||||
const resolved = _resolveAlias(raw, aliasMap);
|
||||
const matched = _findItem(items, resolved);
|
||||
if (matched && !matched.disabled) return matched.id;
|
||||
}
|
||||
if (defaultTab) {
|
||||
const matched = _findItem(items, defaultTab);
|
||||
if (matched && !matched.disabled) return matched.id;
|
||||
}
|
||||
return _enabledItems(items)[0]?.id ?? items[0]?.id ?? '';
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [activeId, setActiveId] = useState(_initialTab);
|
||||
const [mountedIds, setMountedIds] = useState<Set<string>>(
|
||||
() => new Set([_initialTab]),
|
||||
);
|
||||
|
||||
// Collapse state for the tab bar
|
||||
const [tabBarCollapsed, setTabBarCollapsed] = useState(() => {
|
||||
if (!collapsible) return false;
|
||||
if (collapseKey) {
|
||||
const stored = _collapsePersistence.load<boolean>(collapseKey);
|
||||
if (stored !== null) return stored;
|
||||
}
|
||||
return defaultCollapsed;
|
||||
});
|
||||
|
||||
const _toggleTabBar = useCallback(() => {
|
||||
setTabBarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
if (collapseKey) _collapsePersistence.save(collapseKey, next);
|
||||
return next;
|
||||
});
|
||||
}, [collapseKey]);
|
||||
|
||||
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
const _setTab = useCallback(
|
||||
(id: string) => {
|
||||
setActiveId(id);
|
||||
|
||||
if (lazy) {
|
||||
setMountedIds((prev) => {
|
||||
if (prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldSyncUrl && urlParam) {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = preserveSearchParams
|
||||
? new URLSearchParams(prev)
|
||||
: new URLSearchParams();
|
||||
next.set(urlParam, id);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
onTabChange?.(id);
|
||||
},
|
||||
[shouldSyncUrl, urlParam, preserveSearchParams, setSearchParams, onTabChange, lazy],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!urlParam || !shouldSyncUrl) return;
|
||||
const raw = searchParams.get(urlParam);
|
||||
const resolved = _resolveAlias(raw, aliasMap);
|
||||
const matched = _findItem(items, resolved);
|
||||
if (matched && !matched.disabled && matched.id !== activeId) {
|
||||
setActiveId(matched.id);
|
||||
if (lazy) {
|
||||
setMountedIds((prev) => {
|
||||
if (prev.has(matched.id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(matched.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [searchParams, urlParam, aliasMap, items, shouldSyncUrl, activeId, lazy]);
|
||||
|
||||
const enabled = useMemo(() => _enabledItems(items), [items]);
|
||||
|
||||
const _handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const idx = enabled.findIndex((item) => item.id === activeId);
|
||||
let target: LayoutTabItem | undefined;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
target = enabled[(idx + 1) % enabled.length];
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
target = enabled[(idx - 1 + enabled.length) % enabled.length];
|
||||
break;
|
||||
case 'Home':
|
||||
target = enabled[0];
|
||||
break;
|
||||
case 'End':
|
||||
target = enabled[enabled.length - 1];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
_setTab(target.id);
|
||||
tabRefs.current.get(target.id)?.focus();
|
||||
}
|
||||
},
|
||||
[enabled, activeId, _setTab],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => _buildGroups(items), [items]);
|
||||
const hasGroups = groups.some((g) => !!g.label);
|
||||
const panelId = `tabpanel-${activeId}`;
|
||||
const activeItem = _findItem(items, activeId) ?? items[0];
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
const showCollapsed = collapsible && tabBarCollapsed;
|
||||
|
||||
const _toggleButton = collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleBtn}
|
||||
aria-label={showCollapsed ? 'Tab-Auswahl einblenden' : 'Tab-Auswahl einklappen'}
|
||||
aria-expanded={!showCollapsed}
|
||||
onClick={_toggleTabBar}
|
||||
>
|
||||
{showCollapsed
|
||||
? <FaChevronDown className={styles.toggleIcon} aria-hidden />
|
||||
: <FaChevronUp className={styles.toggleIcon} aria-hidden />}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={[styles.container, !fill && styles.containerNatural, className].filter(Boolean).join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
styles.tabBarRow,
|
||||
showCollapsed && styles.tabBarRowCollapsed,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{!showCollapsed ? (
|
||||
<div
|
||||
className={[styles.tabBar, hasGroups && styles.tabBarGrouped]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="tablist"
|
||||
>
|
||||
{groups.map((group, gi) => (
|
||||
<div key={group.key || gi} className={styles.group}>
|
||||
{gi > 0 && !hasGroups && (
|
||||
<div className={styles.groupSeparator} aria-hidden />
|
||||
)}
|
||||
{group.label && (
|
||||
<span className={styles.groupLabel} aria-hidden>
|
||||
{group.label}
|
||||
</span>
|
||||
)}
|
||||
{group.items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
const tabId = `tab-${item.id}`;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(item.id, el);
|
||||
else tabRefs.current.delete(item.id);
|
||||
}}
|
||||
id={tabId}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={isActive}
|
||||
aria-controls={panelId}
|
||||
aria-disabled={item.disabled || undefined}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={[styles.tab, isActive && styles.tabActive]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
if (!item.disabled) _setTab(item.id);
|
||||
}}
|
||||
onKeyDown={_handleKeyDown}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className={styles.tabIcon} aria-hidden>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.tabLabel}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className={styles.collapsedLabel}>{activeItem?.label}</span>
|
||||
)}
|
||||
{_toggleButton}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${activeId}`}
|
||||
className={styles.panel}
|
||||
>
|
||||
{lazy
|
||||
? items.map((item) =>
|
||||
mountedIds.has(item.id) ? (
|
||||
<div
|
||||
key={item.id}
|
||||
style={
|
||||
item.id === activeId
|
||||
? fill
|
||||
? {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}
|
||||
: { display: 'block' }
|
||||
: { display: 'none' }
|
||||
}
|
||||
>
|
||||
{item.render()}
|
||||
</div>
|
||||
) : null,
|
||||
)
|
||||
: activeItem?.render()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LayoutTabs;
|
||||
170
src/components/Layout/Panel.module.css
Normal file
170
src/components/Layout/Panel.module.css
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/** Panel — typed region container with optional collapsible header. */
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary, #fff);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
/* --- Variant: table — fills available height, bounded scroll --- */
|
||||
.panel[data-variant="table"] {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel[data-variant="table"] .body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* --- Variant: dashboard — natural height, grid-friendly --- */
|
||||
.panel[data-variant="dashboard"] .body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* --- Variant: toolbar — compact, no border-radius, minimal chrome --- */
|
||||
.panel[data-variant="toolbar"] {
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.panel[data-variant="toolbar"] .body {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.panel[data-variant="toolbar"] .header {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* --- Variant: wizard — step container --- */
|
||||
.panel[data-variant="wizard"] .body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary, rgba(0, 0, 0, 0.02));
|
||||
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.headerCollapsible {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.headerCollapsible:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #666);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--text-tertiary, #888);
|
||||
transition: transform 0.2s ease;
|
||||
transform-origin: 50% 40%;
|
||||
}
|
||||
|
||||
.panelCollapsed .chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.bodyHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
:global(.dark-theme) .panel {
|
||||
border-color: var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
background: var(--bg-primary, #1e1e1e);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .header {
|
||||
background: var(--bg-secondary, rgba(255, 255, 255, 0.03));
|
||||
border-bottom-color: var(--border-color, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
:global(.dark-theme) .headerCollapsible:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
84
src/components/Layout/Panel.tsx
Normal file
84
src/components/Layout/Panel.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { type FC, useState, useEffect, useCallback } from 'react';
|
||||
import type { PanelProps } from './types';
|
||||
import styles from './Panel.module.css';
|
||||
|
||||
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 */ }
|
||||
}
|
||||
|
||||
export const Panel: FC<PanelProps> = ({
|
||||
variant = 'card',
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
collapsible = false,
|
||||
defaultCollapsed = false,
|
||||
collapseKey,
|
||||
className = '',
|
||||
fill = false,
|
||||
children,
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(() => _loadCollapsed(collapseKey, defaultCollapsed));
|
||||
|
||||
useEffect(() => {
|
||||
_saveCollapsed(collapseKey, collapsed);
|
||||
}, [collapseKey, collapsed]);
|
||||
|
||||
const _toggleCollapsed = useCallback(() => {
|
||||
if (collapsible) setCollapsed((prev) => !prev);
|
||||
}, [collapsible]);
|
||||
|
||||
const hasHeader = title != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.panel} ${collapsed ? styles.panelCollapsed : ''} ${className}`}
|
||||
data-variant={variant}
|
||||
data-fill={fill ? 'true' : undefined}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div
|
||||
className={`${styles.header} ${collapsible ? styles.headerCollapsible : ''}`}
|
||||
role={collapsible ? 'button' : undefined}
|
||||
tabIndex={collapsible ? 0 : undefined}
|
||||
aria-expanded={collapsible ? !collapsed : undefined}
|
||||
onClick={_toggleCollapsed}
|
||||
onKeyDown={
|
||||
collapsible
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
_toggleCollapsed();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.titles}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
{collapsible && <span className={styles.chevron} aria-hidden />}
|
||||
</div>
|
||||
)}
|
||||
<div className={`${styles.body} ${collapsed ? styles.bodyHidden : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
98
src/components/Layout/PanelLayout.module.css
Normal file
98
src/components/Layout/PanelLayout.module.css
Normal 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);
|
||||
}
|
||||
298
src/components/Layout/PanelLayout.tsx
Normal file
298
src/components/Layout/PanelLayout.tsx
Normal 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;
|
||||
110
src/components/Layout/StackLayout.module.css
Normal file
110
src/components/Layout/StackLayout.module.css
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/** StackLayout — structural flex-column container with named regions. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Regions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Variant overrides on body */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.bodyTable {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.bodyScroll {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bodyForm {
|
||||
overflow-y: auto;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Document scroll-mode: body becomes overflow:visible */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
:global(html[data-scroll-mode="document"]) .root {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:global(html[data-scroll-mode="document"]) .bodyTable,
|
||||
:global(html[data-scroll-mode="document"]) .bodyScroll,
|
||||
:global(html[data-scroll-mode="document"]) .bodyForm,
|
||||
:global(html[data-scroll-mode="document"]) .bodyDashboard {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dark theme */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
:global(.dark-theme) .root {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Responsive: tighten padding on small screens */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.bodyForm {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.bodyDashboard {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
105
src/components/Layout/StackLayout.tsx
Normal file
105
src/components/Layout/StackLayout.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components (compound component pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SlotProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Header: FC<SlotProps> = ({ className = '', children }) => (
|
||||
<div className={`${styles.header} ${className}`}>{children}</div>
|
||||
);
|
||||
|
||||
const Toolbar: FC<SlotProps> = ({ className = '', children }) => (
|
||||
<div className={`${styles.toolbar} ${className}`}>{children}</div>
|
||||
);
|
||||
|
||||
const Tabs: FC<SlotProps> = ({ className = '', children }) => (
|
||||
<div className={`${styles.tabs} ${className}`}>{children}</div>
|
||||
);
|
||||
|
||||
const Body: FC<SlotProps> = ({ className = '', children }) => (
|
||||
<div className={className}>{children}</div>
|
||||
);
|
||||
|
||||
const Footer: FC<SlotProps> = ({ className = '', children }) => (
|
||||
<div className={`${styles.footer} ${className}`}>{children}</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant → CSS class mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _variantBodyClass: Record<StackLayoutVariant, string> = {
|
||||
table: styles.bodyTable,
|
||||
scroll: styles.bodyScroll,
|
||||
form: styles.bodyForm,
|
||||
dashboard: styles.bodyDashboard,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StackLayoutComponent extends FC<StackLayoutProps> {
|
||||
Header: FC<SlotProps>;
|
||||
Toolbar: FC<SlotProps>;
|
||||
Tabs: FC<SlotProps>;
|
||||
Body: FC<SlotProps>;
|
||||
Footer: FC<SlotProps>;
|
||||
}
|
||||
|
||||
const _StackLayoutRoot: FC<StackLayoutProps> = ({
|
||||
variant = 'scroll',
|
||||
className = '',
|
||||
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}
|
||||
>
|
||||
{_processChildren(children, variant)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function _processChildren(children: ReactNode, variant: StackLayoutVariant): ReactNode {
|
||||
const bodyClass = `${styles.body} ${_variantBodyClass[variant]}`;
|
||||
|
||||
return Children.map(children, (child: ReactNode) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
if (child.type === Body) {
|
||||
return cloneElement(child as React.ReactElement<SlotProps>, {
|
||||
className: `${bodyClass} ${(child.props as SlotProps).className ?? ''}`,
|
||||
});
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assemble compound component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const StackLayout = _StackLayoutRoot as StackLayoutComponent;
|
||||
StackLayout.Header = Header;
|
||||
StackLayout.Toolbar = Toolbar;
|
||||
StackLayout.Tabs = Tabs;
|
||||
StackLayout.Body = Body;
|
||||
StackLayout.Footer = Footer;
|
||||
110
src/components/Layout/ViewStack.module.css
Normal file
110
src/components/Layout/ViewStack.module.css
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
.viewStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.viewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
color: var(--text-primary, #333);
|
||||
border-color: var(--primary-color, #007bff);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.backButton:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.backArrow {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detailTitle {
|
||||
flex: 1;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.dark-theme) .detailHeader {
|
||||
border-bottom-color: var(--border-color, #333);
|
||||
background: var(--bg-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .backButton {
|
||||
color: var(--text-secondary, #aaa);
|
||||
border-color: var(--border-color, #444);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .backButton:hover {
|
||||
color: var(--text-primary, #eee);
|
||||
border-color: var(--primary-color, #4da3ff);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .detailTitle {
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.detailHeader {
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detailTitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
197
src/components/Layout/ViewStack.tsx
Normal file
197
src/components/Layout/ViewStack.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
|
||||
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,
|
||||
registeredViews: ViewMode[],
|
||||
): ViewResolution {
|
||||
const rawView = searchParams.get(viewParam) as ViewMode | null;
|
||||
const entityId = entityParam ? searchParams.get(entityParam) : null;
|
||||
|
||||
let sanitized = false;
|
||||
|
||||
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') {
|
||||
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 { activeView: resolved, sanitized };
|
||||
}
|
||||
|
||||
function _findActiveChild(
|
||||
children: React.ReactNode,
|
||||
activeView: ViewMode,
|
||||
): ReactElement<ViewProps> | null {
|
||||
let match: ReactElement<ViewProps> | null = null;
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement<ViewProps>(child)) return;
|
||||
if (child.props.id === activeView) {
|
||||
match = child;
|
||||
}
|
||||
});
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
function _buildBackParams(
|
||||
searchParams: URLSearchParams,
|
||||
viewParam: string,
|
||||
entityParam: string | undefined,
|
||||
defaultView: ViewMode,
|
||||
): URLSearchParams {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
|
||||
if (entityParam) {
|
||||
next.delete(entityParam);
|
||||
}
|
||||
|
||||
if (defaultView === 'list') {
|
||||
next.delete(viewParam);
|
||||
} else {
|
||||
next.set(viewParam, 'list');
|
||||
}
|
||||
|
||||
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}</>;
|
||||
}
|
||||
|
||||
function ViewStack({
|
||||
viewParam = 'view',
|
||||
entityParam,
|
||||
defaultView = 'list',
|
||||
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 activeChild = _findActiveChild(children, activeView);
|
||||
|
||||
if (!activeChild) return null;
|
||||
|
||||
const { title, backLabel, actions } = activeChild.props;
|
||||
|
||||
const handleBack = () => {
|
||||
const nextParams = _buildBackParams(searchParams, viewParam, entityParam, defaultView);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.viewStack}>
|
||||
{activeView === 'detail' && (
|
||||
<div className={styles.detailHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<span className={styles.backArrow}>←</span>
|
||||
{backLabel && <span>{backLabel}</span>}
|
||||
</button>
|
||||
{title && <h2 className={styles.detailTitle}>{title}</h2>}
|
||||
{actions && <div className={styles.detailActions}>{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.viewContent}>
|
||||
{activeChild}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ViewStack.View = View;
|
||||
|
||||
export default ViewStack;
|
||||
28
src/components/Layout/index.ts
Normal file
28
src/components/Layout/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Layout component system — barrel export.
|
||||
*
|
||||
* Provides: StackLayout, LayoutTabs, ViewStack, Panel, persistence adapters.
|
||||
* Replaces: Admin.module.css layout classes, UiComponents/Tabs, inline layouts.
|
||||
*/
|
||||
|
||||
export type {
|
||||
ScrollMode,
|
||||
LayoutTabItem,
|
||||
LayoutTabsProps,
|
||||
ViewMode,
|
||||
ViewStackProps,
|
||||
ViewProps,
|
||||
PanelProps,
|
||||
StackLayoutVariant,
|
||||
StackLayoutProps,
|
||||
LayoutPersistenceAdapter,
|
||||
} from './types';
|
||||
|
||||
export { _createLocalStorageAdapter } from './persistence';
|
||||
export { default as ViewStack } from './ViewStack';
|
||||
export { LayoutTabs } from './LayoutTabs';
|
||||
export { Panel } from './Panel';
|
||||
export { StackLayout } from './StackLayout';
|
||||
export { PanelLayout } from './PanelLayout';
|
||||
39
src/components/Layout/persistence.ts
Normal file
39
src/components/Layout/persistence.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* LayoutPersistenceAdapter — pluggable persistence for layout state.
|
||||
*
|
||||
* Default implementation uses localStorage.
|
||||
* NOT used for navigation state (URL is source-of-truth) or settings values (DB).
|
||||
* Use for: panel widths, collapse state, user UI preferences.
|
||||
*/
|
||||
|
||||
import type { LayoutPersistenceAdapter } from './types';
|
||||
|
||||
const PREFIX = 'po_layout_';
|
||||
|
||||
function _buildKey(scope: string, key: string): string {
|
||||
return `${PREFIX}${scope}:${key}`;
|
||||
}
|
||||
|
||||
export function _createLocalStorageAdapter(scope: string): LayoutPersistenceAdapter {
|
||||
return {
|
||||
scope,
|
||||
load<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(_buildKey(scope, key));
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
save<T>(key: string, value: T): void {
|
||||
try {
|
||||
localStorage.setItem(_buildKey(scope, key), JSON.stringify(value));
|
||||
} catch {
|
||||
// localStorage full or unavailable — silently ignore for UI preferences
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
147
src/components/Layout/types.ts
Normal file
147
src/components/Layout/types.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Shared types for the Layout component system.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScrollMode (from useScrollMode hook)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ScrollMode = 'bounded' | 'document';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LayoutTabs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LayoutTabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
group?: string;
|
||||
disabled?: boolean;
|
||||
render: () => ReactNode;
|
||||
}
|
||||
|
||||
export interface LayoutTabsProps {
|
||||
items: LayoutTabItem[];
|
||||
urlParam?: string;
|
||||
defaultTab?: string;
|
||||
preserveSearchParams?: boolean;
|
||||
aliasMap?: Record<string, string>;
|
||||
syncUrl?: boolean;
|
||||
lazy?: boolean;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
className?: string;
|
||||
/** Allow the tab bar to be collapsed into a single-line summary. */
|
||||
collapsible?: boolean;
|
||||
/** Persist collapse state under this key (localStorage). */
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ViewStack
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ViewMode = 'list' | 'catalog' | 'detail';
|
||||
|
||||
export interface ViewStackProps {
|
||||
viewParam?: string;
|
||||
entityParam?: string;
|
||||
defaultView?: ViewMode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ViewProps {
|
||||
id: ViewMode;
|
||||
title?: string | ReactNode;
|
||||
backLabel?: string;
|
||||
actions?: ReactNode;
|
||||
presentation?: 'inline' | 'modal';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PanelVariant = 'card' | 'table' | 'dashboard' | 'toolbar' | 'editor' | 'wizard';
|
||||
|
||||
export interface PanelProps {
|
||||
variant?: PanelVariant;
|
||||
title?: string | ReactNode;
|
||||
subtitle?: string | ReactNode;
|
||||
actions?: ReactNode;
|
||||
collapsible?: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StackLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StackLayoutVariant = 'table' | 'scroll' | 'form' | 'dashboard';
|
||||
|
||||
export interface StackLayoutProps {
|
||||
variant?: StackLayoutVariant;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LayoutPersistenceAdapter {
|
||||
scope: string;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
<div className={`${styles.userSection} ${collapsed ? styles.userSectionCollapsed : ''}`}>
|
||||
{!collapsed && (
|
||||
<NotificationBell className={styles.notificationBell} />
|
||||
)}
|
||||
|
||||
<button
|
||||
className={styles.userButton}
|
||||
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 && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,46 +60,28 @@ 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,
|
||||
actionId: string,
|
||||
event: React.MouseEvent
|
||||
event: React.MouseEvent,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setActionLoading(`${notification.id}-${actionId}`);
|
||||
|
|
@ -109,11 +92,9 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
|||
|
||||
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 */}
|
||||
<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
|
||||
type="button"
|
||||
className={styles.markAllRead}
|
||||
onClick={() => markAllAsRead()}
|
||||
>
|
||||
|
|
@ -172,7 +157,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={styles.content}>
|
||||
{loading && visibleNotifications.length === 0 && (
|
||||
<div className={styles.loading}>{t('Lade')}</div>
|
||||
|
|
@ -199,7 +183,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
|||
`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
{/* Success overlay */}
|
||||
{actionSuccess === notification.id && (
|
||||
<div className={styles.successOverlay}>
|
||||
<FaCheckCircle />
|
||||
|
|
@ -207,23 +190,21 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
|||
</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}`}
|
||||
|
|
@ -242,7 +223,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Action result */}
|
||||
{notification.actionTaken && (
|
||||
<div className={styles.actionResult}>
|
||||
{notification.actionResult}
|
||||
|
|
@ -250,9 +230,9 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
{notification.status !== 'actioned' && (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,17 +167,25 @@ 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}
|
||||
|
|
@ -237,7 +228,7 @@ function DropdownSelect<T = any>({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FloatingPortal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
.layer {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
168
src/components/UiComponents/FloatingPortal/FloatingPortal.tsx
Normal file
168
src/components/UiComponents/FloatingPortal/FloatingPortal.tsx
Normal 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;
|
||||
4
src/components/UiComponents/FloatingPortal/index.ts
Normal file
4
src/components/UiComponents/FloatingPortal/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
export { FloatingPortal, default } from './FloatingPortal';
|
||||
export type { FloatingPortalProps, FloatingPlacement } from './FloatingPortal';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
export { Tabs, default } from './Tabs';
|
||||
export type { TabsProps, Tab } from './Tabs';
|
||||
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
.chatsTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
.filesTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(?:$|\/)/,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
37
src/hooks/useDocumentTitle.ts
Normal file
37
src/hooks/useDocumentTitle.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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);
|
||||
useEffect(() => {
|
||||
_ensureNavigationChangeListeners();
|
||||
|
||||
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);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
54
src/hooks/useScrollMode.ts
Normal file
54
src/hooks/useScrollMode.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* useScrollMode Hook
|
||||
*
|
||||
* Determines the layout scroll mode for the current viewport:
|
||||
* - "bounded": Desktop with sufficient height. Content areas use overflow:hidden
|
||||
* chains and tables scroll internally.
|
||||
* - "document": Mobile or very short viewports. The overflow:hidden chain is
|
||||
* broken so the page scrolls as a document, letting the header scroll away.
|
||||
*
|
||||
* Sets `data-scroll-mode` on <html> so every CSS module can react via
|
||||
* `:global(html[data-scroll-mode="document"])`.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export type ScrollMode = 'bounded' | 'document';
|
||||
|
||||
const DOCUMENT_MODE_QUERY = '(max-width: 1024px), (max-height: 500px)';
|
||||
|
||||
function _evaluateScrollMode(): ScrollMode {
|
||||
return window.matchMedia(DOCUMENT_MODE_QUERY).matches ? 'document' : 'bounded';
|
||||
}
|
||||
|
||||
export function useScrollMode(): ScrollMode {
|
||||
const [mode, setMode] = useState<ScrollMode>(_evaluateScrollMode);
|
||||
|
||||
const _syncAttribute = useCallback((m: ScrollMode) => {
|
||||
document.documentElement.dataset.scrollMode = m;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
_syncAttribute(mode);
|
||||
}, [mode, _syncAttribute]);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(DOCUMENT_MODE_QUERY);
|
||||
const _handleChange = () => {
|
||||
const next = _evaluateScrollMode();
|
||||
setMode(next);
|
||||
};
|
||||
mql.addEventListener('change', _handleChange);
|
||||
return () => mql.removeEventListener('change', _handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
delete document.documentElement.dataset.scrollMode;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return mode;
|
||||
}
|
||||
85
src/hooks/useScrollRestoration.ts
Normal file
85
src/hooks/useScrollRestoration.ts
Normal 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]);
|
||||
}
|
||||
55
src/hooks/useVisibilityRemeasure.ts
Normal file
55
src/hooks/useVisibilityRemeasure.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -135,13 +135,10 @@
|
|||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* Feature Content */
|
||||
/* Feature Content — viewContent owns padding, not featureContent */
|
||||
.featureContent {
|
||||
flex: 1;
|
||||
/* Let child components handle their own scrolling for sticky headers */
|
||||
overflow: hidden;
|
||||
padding: 1.5rem;
|
||||
/* Maintain flex chain for child components */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
|
@ -180,10 +177,35 @@
|
|||
|
||||
@media (max-width: 1024px) {
|
||||
.featureHeader {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.featureContent {
|
||||
padding: 1rem;
|
||||
.breadcrumb {
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mandateName {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mandateName + .separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* scrollMode: document — layout grows with content, header scrolls away */
|
||||
:global(html[data-scroll-mode="document"]) .featureLayout {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(html[data-scroll-mode="document"]) .featureContent {
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,12 +91,10 @@ export const FeatureLayout: React.FC = () => {
|
|||
};
|
||||
}, [dynamicBlock, mandateId, featureCode, instanceId]);
|
||||
|
||||
// Warten bis Features geladen sind
|
||||
if (!initialized || loading || isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Prüfen ob Instanz existiert und gültig ist
|
||||
if (!isValid) {
|
||||
console.warn('FeatureLayout: Invalid instance context', {
|
||||
path: location.pathname,
|
||||
|
|
@ -112,10 +110,8 @@ export const FeatureLayout: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
// Alles OK - rendere Content
|
||||
return (
|
||||
<div className={styles.featureLayout}>
|
||||
{/* Header mit Instanz-Info */}
|
||||
<header className={styles.featureHeader}>
|
||||
<div className={styles.breadcrumb}>
|
||||
<span className={styles.mandateName}>
|
||||
|
|
@ -131,7 +127,6 @@ export const FeatureLayout: React.FC = () => {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Area */}
|
||||
<main className={styles.featureContent}>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
|
@ -148,10 +143,6 @@ interface ProtectedFeatureRouteProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper für geschützte Feature-Routes
|
||||
* Prüft zusätzlich View-Berechtigungen
|
||||
*/
|
||||
export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
|
||||
requiredView,
|
||||
children,
|
||||
|
|
@ -163,7 +154,6 @@ export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
|
|||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Prüfe View-Berechtigung wenn erforderlich
|
||||
if (requiredView) {
|
||||
const hasViewAccess = instance?.permissions?.views?.[requiredView] ?? false;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,18 @@
|
|||
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 */
|
||||
.content[data-scroll-mode="document"] {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content[data-scroll-mode="document"] .outletShell {
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobileTopBar {
|
||||
|
|
@ -159,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);
|
||||
}
|
||||
|
|
@ -214,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;
|
||||
|
|
@ -249,6 +343,7 @@
|
|||
|
||||
.content {
|
||||
--mobile-topbar-height: 57px;
|
||||
--content-inset: 8px;
|
||||
}
|
||||
|
||||
.mobileBackdrop {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -16,17 +17,51 @@ import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
|
|||
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 } : {}),
|
||||
});
|
||||
|
|
@ -105,12 +140,47 @@ const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string; s
|
|||
|
||||
const MainLayoutInner: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const scrollMode = useScrollMode();
|
||||
|
||||
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) {
|
||||
|
|
@ -124,7 +194,9 @@ const MainLayoutInner: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth > 1024) {
|
||||
const desktop = _isDesktopViewport();
|
||||
setIsDesktop(desktop);
|
||||
if (desktop) {
|
||||
setIsMobileSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -133,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}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
14
src/layouts/SidebarContext.tsx
Normal file
14
src/layouts/SidebarContext.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -112,3 +88,9 @@
|
|||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* scrollMode: document — view grows with content, no internal scroll */
|
||||
:global(html[data-scroll-mode="document"]) .featureView {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,35 +52,6 @@ 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();
|
||||
|
|
@ -118,11 +89,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
analyse: TrusteeAnalyseView,
|
||||
abschluss: TrusteeAbschlussView,
|
||||
},
|
||||
chatworkflow: {
|
||||
dashboard: ChatworkflowDashboard,
|
||||
runs: ChatworkflowRuns,
|
||||
files: ChatworkflowFiles,
|
||||
},
|
||||
realestate: {
|
||||
dashboard: RealEstatePekView,
|
||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||
|
|
@ -208,9 +174,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
|||
|
||||
return (
|
||||
<div className={styles.featureView}>
|
||||
<main className={styles.viewContent}>
|
||||
<ViewComponent />
|
||||
</main>
|
||||
<ViewComponent />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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}>×</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,36 +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;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
@ -211,14 +181,6 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -728,12 +690,6 @@
|
|||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.pageHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -751,18 +707,6 @@
|
|||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
|
@ -784,10 +728,6 @@
|
|||
min-height: 32px;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================== */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }} />
|
||||
|
|
@ -269,10 +275,10 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
</StackLayout.Header>
|
||||
<StackLayout.Body>
|
||||
<Panel variant="toolbar">
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
</StackLayout.Header>
|
||||
<StackLayout.Body>
|
||||
<Panel variant="toolbar">
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue