ui-nyla/src/pages/admin/InstanceHierarchyView.tsx
2026-04-11 19:44:52 +02:00

300 lines
11 KiB
TypeScript

/**
* InstanceHierarchyView
*
* Visual hierarchy: Mandanten (expandable) → Feature → Instanz (expandable) → User.
* Hover over a user shows tooltip with Berechtigungen (roleLabels), E-Mail, and Aktiv/Inaktiv.
*/
import React, { useState, useMemo } from 'react';
import { FaChevronDown, FaChevronRight, FaUsers } from 'react-icons/fa';
import type { Feature } from '../../hooks/useFeatureAccess';
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
import type { InstanceWithStats } from './AccessManagementHub';
import type { Mandate } from '../../hooks/useUserMandates';
import hubStyles from './AccessManagementHub.module.css';
import hierarchyStyles from './InstanceHierarchyView.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface InstanceHierarchyViewProps {
mandates: Mandate[];
getMandateName: (mandate: Mandate) => string;
instancesByMandate: Record<string, InstanceWithStats[]>;
instanceUsersMap: Record<string, FeatureAccessUser[]>;
features: Feature[];
getFeatureLabel: (feature: Feature) => string;
loading?: boolean;
onOpenDetail: (instance: InstanceWithStats, mandateId: string) => void;
}
function getFeatureLabelSafe(
features: Feature[],
featureCode: string,
getFeatureLabel: (f: Feature) => string
): string {
const f = features.find((x) => x.code === featureCode);
return f ? getFeatureLabel(f) : featureCode;
}
interface MandateContentProps {
mandateId: string;
mandateName: string;
instances: InstanceWithStats[];
instanceUsersMap: Record<string, FeatureAccessUser[]>;
features: Feature[];
getFeatureLabel: (f: Feature) => string;
onOpenDetail: (instance: InstanceWithStats, mandateId: string) => void;
}
function MandateContent({
mandateId,
mandateName: _mandateName,
instances,
instanceUsersMap,
features,
getFeatureLabel,
onOpenDetail,
}: MandateContentProps) {
const { t } = useLanguage();
const [expandedInstanceIds, setExpandedInstanceIds] = useState<Set<string>>(new Set());
const byFeature = useMemo(() => {
const map: Record<string, InstanceWithStats[]> = {};
instances.forEach((inst) => {
if (!map[inst.featureCode]) map[inst.featureCode] = [];
map[inst.featureCode].push(inst);
});
return map;
}, [instances]);
const toggleInstance = (instanceId: string) => {
setExpandedInstanceIds((prev) => {
const next = new Set(prev);
if (next.has(instanceId)) next.delete(instanceId);
else next.add(instanceId);
return next;
});
};
return (
<div className={hierarchyStyles.mandateContentInner}>
{Object.entries(byFeature).map(([featureCode, featureInstances]) => {
const featureUserCount = featureInstances.reduce(
(sum, inst) => sum + (instanceUsersMap[inst.id]?.length ?? 0),
0
);
return (
<div key={featureCode} className={hierarchyStyles.levelFeature}>
<div className={hierarchyStyles.featureHeader}>
<span className={hierarchyStyles.featureLabel}>
{getFeatureLabelSafe(features, featureCode, getFeatureLabel)}
</span>
<span className={hierarchyStyles.featureCount}>
{featureInstances.length}{' '}
{featureInstances.length !== 1 ? t('Instanzen') : t('Instanz')}
{featureUserCount > 0 && ` · ${featureUserCount} ${t('Benutzer')}`}
</span>
</div>
{featureInstances.map((inst) => {
const isExpanded = expandedInstanceIds.has(inst.id);
const users = instanceUsersMap[inst.id] ?? [];
return (
<div key={inst.id} className={hierarchyStyles.levelInstance}>
<div className={hierarchyStyles.instanceRowContainer}>
<button
type="button"
className={hierarchyStyles.instanceRow}
onClick={() => toggleInstance(inst.id)}
aria-expanded={isExpanded}
>
<span className={hierarchyStyles.instanceChevron}>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={hierarchyStyles.instanceLabel}>{inst.label}</span>
<span
className={`${hubStyles.instanceBadge} ${inst.enabled ? hubStyles.badgeActive : hubStyles.badgeInactive}`}
>
{inst.enabled ? t('Aktiv') : t('Inaktiv')}
</span>
<span className={hierarchyStyles.instanceUserCount}>
<FaUsers /> {users.length}
</span>
</button>
<button
type="button"
className={hierarchyStyles.manageUsersBtn}
onClick={(e) => {
e.stopPropagation();
onOpenDetail(inst, mandateId);
}}
title={t('Benutzer verwalten')}
>
<FaUsers /> {t('Benutzer verwalten')}
</button>
</div>
{isExpanded && (
<div className={hierarchyStyles.levelUsers}>
{users.length === 0 ? (
<div className={hierarchyStyles.noUsers}>
{t('Keine Benutzer zugeordnet.')}{' '}
<button
type="button"
className={hierarchyStyles.linkButton}
onClick={() => onOpenDetail(inst, mandateId)}
>
{t('Benutzer verwalten')}
</button>
</div>
) : (
users.map((u) => <UserRow key={u.id} user={u} />)
)}
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
);
}
export const InstanceHierarchyView: React.FC<InstanceHierarchyViewProps> = ({
mandates,
getMandateName,
instancesByMandate,
instanceUsersMap,
features,
getFeatureLabel,
loading,
onOpenDetail,
}) => {
const { t } = useLanguage();
const [expandedMandateIds, setExpandedMandateIds] = useState<Set<string>>(new Set());
const toggleMandate = (mandateId: string) => {
setExpandedMandateIds((prev) => {
const next = new Set(prev);
if (next.has(mandateId)) next.delete(mandateId);
else next.add(mandateId);
return next;
});
};
if (loading) {
return (
<section className={hubStyles.section}>
<div className={hierarchyStyles.hierarchyLoading}>
<span className={hierarchyStyles.spinner} />
<span>{t('Lade Hierarchie und Benutzer')}</span>
</div>
</section>
);
}
if (mandates.length === 0) {
return (
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>{t('Hierarchie')}</h2>
<div className={hierarchyStyles.emptyHierarchy}>
{t('Keine Mandanten vorhanden. Legen Sie unter „Mandanten verwalten“ einen Mandanten an.')}
</div>
</section>
);
}
return (
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>{t('Hierarchie')}</h2>
<div className={hierarchyStyles.hierarchyRoot}>
{mandates.map((mandate) => {
const mandateId = mandate.id;
const instances = instancesByMandate[mandateId] ?? [];
const isExpanded = expandedMandateIds.has(mandateId);
const mandateName = getMandateName(mandate);
const byFeat: Record<string, InstanceWithStats[]> = {};
instances.forEach((inst) => {
if (!byFeat[inst.featureCode]) byFeat[inst.featureCode] = [];
byFeat[inst.featureCode].push(inst);
});
const featureCount = Object.keys(byFeat).length;
return (
<div key={mandateId} className={hierarchyStyles.levelMandateWrapper}>
<button
type="button"
className={hierarchyStyles.mandateRow}
onClick={() => toggleMandate(mandateId)}
aria-expanded={isExpanded}
>
<span className={hierarchyStyles.instanceChevron}>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={hierarchyStyles.mandateLabel}>{mandateName}</span>
<span className={hierarchyStyles.mandateMeta}>
{featureCount} {featureCount !== 1 ? t('Features') : t('Feature')} · {instances.length}{' '}
{instances.length !== 1 ? t('Instanzen') : t('Instanz')}
</span>
</button>
{isExpanded && (
<MandateContent
mandateId={mandateId}
mandateName={mandateName}
instances={instances}
instanceUsersMap={instanceUsersMap}
features={features}
getFeatureLabel={getFeatureLabel}
onOpenDetail={onOpenDetail}
/>
)}
</div>
);
})}
</div>
</section>
);
};
interface UserRowProps {
user: FeatureAccessUser;
}
function UserRow({ user }: UserRowProps) {
const { t } = useLanguage();
const displayName = user.fullName?.trim() || user.username || user.userId;
const rolesText =
user.roleLabels && user.roleLabels.length > 0
? user.roleLabels.join(', ')
: t('Keine Rollen');
const statusText = user.enabled ? t('Aktiv') : t('Inaktiv');
return (
<div className={hierarchyStyles.userRowWrapper}>
<div className={hierarchyStyles.userRow}>
<span className={hierarchyStyles.userName}>{displayName}</span>
<span className={hierarchyStyles.userRoles}>{rolesText}</span>
<span
className={user.enabled ? hierarchyStyles.userStatusActive : hierarchyStyles.userStatusInactive}
>
{statusText}
</span>
</div>
<div className={hierarchyStyles.tooltipBubble} role="tooltip">
<div className={hierarchyStyles.tooltipTitle}>{t('Berechtigungen')}</div>
{user.email && (
<div className={hierarchyStyles.tooltipEmail}>{user.email}</div>
)}
<div className={hierarchyStyles.tooltipRoles}>{rolesText}</div>
<div className={hierarchyStyles.tooltipStatus}>
{t('Zugang')}: {statusText}
</div>
</div>
</div>
);
}
export default InstanceHierarchyView;