292 lines
10 KiB
TypeScript
292 lines
10 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';
|
|
|
|
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 [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} Instanz{featureInstances.length !== 1 ? 'en' : ''}
|
|
{featureUserCount > 0 && ` · ${featureUserCount} 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 ? 'Aktiv' : '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="Benutzer verwalten"
|
|
>
|
|
<FaUsers /> Benutzer verwalten
|
|
</button>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className={hierarchyStyles.levelUsers}>
|
|
{users.length === 0 ? (
|
|
<div className={hierarchyStyles.noUsers}>
|
|
Keine Benutzer zugeordnet.{' '}
|
|
<button
|
|
type="button"
|
|
className={hierarchyStyles.linkButton}
|
|
onClick={() => onOpenDetail(inst, mandateId)}
|
|
>
|
|
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 [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>Lade Hierarchie und Benutzer...</span>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (mandates.length === 0) {
|
|
return (
|
|
<section className={hubStyles.section}>
|
|
<h2 className={hubStyles.sectionTitle}>Hierarchie</h2>
|
|
<div className={hierarchyStyles.emptyHierarchy}>
|
|
Keine Mandanten vorhanden. Legen Sie unter "Mandanten verwalten" einen Mandanten an.
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className={hubStyles.section}>
|
|
<h2 className={hubStyles.sectionTitle}>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} Feature{featureCount !== 1 ? 's' : ''} · {instances.length} Instanz{instances.length !== 1 ? 'en' : ''}
|
|
</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 displayName = user.fullName?.trim() || user.username || user.userId;
|
|
const rolesText =
|
|
user.roleLabels && user.roleLabels.length > 0
|
|
? user.roleLabels.join(', ')
|
|
: 'Keine Rollen';
|
|
const statusText = user.enabled ? 'Aktiv' : '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}>Berechtigungen</div>
|
|
{user.email && (
|
|
<div className={hierarchyStyles.tooltipEmail}>{user.email}</div>
|
|
)}
|
|
<div className={hierarchyStyles.tooltipRoles}>{rolesText}</div>
|
|
<div className={hierarchyStyles.tooltipStatus}>Zugang: {statusText}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default InstanceHierarchyView;
|