logical fixes

This commit is contained in:
ValueOn AG 2026-02-09 23:45:05 +01:00
parent 75125e3f58
commit 2a1cd2ef64
30 changed files with 449 additions and 170 deletions

View file

@ -22,6 +22,7 @@ export interface Automation {
_updatedAt?: number; _updatedAt?: number;
_createdByUserName?: string; _createdByUserName?: string;
mandateName?: string; mandateName?: string;
featureInstanceName?: string;
[key: string]: any; [key: string]: any;
} }

View file

@ -37,6 +37,43 @@
white-space: nowrap; white-space: nowrap;
} }
/* CSV Export Button */
.csvExportButton {
display: inline-flex;
align-items: center;
gap: 5px;
height: 40px;
padding: 0 14px;
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
color: var(--color-text);
font-size: 12px;
font-family: var(--font-family);
font-weight: 400;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
}
.csvExportButton:hover:not(:disabled) {
background: var(--color-secondary);
color: var(--color-bg);
border-color: var(--color-secondary);
}
.csvExportButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.csvExportIcon {
font-size: 13px;
display: flex;
align-items: center;
}
.refreshButton { .refreshButton {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -3,7 +3,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorControls.module.css'; import styles from './FormGeneratorControls.module.css';
import { Button } from '../../UiComponents/Button'; import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io"; import { IoIosRefresh } from "react-icons/io";
import { FaTrash } from "react-icons/fa"; import { FaTrash, FaDownload } from "react-icons/fa";
import type { AttributeType } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface // Generic field/column config interface
@ -62,6 +62,9 @@ export interface FormGeneratorControlsProps {
onPageSizeChange?: (pageSize: number) => void; onPageSizeChange?: (pageSize: number) => void;
supportsBackendPagination?: boolean; supportsBackendPagination?: boolean;
hookData?: any; hookData?: any;
// CSV Export
onCsvExport?: () => void;
csvExporting?: boolean;
} }
export function FormGeneratorControls({ export function FormGeneratorControls({
@ -87,7 +90,9 @@ export function FormGeneratorControls({
onPageChange, onPageChange,
onPageSizeChange, onPageSizeChange,
supportsBackendPagination = false, supportsBackendPagination = false,
hookData: _hookData // Reserved for future use hookData: _hookData, // Reserved for future use
onCsvExport,
csvExporting = false
}: FormGeneratorControlsProps) { }: FormGeneratorControlsProps) {
void _hookData; // Suppress unused variable warning void _hookData; // Suppress unused variable warning
const { t } = useLanguage(); const { t } = useLanguage();
@ -147,6 +152,17 @@ export function FormGeneratorControls({
{activeFiltersCount} {t('formgen.filter.active', 'filter(s)')} {activeFiltersCount} {t('formgen.filter.active', 'filter(s)')}
</span> </span>
)} )}
{onCsvExport && (
<button
onClick={onCsvExport}
className={styles.csvExportButton}
title={t('formgen.export.csv', 'Export all data as CSV')}
disabled={csvExporting}
>
<span className={styles.csvExportIcon}><FaDownload /></span>
{csvExporting ? t('formgen.export.exporting', 'Exporting...') : 'CSV'}
</button>
)}
{onRefresh && ( {onRefresh && (
<button <button
onClick={onRefresh} onClick={onRefresh}

View file

@ -110,7 +110,7 @@
/* Use separate borders for sticky header support */ /* Use separate borders for sticky header support */
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
font-size: 14px; font-size: 12px;
background: var(--color-bg); background: var(--color-bg);
table-layout: fixed; table-layout: fixed;
word-wrap: break-word; word-wrap: break-word;
@ -151,9 +151,10 @@
.th { .th {
background: var(--color-bg); background: var(--color-bg);
padding: 10px 16px; padding: 6px 10px;
text-align: left; text-align: left;
font-weight: 400; font-weight: 400;
font-size: 12px;
color: var(--color-text); color: var(--color-text);
white-space: normal; white-space: normal;
word-wrap: break-word; word-wrap: break-word;
@ -163,7 +164,6 @@
overflow: visible; overflow: visible;
/* Border separates header from scrolled content */ /* Border separates header from scrolled content */
border-bottom: 2px solid var(--color-primary); border-bottom: 2px solid var(--color-primary);
/* Shadow on the row, not individual cells */
} }
.th.actionsColumn { .th.actionsColumn {
@ -339,9 +339,11 @@
} }
.td { .td {
padding: 12px 16px; padding: 4px 10px;
border-top: 1px solid var(--color-primary); border-top: 1px solid var(--color-primary);
color: var(--color-text); color: var(--color-text);
font-weight: 400;
font-size: 12px;
vertical-align: middle; vertical-align: middle;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
@ -376,7 +378,7 @@
/* Selection Column */ /* Selection Column */
.selectColumn { .selectColumn {
text-align: center; text-align: center;
padding: 8px !important; padding: 4px !important;
background: var(--color-bg); background: var(--color-bg);
position: relative; position: relative;
} }
@ -393,9 +395,9 @@ tbody .selectColumn {
.selectColumn input[type="checkbox"] { .selectColumn input[type="checkbox"] {
cursor: pointer; cursor: pointer;
transform: scale(1.3); transform: scale(1.1);
width: 16px; width: 14px;
height: 16px; height: 14px;
accent-color: var(--color-secondary); accent-color: var(--color-secondary);
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -427,7 +429,7 @@ tbody .selectColumn {
.actionsColumn { .actionsColumn {
white-space: nowrap; white-space: nowrap;
text-align: center; text-align: center;
padding: 8px !important; padding: 4px !important;
font-weight: 400; font-weight: 400;
box-sizing: border-box; box-sizing: border-box;
background: var(--color-bg); background: var(--color-bg);
@ -463,17 +465,17 @@ tbody .actionsColumn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 6px; padding: 4px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
font-size: 12px; font-size: 11px;
font-family: var(--font-family); font-family: var(--font-family);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
min-width: 28px; min-width: 24px;
min-height: 28px; min-height: 24px;
background: var(--color-secondary); background: var(--color-secondary);
color: var(--color-bg); color: var(--color-bg);
} }
@ -483,9 +485,9 @@ tbody .actionsColumn {
} }
.actionIcon { .actionIcon {
font-size: 16px; font-size: 14px;
height: 16px; height: 14px;
width: 16px; width: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -698,18 +700,20 @@ tbody .actionsColumn {
.th, .th,
.td { .td {
padding: 8px 12px; padding: 4px 8px;
font-size: 13px; font-size: 11px;
} }
.actionButtons { .actionButtons {
flex-direction: column; flex-direction: column;
gap: 4px; gap: 2px;
} }
.actionButton { .actionButton {
padding: 4px 8px; padding: 3px;
font-size: 11px; font-size: 10px;
min-width: 22px;
min-height: 22px;
} }
.pagination { .pagination {
@ -916,3 +920,4 @@ tbody .actionsColumn {
50% { opacity: 1; } 50% { opacity: 1; }
} }

View file

@ -155,6 +155,8 @@ export interface FormGeneratorTableProps<T = any> {
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc. hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
// Custom empty message when table is empty // Custom empty message when table is empty
emptyMessage?: string; emptyMessage?: string;
// API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown.
apiEndpoint?: string;
} }
export function FormGeneratorTable<T extends Record<string, any>>({ export function FormGeneratorTable<T extends Record<string, any>>({
@ -185,7 +187,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
className = '', className = '',
getRowDataAttributes, getRowDataAttributes,
hookData, hookData,
emptyMessage emptyMessage,
apiEndpoint
}: FormGeneratorTableProps<T>) { }: FormGeneratorTableProps<T>) {
const { t } = useLanguage(); const { t } = useLanguage();
// Get current language from localStorage or default to 'en' // Get current language from localStorage or default to 'en'
@ -347,9 +350,14 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [containerWidth, setContainerWidth] = useState<number>(0); const [containerWidth, setContainerWidth] = useState<number>(0);
// Calculate default actions column width and track container width // Calculate default actions column width and track container width
// Minimum width always fits 4 icons (4 * 26px button + 3 * 2px gap + 8px padding = 122px)
const MIN_ACTIONS_WIDTH_FOR_4_ICONS = 122;
const defaultActionsWidth = useMemo(() => { const defaultActionsWidth = useMemo(() => {
return actionButtons.length > 0 ? Math.max(60, actionButtons.length * 32 + 16) : 0; if (actionButtons.length === 0 && customActions.length === 0) return 0;
}, [actionButtons.length]); const totalButtons = actionButtons.length + customActions.length;
const calculatedWidth = totalButtons * 26 + (totalButtons - 1) * 2 + 8;
return Math.max(MIN_ACTIONS_WIDTH_FOR_4_ICONS, calculatedWidth);
}, [actionButtons.length, customActions.length]);
// Current actions column width (user-defined or default) // Current actions column width (user-defined or default)
const currentActionsWidth = actionsColumnWidth ?? defaultActionsWidth; const currentActionsWidth = actionsColumnWidth ?? defaultActionsWidth;
@ -853,7 +861,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const cWidth = tableContainer.clientWidth; const cWidth = tableContainer.clientWidth;
// Calculate actions column width dynamically: ~32px per button + padding // Calculate actions column width dynamically: ~32px per button + padding
const actionsColWidth = currentActionsWidth; const actionsColWidth = currentActionsWidth;
const selectColumnWidth = selectable ? 50 : 0; const selectColumnWidth = selectable ? 40 : 0;
const fixedWidth = actionsColWidth + selectColumnWidth; const fixedWidth = actionsColWidth + selectColumnWidth;
// Maximum allowed width - simple calculation to prevent overflow // Maximum allowed width - simple calculation to prevent overflow
@ -1000,6 +1008,128 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Track which cells are currently being updated (for loading state) // Track which cells are currently being updated (for loading state)
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set()); const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
// CSV Export state
const [csvExporting, setCsvExporting] = useState(false);
// CSV Export: fetch ALL data via direct API call (no state update, no table flicker)
// Fetches in batches of 1000 (backend max pageSize) until all data is loaded.
const handleCsvExport = useCallback(async () => {
if (csvExporting || detectedColumns.length === 0 || !apiEndpoint) return;
setCsvExporting(true);
try {
const batchSize = 1000; // Backend max pageSize
let allData: T[] = [];
let currentPage = 1;
let hasMore = true;
while (hasMore) {
const response = await api.get(apiEndpoint, {
params: { pagination: JSON.stringify({ page: currentPage, pageSize: batchSize }) }
});
let batchItems: T[];
if (response.data && typeof response.data === 'object' && 'items' in response.data) {
batchItems = Array.isArray(response.data.items) ? response.data.items : [];
// Use pagination metadata to determine if more pages exist
const totalPages = response.data.pagination?.totalPages || 1;
hasMore = currentPage < totalPages;
} else if (Array.isArray(response.data)) {
batchItems = response.data;
hasMore = false; // Non-paginated response — all data in one shot
} else {
batchItems = [];
hasMore = false;
}
allData = allData.concat(batchItems);
currentPage++;
// Safety: stop after 100 pages (100k records)
if (currentPage > 100) {
hasMore = false;
}
}
if (allData.length === 0) {
setCsvExporting(false);
return;
}
// Build CSV content
const separator = ';';
const _escapeCsvValue = (val: any): string => {
if (val === null || val === undefined) return '';
let str: string;
if (typeof val === 'boolean') {
str = val ? 'true' : 'false';
} else if (typeof val === 'object') {
if (isTextMultilingual(val)) {
str = formatTextMultilingual(val, currentLanguage);
} else {
try { str = JSON.stringify(val); } catch { str = String(val); }
}
} else {
str = String(val);
}
// Escape quotes and wrap in quotes if needed
if (str.includes(separator) || str.includes('"') || str.includes('\n') || str.includes('\r')) {
str = '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
// Header row
const headerRow = detectedColumns.map(col => _escapeCsvValue(col.label)).join(separator);
// Data rows
const dataRows = allData.map(row => {
return detectedColumns.map(col => {
let cellValue = row[col.key];
// FK resolution
if (col.fkSource && typeof cellValue === 'string' && cellValue.length > 0) {
const resolved = fkCache[col.fkSource]?.[cellValue];
if (resolved) cellValue = resolved;
}
// Timestamp formatting
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked|valuta)$/i.test(col.key);
const isExplicitDateType = col.type && isDateTimeType(col.type);
if ((isTimestampField || isExplicitDateType) && typeof cellValue === 'number') {
try {
let ts = cellValue < 10000000000 ? cellValue : cellValue / 1000;
const formatted = formatUnixTimestamp(ts, undefined, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
cellValue = `${formatted.time} ${formatted.timezone}`;
} catch { /* keep original */ }
}
return _escapeCsvValue(cellValue);
}).join(separator);
});
const csvContent = '\ufeff' + [headerRow, ...dataRows].join('\r\n'); // BOM for Excel
// Trigger download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `export_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('CSV export failed:', error);
} finally {
setCsvExporting(false);
}
}, [csvExporting, detectedColumns, apiEndpoint, currentLanguage, fkCache]);
// Check if inline editing is allowed for a column (based on RBAC permissions) // Check if inline editing is allowed for a column (based on RBAC permissions)
const canInlineEdit = useMemo(() => { const canInlineEdit = useMemo(() => {
// Auto-enable if inlineEditable is explicitly true OR if handleInlineUpdate is available // Auto-enable if inlineEditable is explicitly true OR if handleInlineUpdate is available
@ -1416,6 +1546,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
supportsBackendPagination={supportsBackendPagination} supportsBackendPagination={supportsBackendPagination}
hookData={hookData} hookData={hookData}
onCsvExport={apiEndpoint ? handleCsvExport : undefined}
csvExporting={csvExporting}
/> />
)} )}
@ -1452,7 +1584,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
<thead> <thead>
<tr> <tr>
{selectable && ( {selectable && (
<th className={styles.selectColumn} style={{ width: '50px', minWidth: '50px', maxWidth: '50px' }}> <th className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input <input
type="checkbox" type="checkbox"
checked={(() => { checked={(() => {
@ -1610,7 +1742,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)} )}
> >
{selectable && ( {selectable && (
<td className={styles.selectColumn} style={{ width: '50px', minWidth: '50px', maxWidth: '50px' }}> <td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input <input
type="checkbox" type="checkbox"
checked={selectedRows.has(index)} checked={selectedRows.has(index)}

View file

@ -104,7 +104,7 @@ export function useAutomations() {
// Fetch permissions from backend // Fetch permissions from backend
const fetchPermissions = useCallback(async () => { const fetchPermissions = useCallback(async () => {
try { try {
const perms = await checkPermission('DATA', 'AutomationDefinition'); const perms = await checkPermission('DATA', 'data.automation.AutomationDefinition');
setPermissions(perms); setPermissions(perms);
return perms; return perms;
} catch (error: any) { } catch (error: any) {
@ -507,7 +507,7 @@ export function useAutomationTemplates() {
const fetchPermissions = useCallback(async () => { const fetchPermissions = useCallback(async () => {
try { try {
const perms = await checkPermission('DATA', 'AutomationTemplate'); const perms = await checkPermission('DATA', 'data.automation.AutomationTemplate');
setPermissions(perms); setPermissions(perms);
return perms; return perms;
} catch (e: any) { } catch (e: any) {

View file

@ -524,15 +524,12 @@ export function usePromptOperations() {
} }
}; };
const handlePromptUpdate = async (promptId: string, updateData: { name: string; content: string }, _originalData?: any) => { const handlePromptUpdate = async (promptId: string, updateData: Record<string, any>, _originalData?: any) => {
setUpdateError(null); setUpdateError(null);
try { try {
// mandateId wird nicht mehr vom Client gesendet // Pass all provided fields (supports partial inline updates like isSystem toggle)
const requestBody = { const { id, mandateId, _createdBy, _createdAt, _modifiedAt, _permissions, ...requestBody } = updateData;
name: updateData.name,
content: updateData.content
};
const updatedPrompt = await updatePromptApi(request, promptId, requestBody); const updatedPrompt = await updatePromptApi(request, promptId, requestBody);
@ -555,8 +552,8 @@ export function usePromptOperations() {
}; };
// Generic inline update handler for FormGeneratorTable // Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (promptId: string, changes: Partial<{ name: string; content: string }>) => { const handleInlineUpdate = async (promptId: string, changes: Record<string, any>) => {
const result = await handlePromptUpdate(promptId, changes as { name: string; content: string }); const result = await handlePromptUpdate(promptId, changes);
if (!result.success) { if (!result.success) {
throw new Error(result.error || 'Failed to update'); throw new Error(result.error || 'Failed to update');
} }

View file

@ -3,55 +3,44 @@
* *
* System-Übersicht für den User. * System-Übersicht für den User.
* Zeigt alle verfügbaren Feature-Instanzen als Karten an. * Zeigt alle verfügbaren Feature-Instanzen als Karten an.
* Daten kommen vom Backend via GET /api/navigation.
*/ */
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useMandates, useFeatureStore } from '../stores/featureStore'; import useNavigation from '../hooks/useNavigation';
import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
import type { FeatureInstance } from '../types/mandate'; import { getPageIcon } from '../config/pageRegistry';
import { FaBriefcase, FaRobot, FaPlay, FaArrowRight } from 'react-icons/fa'; import { FaArrowRight } from 'react-icons/fa';
import styles from './Dashboard.module.css'; import styles from './Dashboard.module.css';
// =============================================================================
// FEATURE ICONS
// =============================================================================
const FEATURE_ICONS: Record<string, React.ReactNode> = {
trustee: <FaBriefcase size={24} />,
chatbot: <FaRobot size={24} />,
chatworkflow: <FaPlay size={24} />,
};
// ============================================================================= // =============================================================================
// INSTANCE CARD // INSTANCE CARD
// ============================================================================= // =============================================================================
interface InstanceCardProps { interface InstanceCardProps {
instance: FeatureInstance; instance: NavFeatureInstance;
featureLabel: string; feature: MandateFeature;
mandateLabel: string;
} }
const InstanceCard: React.FC<InstanceCardProps> = ({ instance, featureLabel }) => { const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature, mandateLabel }) => {
const basePath = `/mandates/${instance.mandateId}/${instance.featureCode}/${instance.id}`; // Ersten verfügbaren View-Pfad vom Backend nehmen
const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined;
// Ersten verfügbaren View finden
const featureConfig = FEATURE_REGISTRY[instance.featureCode]; if (!targetPath) return null;
const firstView = featureConfig?.views?.[0];
const targetPath = firstView ? `${basePath}/${firstView.path}` : basePath;
return ( return (
<Link to={targetPath} className={styles.instanceCard}> <Link to={targetPath} className={styles.instanceCard}>
<div className={styles.cardIcon}> <div className={styles.cardIcon}>
{FEATURE_ICONS[instance.featureCode] || <FaBriefcase size={24} />} {getPageIcon(feature.uiComponent)}
</div> </div>
<div className={styles.cardContent}> <div className={styles.cardContent}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
<span className={styles.featureLabel}>{featureLabel}</span> <span className={styles.featureLabel}>{feature.uiLabel}</span>
<span className={styles.roleBadge}>{instance.userRoles?.join(', ') || '-'}</span>
</div> </div>
<h3 className={styles.instanceLabel}>{instance.instanceLabel}</h3> <h3 className={styles.instanceLabel}>{instance.uiLabel}</h3>
<p className={styles.mandateName}>{instance.mandateName}</p> <p className={styles.mandateName}>{mandateLabel}</p>
</div> </div>
<div className={styles.cardArrow}> <div className={styles.cardArrow}>
<FaArrowRight /> <FaArrowRight />
@ -78,59 +67,80 @@ const EmptyState: React.FC = () => (
// ============================================================================= // =============================================================================
export const DashboardPage: React.FC = () => { export const DashboardPage: React.FC = () => {
const mandates = useMandates(); const { dynamicBlock, loading } = useNavigation();
const { hasAnyInstance, getAllInstances } = useFeatureStore();
// Alle Mandate und deren Features/Instanzen aus der Navigation
// Alle Instanzen sammeln für Übersicht const mandates: NavigationMandate[] = dynamicBlock?.mandates || [];
const allInstances = getAllInstances();
// Gesamtzahl Instanzen und Mandate berechnen
// Gruppiere nach Feature let totalInstances = 0;
const instancesByFeature = allInstances.reduce((acc, instance) => { const totalMandates = mandates.length;
const featureCode = instance.featureCode; mandates.forEach(m => m.features.forEach(f => {
if (!acc[featureCode]) { totalInstances += f.instances.length;
acc[featureCode] = []; }));
}
acc[featureCode].push(instance); if (loading) {
return acc; return (
}, {} as Record<string, FeatureInstance[]>); <div className={styles.dashboard}>
<header className={styles.header}>
if (!hasAnyInstance()) { <h1>Übersicht</h1>
<p className={styles.subtitle}>Lade...</p>
</header>
</div>
);
}
if (totalInstances === 0) {
return <EmptyState />; return <EmptyState />;
} }
// Gruppiere Instanzen nach Feature (über alle Mandate)
const featureGroups: { feature: MandateFeature; instances: { instance: NavFeatureInstance; mandateLabel: string }[] }[] = [];
const featureMap = new Map<string, typeof featureGroups[0]>();
for (const mandate of mandates) {
for (const feature of mandate.features) {
const key = feature.uiComponent;
let group = featureMap.get(key);
if (!group) {
group = { feature, instances: [] };
featureMap.set(key, group);
featureGroups.push(group);
}
for (const instance of feature.instances) {
group.instances.push({ instance, mandateLabel: mandate.uiLabel });
}
}
}
return ( return (
<div className={styles.dashboard}> <div className={styles.dashboard}>
<header className={styles.header}> <header className={styles.header}>
<h1>Übersicht</h1> <h1>Übersicht</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
Du hast Zugriff auf {allInstances.length} Feature-Instanz{allInstances.length !== 1 ? 'en' : ''} Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
in {mandates.length} Mandant{mandates.length !== 1 ? 'en' : ''}.
</p> </p>
</header> </header>
<main className={styles.content}> <main className={styles.content}>
{Object.entries(instancesByFeature).map(([featureCode, instances]) => { {featureGroups.map(({ feature, instances }) => (
const featureConfig = FEATURE_REGISTRY[featureCode]; <section key={feature.uiComponent} className={styles.featureSection}>
const featureLabel = featureConfig ? getLabel(featureConfig.label) : featureCode; <h2 className={styles.sectionTitle}>
{getPageIcon(feature.uiComponent)}
return ( <span>{feature.uiLabel}</span>
<section key={featureCode} className={styles.featureSection}> </h2>
<h2 className={styles.sectionTitle}> <div className={styles.instanceGrid}>
{FEATURE_ICONS[featureCode]} {instances.map(({ instance, mandateLabel }) => (
<span>{featureLabel}</span> <InstanceCard
</h2> key={instance.id}
<div className={styles.instanceGrid}> instance={instance}
{instances.map(instance => ( feature={feature}
<InstanceCard mandateLabel={mandateLabel}
key={instance.id} />
instance={instance} ))}
featureLabel={featureLabel} </div>
/> </section>
))} ))}
</div>
</section>
);
})}
</main> </main>
</div> </div>
); );

View file

@ -8,7 +8,7 @@
import React from 'react'; import React from 'react';
import { useCurrentInstance } from '../hooks/useCurrentInstance'; import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions'; import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; import useNavigation from '../hooks/useNavigation';
// Trustee Views // Trustee Views
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation // Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
@ -129,31 +129,13 @@ interface FeatureViewPageProps {
} }
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => { export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const { instance, featureCode, isValid } = useCurrentInstance(); const { instance, featureCode, mandateId, isValid } = useCurrentInstance();
const { dynamicBlock } = useNavigation();
// Berechtigungs-Check // Berechtigungs-Check
const viewCode = `${featureCode}-${view}`; const viewCode = `${featureCode}-${view}`;
const canView = useCanViewFeatureView(viewCode); const canView = useCanViewFeatureView(viewCode);
// DEBUG: Log permission check for chatbot
if (featureCode === 'chatbot') {
console.log('🔍 [DEBUG] FeatureView Permission Check:', {
featureCode,
view,
viewCode,
instanceId: instance?.id,
instanceLabel: instance?.instanceLabel,
isValid,
canView,
permissions: instance?.permissions,
views: instance?.permissions?.views,
viewKeys: instance?.permissions?.views ? Object.keys(instance.permissions.views) : [],
hasLegacyView: instance?.permissions?.views?.[viewCode],
hasFullObjectKey: instance?.permissions?.views?.[`ui.feature.${featureCode}.${view}`],
hasWildcard: instance?.permissions?.views?.['_all'],
});
}
// Nicht valider Kontext // Nicht valider Kontext
if (!isValid || !featureCode || !instance) { if (!isValid || !featureCode || !instance) {
return <NotFound />; return <NotFound />;
@ -175,10 +157,17 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return <NotFound />; return <NotFound />;
} }
// View-Info aus Registry // View-Label aus Backend-Navigation ermitteln
const featureConfig = FEATURE_REGISTRY[featureCode]; let viewLabel = view;
const viewConfig = featureConfig?.views?.find(v => v.code === view); if (dynamicBlock) {
const viewLabel = viewConfig ? getLabel(viewConfig.label) : view; const navMandate = dynamicBlock.mandates.find(m => m.id === mandateId);
const navFeature = navMandate?.features.find(f => f.uiComponent.includes(featureCode));
const navInstance = navFeature?.instances.find(i => i.id === instance.id);
const navView = navInstance?.views.find(v => v.uiComponent.includes(view));
if (navView) {
viewLabel = navView.uiLabel;
}
}
return ( return (
<div className={styles.featureView}> <div className={styles.featureView}>

View file

@ -439,6 +439,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={instances} data={instances}
columns={columns} columns={columns}
apiEndpoint="/api/features/instances"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -528,6 +528,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={instanceUsers} data={instanceUsers}
columns={columns} columns={columns}
apiEndpoint={selectedInstanceId ? `/api/features/instances/${selectedInstanceId}/users` : undefined}
loading={usersLoading} loading={usersLoading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -353,6 +353,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={roles} data={roles}
columns={columns} columns={columns}
apiEndpoint="/api/features/templates/roles"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -349,6 +349,7 @@ export const AdminInvitationsPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={invitations} data={invitations}
columns={columns} columns={columns}
apiEndpoint="/api/invitations/"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -433,6 +433,7 @@ export const AdminMandateRolesPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={roles} data={roles}
columns={columns} columns={columns}
apiEndpoint="/api/rbac/roles"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -162,6 +162,7 @@ export const AdminMandatesPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={mandates} data={mandates}
columns={columns} columns={columns}
apiEndpoint="/api/mandates/"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -188,7 +188,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
{overview.mandates.length === 0 ? ( {overview.mandates.length === 0 ? (
<p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p> <p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p>
) : ( ) : (
<div className={styles.rolesList}> <div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.mandates.map(mandate => ( {overview.mandates.map(mandate => (
<div key={mandate.id} className={styles.roleCard}> <div key={mandate.id} className={styles.roleCard}>
<div <div
@ -250,7 +250,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
{overview.roles.length === 0 ? ( {overview.roles.length === 0 ? (
<p className={styles.emptyHint}>Keine Rollen zugewiesen.</p> <p className={styles.emptyHint}>Keine Rollen zugewiesen.</p>
) : ( ) : (
<div className={styles.rolesList}> <div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.roles.map(role => ( {overview.roles.map(role => (
<div key={role.id} className={styles.roleCard}> <div key={role.id} className={styles.roleCard}>
<div <div
@ -590,7 +590,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
) : overview ? ( ) : overview ? (
<> <>
{/* User Info */} {/* User Info */}
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}> <div className={styles.infoBox} style={{ marginBottom: '1rem', flexShrink: 0 }}>
<strong>{overview.user.fullName || overview.user.username}</strong> <strong>{overview.user.fullName || overview.user.username}</strong>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span> <span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span>{overview.user.email}</span> <span>{overview.user.email}</span>
@ -610,7 +610,8 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
gap: '0.5rem', gap: '0.5rem',
marginBottom: '1rem', marginBottom: '1rem',
borderBottom: '1px solid var(--border-color)', borderBottom: '1px solid var(--border-color)',
paddingBottom: '0.5rem' paddingBottom: '0.5rem',
flexShrink: 0
}}> }}>
<button <button
className={activeTab === 'overview' ? styles.primaryButton : styles.secondaryButton} className={activeTab === 'overview' ? styles.primaryButton : styles.secondaryButton}

View file

@ -352,6 +352,7 @@ export const AdminUserMandatesPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={users} data={users}
columns={columns} columns={columns}
apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined}
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -203,6 +203,7 @@ export const AdminUsersPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={users} data={users}
columns={columns} columns={columns}
apiEndpoint="/api/users/"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -43,19 +43,36 @@ export const ConnectionsPage: React.FC = () => {
refetch(); refetch();
}, []); }, []);
// Generate columns from attributes // Generate columns from attributes - hide internal/redundant fields
const columns = useMemo(() => { const columns = useMemo(() => {
return (attributes || []).map(attr => ({ const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes'];
key: attr.name,
label: attr.label || attr.name, return (attributes || [])
type: attr.type as any, .filter(attr => !hiddenColumns.includes(attr.name))
sortable: attr.sortable !== false, .map(attr => {
filterable: attr.filterable !== false, const col: any = {
searchable: attr.searchable !== false, key: attr.name,
width: attr.width || 150, label: attr.label || attr.name,
minWidth: attr.minWidth || 100, type: attr.type as any,
maxWidth: attr.maxWidth || 400, sortable: attr.sortable !== false,
})); filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
};
// Resolve userId to username via FK
if (attr.name === 'userId') {
col.fkSource = '/api/users/';
col.fkDisplayField = 'username';
col.label = 'User';
}
return col;
});
}, [attributes]); }, [attributes]);
// Check permissions // Check permissions
@ -258,6 +275,7 @@ export const ConnectionsPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={connections} data={connections}
columns={columns} columns={columns}
apiEndpoint="/api/connections/"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -60,19 +60,40 @@ export const FilesPage: React.FC = () => {
refetch(); refetch();
}, []); }, []);
// Generate columns from attributes // Generate columns from attributes - hide internal fields
const columns = useMemo(() => { const columns = useMemo(() => {
return (attributes || []).map(attr => ({ const hiddenColumns = ['id', 'mandateId', 'featureInstanceId', 'fileHash'];
key: attr.name,
label: attr.label || attr.name, const cols = (attributes || [])
type: attr.type as any, .filter(attr => !hiddenColumns.includes(attr.name))
sortable: attr.sortable !== false, .map(attr => ({
filterable: attr.filterable !== false, key: attr.name,
searchable: attr.searchable !== false, label: attr.label || attr.name,
width: attr.width || 150, type: attr.type as any,
minWidth: attr.minWidth || 100, sortable: attr.sortable !== false,
maxWidth: attr.maxWidth || 400, filterable: attr.filterable !== false,
})); searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
// Add _createdBy column with FK resolution to show username
cols.push({
key: '_createdBy',
label: 'Created By',
type: 'text' as any,
sortable: true,
filterable: false,
searchable: false,
width: 150,
minWidth: 100,
maxWidth: 250,
fkSource: '/api/users/',
fkDisplayField: 'username',
});
return cols;
}, [attributes]); }, [attributes]);
// Check permissions // Check permissions
@ -255,6 +276,7 @@ export const FilesPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={files} data={files}
columns={columns} columns={columns}
apiEndpoint="/api/files/list"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -53,9 +53,9 @@ export const PromptsPage: React.FC = () => {
// Generate columns from attributes - exclude ID fields from display // Generate columns from attributes - exclude ID fields from display
const columns = useMemo(() => { const columns = useMemo(() => {
// Fields to hide in table view // Fields to hide in table view
const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete']; const hiddenColumns = ['id', 'mandateId', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
return (attributes || []) const cols = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name)) .filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({ .map(attr => ({
key: attr.name, key: attr.name,
@ -67,7 +67,26 @@ export const PromptsPage: React.FC = () => {
width: attr.name === 'content' ? 300 : attr.width || 150, width: attr.name === 'content' ? 300 : attr.width || 150,
minWidth: attr.minWidth || 100, minWidth: attr.minWidth || 100,
maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400, maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
})); }));
// Add _createdBy column with FK resolution to show username
cols.push({
key: '_createdBy',
label: 'Created By',
type: 'text' as any,
sortable: true,
filterable: false,
searchable: false,
width: 150,
minWidth: 100,
maxWidth: 250,
fkSource: '/api/users/',
fkDisplayField: 'username',
});
return cols;
}, [attributes]); }, [attributes]);
// Check permissions // Check permissions
@ -118,7 +137,7 @@ export const PromptsPage: React.FC = () => {
// Form attributes for create/edit modal // Form attributes for create/edit modal
const formAttributes = useMemo(() => { const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete']; const excludedFields = ['id', 'mandateId', 'isSystem', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
return (attributes || []) return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name)); .filter(attr => !excludedFields.includes(attr.name));
}, [attributes]); }, [attributes]);
@ -189,6 +208,7 @@ export const PromptsPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={prompts} data={prompts}
columns={columns} columns={columns}
apiEndpoint="/api/prompts"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -474,6 +474,7 @@ export const BillingDataView: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={transactions} data={transactions}
columns={columns} columns={columns}
apiEndpoint="/api/billing/balance"
loading={transactionsLoading} loading={transactionsLoading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -185,6 +185,7 @@ export const RealEstateParcelsView: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={parcels} data={parcels}
columns={columns} columns={columns}
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -169,6 +169,7 @@ export const RealEstateProjectsView: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={projects} data={projects}
columns={columns} columns={columns}
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -228,6 +228,7 @@ export const TrusteeDocumentsView: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={documents} data={documents}
columns={columns} columns={columns}
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/documents` : undefined}
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -201,6 +201,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={links} data={links}
columns={columns} columns={columns}
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/position-documents` : undefined}
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -222,6 +222,7 @@ export const TrusteePositionsView: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={positions} data={positions}
columns={columns} columns={columns}
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/positions` : undefined}
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -170,6 +170,7 @@ export const AutomationTemplatesPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={templates as any[]} data={templates as any[]}
columns={columns} columns={columns}
apiEndpoint="/api/automation-templates"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -125,11 +125,16 @@ export const AutomationsPage: React.FC = () => {
} }
}, [executionModal.logs]); }, [executionModal.logs]);
// Generate columns from attributes - exclude ID fields from display // Generate columns from attributes - exclude internal fields, add enriched display columns
const columns = useMemo(() => { const columns = useMemo(() => {
const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'template', 'executionLogs', 'placeholders']; const hiddenColumns = [
'id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt',
'template', 'executionLogs', 'placeholders',
// Hide enriched fields from attribute list (added manually below)
'_createdByUserName', 'mandateName', 'featureInstanceName',
];
return (attributes || []) const attrColumns = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name)) .filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({ .map(attr => ({
key: attr.name, key: attr.name,
@ -142,6 +147,15 @@ export const AutomationsPage: React.FC = () => {
minWidth: attr.minWidth || 100, minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400, maxWidth: attr.maxWidth || 400,
})); }));
// Add enriched display columns (from backend enrichment)
const enrichedColumns = [
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
{ key: '_createdByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
];
return [...attrColumns, ...enrichedColumns];
}, [attributes]); }, [attributes]);
// Check permissions // Check permissions
@ -583,6 +597,7 @@ export const AutomationsPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={automations as any[]} data={automations as any[]}
columns={columns} columns={columns}
apiEndpoint="/api/automations"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -173,6 +173,7 @@ export const WorkflowsPage: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={workflows} data={workflows}
columns={columns} columns={columns}
apiEndpoint="/api/workflows/"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}