492 lines
20 KiB
TypeScript
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>
|
|
);
|
|
};
|