frontend_nyla/src/pages/IntegrationsOverviewPage.tsx
2026-04-14 11:16:19 +02:00

492 lines
20 KiB
TypeScript

/**
* PORTA architecture overview — data → processing → organisation.
* Layout matches local/notes/demo-tue-porta_architecture_v3.html (order: Schicht 3 → Pfeil ↓ → Schicht 2 → Pfeil ↑ → Schicht 1).
*/
import React, { useMemo } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
import { useIntegrationsOverview, type DataLayerItem, type LiveStats } from '../hooks/useIntegrationsOverview';
import styles from './IntegrationsOverview.module.css';
/** de-CH: 1'234'567 */
function _formatStatNumber(n: number): string {
return new Intl.NumberFormat('de-CH', { maximumFractionDigits: 0 }).format(n);
}
function _shortExtractorSymbol(className: string): string {
return className.replace(/Extractor$/i, '') || className;
}
function _shortRendererSymbol(className: string): string {
return className.replace(/^Renderer/i, '') || className;
}
function _IconLightning({ className }: { className?: string }) {
return (
<svg className={className} width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path
fill="currentColor"
d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"
/>
</svg>
);
}
function _IconGear({ className }: { className?: string }) {
return (
<svg className={className} width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path
fill="currentColor"
d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
/>
</svg>
);
}
function _ArrowDown() {
return (
<div className={styles.arrowVert} aria-hidden>
<svg width="24" height="28" viewBox="0 0 24 28">
<path
d="M12 2v20M6 16l6 6 6-6"
fill="none"
stroke="var(--text-tertiary, #718096)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function _ArrowUp() {
return (
<div className={styles.arrowVert} aria-hidden>
<svg width="24" height="28" viewBox="0 0 24 28">
<path
d="M12 26V6M6 12l6-6 6 6"
fill="none"
stroke="var(--text-tertiary, #718096)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function _authorityIcon(authority?: string): string {
const a = (authority || '').toLowerCase();
if (a === 'msft') return 'Ⓜ';
if (a === 'google') return 'G';
if (a === 'clickup') return '▣';
if (a === 'local') return '●';
return '◇';
}
function _dataLayerItemKey(item: DataLayerItem): string {
return `${item.kind}-${item.id}`;
}
/** i18n for provider labels where the API sends a fixed German string (e.g. Tavily suffix). */
function _aicoreConnectorLabel(
connectorType: string,
rawLabel: string,
t: (key: string) => string,
): string {
if (connectorType === 'tavily') {
return `Tavily (${t('Websuche')})`;
}
return rawLabel;
}
function _renderPersonalChip(
item: DataLayerItem,
stylesModule: typeof styles,
): React.ReactElement {
return (
<div key={_dataLayerItemKey(item)} className={stylesModule.dataChip}>
<span className={stylesModule.dataIcon}>{_authorityIcon(item.authority)}</span>
<div className={stylesModule.dataChipBody}>
<div className={stylesModule.dataChipMain}>{item.displayLabel}</div>
<div className={stylesModule.dataChipSub}>{item.connectionReference}</div>
</div>
</div>
);
}
interface _CorporateInstanceGroup {
instanceId: string;
instanceLabel: string;
featureCode: string;
}
function _groupCorporateByInstance(items: DataLayerItem[]): _CorporateInstanceGroup[] {
const map = new Map<string, _CorporateInstanceGroup>();
for (const item of items) {
const iid = item.featureInstanceId || '_unknown';
if (map.has(iid)) {
const group = map.get(iid)!;
if (item.instanceLabel && (!group.instanceLabel || group.instanceLabel === group.featureCode)) {
group.instanceLabel = item.instanceLabel.trim();
}
if (item.featureCode && !group.featureCode) {
group.featureCode = item.featureCode;
}
continue;
}
map.set(iid, {
instanceId: iid,
instanceLabel: (item.instanceLabel || '').trim(),
featureCode: item.featureCode || item.connectorType || '',
});
}
return Array.from(map.values());
}
function _ArrowRight() {
return (
<div className={`portaArchFlowCol ${styles.flowCol}`} aria-hidden>
<svg width="20" height="14" viewBox="0 0 20 14">
<path
d="M2 7h14M12 3l4 4-4 4"
fill="none"
stroke="var(--text-tertiary, #718096)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
export const IntegrationsOverviewPage: React.FC = () => {
const { t } = useLanguage();
const {
loading,
error,
diagram,
mandateCards,
workflowChips,
hasNeutralization,
refetch,
} = useIntegrationsOverview();
const infraToolRows = useMemo(() => {
const tools = diagram?.infraTools ?? [];
return tools.map((row) => ({ ...row, label: t(row.label) }));
}, [diagram?.infraTools, t]);
const statItems = useMemo(() => {
const s: LiveStats = diagram?.liveStats ?? {
aiCallCount: 0, aiCallPeriodDays: 30,
totalWorkflows: 0, activeWorkflows: 0, totalRuns: 0, totalTokens: 0,
};
const connectedSystems = (diagram?.dataLayerItems ?? [])
.filter((d) => d.kind === 'userConnection').length;
return [
{ value: s.aiCallCount, label: t('AI-Aufrufe'), sub: `${s.aiCallPeriodDays} ${t('Tage')}` },
{ value: s.activeWorkflows, label: t('Aktive Workflows'), sub: s.totalWorkflows > 0 ? `${_formatStatNumber(s.totalWorkflows)} ${t('total')}` : undefined },
{ value: s.totalRuns, label: t('Workflow-Läufe'), sub: s.totalTokens > 0 ? `${_formatStatNumber(s.totalTokens)} ${t('Tokens')}` : undefined },
{ value: connectedSystems, label: t('Verbundene Systeme') },
];
}, [diagram, t]);
const dataPersonalItems = useMemo(
() => (diagram?.dataLayerItems ?? []).filter((d) => d.kind === 'userConnection'),
[diagram?.dataLayerItems],
);
const corporateGroups = useMemo(() => {
const items = (diagram?.dataLayerItems ?? []).filter(
(d) => d.kind !== 'userConnection' && d.kind !== 'dataSource',
);
return _groupCorporateByInstance(items);
}, [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>
<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}>
{loading && <div className={styles.loadingWrap}>{t('Laden…')}</div>}
{error && (
<div className={styles.errorWrap}>
{error}{' '}
<button
type="button"
className={styles.errorRetry}
onClick={() => void refetch()}
>
{t('Erneut versuchen')}
</button>
</div>
)}
{!loading && !error && (
<>
<div className={styles.layerLabel}>
<span className={styles.layerNum}>3</span>
{t('Organisation — Mandanten & Module')}
</div>
<div className={`${styles.layer} ${styles.layerOrg}`}>
<div className={styles.tenantGrid}>
{mandateCards.length === 0 ? (
<p className={styles.tenantEmpty}>
{t('Keine Mandanten in der Navigation sichtbar.')}
</p>
) : (
mandateCards.map((m) => (
<div key={m.id} className={styles.tenantCard}>
<div className={styles.tenantName}>
{m.uiLabel}
</div>
<div className={styles.modGrid}>
{m.moduleChips.map((chip) => (
<span key={chip} className={styles.modChip}>
{chip}
</span>
))}
</div>
</div>
))
)}
</div>
</div>
<_ArrowDown />
<div className={styles.layerLabel}>
<span className={styles.layerNum}>2</span>
{t('Verarbeitung — Infrastruktur → PORTA → Nutzen')}
</div>
<div className={`portaArchMidRow ${styles.midRow}`}>
<div className={styles.boxInfra}>
<div className={styles.boxTitle}>
<span className={styles.boxTitleIcon}></span>
{t('Infrastruktur')}
</div>
<div className={styles.infraSplit}>
<div className={styles.infraSubBox}>
<div className={`${styles.infraBlockTitle} ${styles.infraBlockTitleWithIcon}`}>
<_IconLightning className={styles.infraTitleSvg} />
{t('AI LLM')}
</div>
<div className={styles.aicoreGrid}>
{(diagram?.aicoreModules ?? []).map((m) => (
<div key={m.connectorType} className={styles.aicoreModule}>
<div className={styles.aicoreModuleText}>
<div className={styles.aicoreModuleTitle}>
{_aicoreConnectorLabel(m.connectorType, m.label, t)}
</div>
{m.modelCount > 0 ? (
<div className={styles.aicoreModuleMeta}>
{m.modelCount} {t('Modelle')}
</div>
) : null}
</div>
</div>
))}
</div>
</div>
<div className={styles.infraSubBox}>
<div className={`${styles.infraBlockTitle} ${styles.infraBlockTitleWithIcon}`}>
<_IconGear className={styles.infraTitleSvg} />
{t('Werkzeuge')}
</div>
{infraToolRows.length > 0 ? (
infraToolRows.map((ex) => (
<div key={ex.id} className={styles.infraItem}>
<_IconGear className={styles.infraItemGear} />
{ex.label}
</div>
))
) : (
<div className={styles.infraEmptyHint}>{t('Keine Werkzeuge registriert.')}</div>
)}
</div>
</div>
</div>
<_ArrowRight />
<div className={styles.boxPorta}>
<div className={styles.boxTitle}>
<img
src="/logos/poweron-logo.png"
alt=""
className={styles.portaTitleLogo}
width={62}
height={62}
/>
{t('PORTA')}
</div>
<div className={styles.shieldRow}>
<div className={styles.coreBox}>
<div className={styles.coreTitle}>
<span className={styles.coreIcon}>🛡</span>
{t('Neutralisierung')}
</div>
<div className={styles.subLabels}>
<span className={styles.subLabel}>{t('PII-Masking')}</span>
<span className={styles.subLabel}>{t('Private LLM')}</span>
<span className={styles.subLabel}>{t('Platzhalter')}</span>
</div>
{!hasNeutralization && (
<div className={styles.subLabels}>
<span className={styles.subLabel}>{t('optional pro Instanz')}</span>
</div>
)}
</div>
<div className={styles.coreBox}>
<div className={styles.coreTitle}>
<span className={styles.coreIcon}>🔒</span>
{t('Datenkontrolle')}
</div>
<div className={styles.subLabels}>
<span className={styles.subLabel}>{t('RBAC')}</span>
<span className={styles.subLabel}>{t('Mandanten')}</span>
<span className={styles.subLabel}>{t('Rollen')}</span>
</div>
</div>
</div>
<div className={styles.secLabel}>{t('Workflows')}</div>
{workflowChips.length === 0 ? (
<div className={styles.portaEmptyHint}>{t('Keine Workflows aus Graphical Editor geladen.')}</div>
) : (
<div className={styles.wfRow}>
{workflowChips.map((w) => (
<div key={w} className={styles.wfChipFlow}>
<span className={styles.wfChipFlowLabel}>{w}</span>
<span className={styles.wfChipFlowArrow} aria-hidden>
<svg width="12" height="12" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8z"
/>
</svg>
</span>
</div>
))}
</div>
)}
<div className={styles.portaCodecSplit}>
<div className={styles.portaCodecSubBox}>
<div className={styles.portaCodecSubTitle}>{t('Extractors')}</div>
<div className={styles.codecSymRow}>
{(diagram?.extractorClasses ?? []).length > 0
? (diagram?.extractorClasses ?? []).map((row) => (
<span key={row.className} className={styles.codecSym} title={row.className}>
{_shortExtractorSymbol(row.className)}
</span>
))
: (diagram?.extractorExtensions ?? []).map((b) => (
<span key={b} className={styles.codecSym} title={b}>
{b}
</span>
))}
</div>
</div>
<div className={styles.portaCodecSubBox}>
<div className={styles.portaCodecSubTitle}>{t('Renderers')}</div>
<div className={styles.codecSymRow}>
{(diagram?.rendererClasses ?? []).length > 0
? (diagram?.rendererClasses ?? []).map((row) => (
<span key={row.className} className={styles.codecSym} title={row.className}>
{_shortRendererSymbol(row.className)}
</span>
))
: (diagram?.rendererFormats ?? []).map((b) => (
<span key={b} className={styles.codecSym} title={b}>
{b}
</span>
))}
</div>
</div>
</div>
</div>
<_ArrowRight />
<div className={styles.boxNutzen}>
<div className={styles.boxTitle}>
<span className={styles.boxTitleIcon}></span>
{t('Nutzen')}
</div>
<div className={styles.statGrid}>
{statItems.map((item) => (
<div key={item.label} className={styles.statTile}>
<span className={styles.statValue}>
{typeof item.value === 'number' ? _formatStatNumber(item.value) : item.value}
</span>
<div className={styles.statText}>
<span className={styles.statLabel}>{item.label}</span>
{item.sub ? (
<span className={styles.statSub}>{item.sub}</span>
) : null}
</div>
</div>
))}
<div className={styles.statTeaser}>
<span className={styles.statTeaserPlus}>+</span>
<span className={styles.statTeaserText}>{t('Ihre KPIs — individuell konfigurierbar')}</span>
</div>
</div>
</div>
</div>
<_ArrowUp />
<div className={styles.layerLabel}>
<span className={styles.layerNum}>1</span>
{t('Daten — die Basis von allem')}
</div>
<div className={`${styles.layer} ${styles.layerData}`}>
<div className={styles.dataLayerSplit}>
<div className={styles.dataSubsection}>
<div className={styles.dataSubsectionTitle}>{t('Persönliche Verbindungen')}</div>
{dataPersonalItems.length === 0 ? (
<span className={styles.dataChipMuted}>{t('Keine persönlichen Verbindungen.')}</span>
) : (
<div className={styles.dataChips}>
{dataPersonalItems.map((item) => _renderPersonalChip(item, styles))}
</div>
)}
</div>
<div className={styles.dataSubsection}>
<div className={styles.dataSubsectionTitle}>{t('Unternehmens- & Systemdaten')}</div>
{corporateGroups.length === 0 ? (
<span className={styles.dataChipMuted}>{t('Keine Unternehmens- oder Systemdaten erfasst.')}</span>
) : (
<div className={styles.modGrid}>
{corporateGroups.map((g) => (
<span key={g.instanceId} className={styles.modChip}>
{g.instanceLabel}{g.featureCode ? ` (${g.featureCode})` : ''}
</span>
))}
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
};