feat: Add configurable card design options and enhance person details in organization chart

This commit is contained in:
Torsten Brendgen
2026-08-24 22:50:06 +02:00
parent 7f741caa4b
commit 983a4af63c
12 changed files with 191 additions and 35 deletions
+3
View File
@@ -11,6 +11,9 @@ SPFx-1.4.1-WebPart für SharePoint Server Subscription Edition. Es erstellt aus
- standardmäßig vollständig aufgeklappter Baum
- einzelne Zweige können auf- und zugeklappt werden
- Profilbild, Anzeigename, Position, Abteilung und optional Kontaktdaten
- Teams-ähnliche Personenkarten mit Text links und Profilbild rechts
- aufklappbare Personendetails direkt in der Karte
- konfigurierbare Kartenfarben und wahlweise abgerundete Ecken
- Verlinkung auf die SharePoint-Profilseite
- kurzzeitiger In-Memory-Profilcache zur Reduzierung wiederholter REST-Aufrufe
- responsive Baumdarstellung mit automatisch kompakteren Karten bei breiten Organisationsebenen
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "organization-chart-webpart",
"title": "organization-chart-webpart",
"id": "b2f88ea8-f7da-4d0e-a945-6973bf1acd1e",
"version": "1.0.6.0",
"version": "1.1.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "organization-chart-webpart",
"version": "1.0.6",
"version": "1.1.0",
"private": true,
"main": "lib/index.js",
"engines": { "node": ">=6.9.0 <9.0.0" },
@@ -23,7 +23,10 @@
"maxPeople": 100,
"cacheMinutes": 15,
"showPictures": true,
"showContactDetails": false
"showContactDetails": false,
"cardBackgroundColor": "",
"cardAccentColor": "",
"roundedCards": false
}
}]
}
@@ -34,6 +34,9 @@ export interface IOrganizationChartWebPartProps {
cacheMinutes: number;
showPictures: boolean;
showContactDetails: boolean;
cardBackgroundColor: string;
cardAccentColor: string;
roundedCards: boolean;
}
export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrganizationChartWebPartProps> {
@@ -61,9 +64,19 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
title: this.properties.title || strings.DefaultTitle,
showPictures: this.properties.showPictures !== false,
showContactDetails: this.properties.showContactDetails === true,
cardBackgroundColor: this.properties.cardBackgroundColor || '',
cardAccentColor: this.properties.cardAccentColor || '',
roundedCards: this.properties.roundedCards === true,
reportsLabel: strings.ReportsLabel,
collapseLabel: strings.CollapseLabel,
expandLabel: strings.ExpandLabel,
expandDetailsLabel: strings.ExpandDetailsLabel,
collapseDetailsLabel: strings.CollapseDetailsLabel,
departmentLabel: strings.DepartmentLabel,
officeLabel: strings.OfficeLabel,
emailLabel: strings.EmailLabel,
phoneLabel: strings.PhoneLabel,
profileLabel: strings.ProfileLabel,
truncatedLabel: strings.TruncatedLabel
});
const contentHost: HTMLDivElement = this.prepareSurface(data.root.profile.displayName);
@@ -104,7 +117,16 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
groupName: strings.DisplayGroupName,
groupFields: [
PropertyPaneToggle('showPictures', { label: strings.ShowPicturesFieldLabel }),
PropertyPaneToggle('showContactDetails', { label: strings.ShowContactDetailsFieldLabel })
PropertyPaneToggle('showContactDetails', { label: strings.ShowContactDetailsFieldLabel }),
PropertyPaneToggle('roundedCards', { label: strings.RoundedCardsFieldLabel }),
PropertyPaneTextField('cardBackgroundColor', {
label: strings.CardBackgroundColorFieldLabel,
description: strings.ColorFieldDescription
}),
PropertyPaneTextField('cardAccentColor', {
label: strings.CardAccentColorFieldLabel,
description: strings.ColorFieldDescription
})
]
}]
}]
@@ -5,8 +5,18 @@ export interface IOrganizationChartProps {
title: string;
showPictures: boolean;
showContactDetails: boolean;
cardBackgroundColor: string;
cardAccentColor: string;
roundedCards: boolean;
reportsLabel: string;
collapseLabel: string;
expandLabel: string;
expandDetailsLabel: string;
collapseDetailsLabel: string;
departmentLabel: string;
officeLabel: string;
emailLabel: string;
phoneLabel: string;
profileLabel: string;
truncatedLabel: string;
}
@@ -32,22 +32,30 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, .12);
color: "[theme:bodyText, default:#323130]";
display: block;
border-bottom: 2px solid "[theme:themePrimary, default:#0078d4]";
min-height: 92px;
overflow: hidden;
text-align: left;
text-decoration: none;
width: 230px;
}
.personCard:hover { border-color: "[theme:themePrimary, default:#0078d4]"; color: "[theme:bodyText, default:#323130]"; }
.personCard:focus { outline: 2px solid "[theme:focusBorder, default:#605e5c]"; outline-offset: 1px; }
.cardContent { align-items: center; display: flex; min-height: 92px; padding: 12px; position: relative; }
.photoWrap { background: "[theme:themePrimary, default:#0078d4]"; border-radius: 50%; flex: 0 0 56px; height: 56px; margin-right: 12px; overflow: hidden; position: relative; }
.personCard:hover { box-shadow: 0 2px 6px rgba(0, 0, 0, .18); color: "[theme:bodyText, default:#323130]"; }
.cardContent { align-items: center; background: transparent; border: 0; color: inherit; cursor: pointer; display: flex; font-family: inherit; min-height: 92px; padding: 12px; position: relative; text-align: left; width: 100%; }
.cardContent:hover { background: rgba(0, 0, 0, .03); }
.cardContent:focus { outline: 2px solid "[theme:focusBorder, default:#605e5c]"; outline-offset: -2px; }
.photoWrap { background: "[theme:themePrimary, default:#0078d4]"; border-radius: 50%; flex: 0 0 56px; height: 56px; margin-left: 12px; overflow: hidden; position: relative; }
.initials { color: #fff; font-size: 18px; left: 0; line-height: 56px; position: absolute; right: 0; text-align: center; }
.photo { height: 56px; object-fit: cover; position: relative; width: 56px; z-index: 1; }
.personText { display: block; min-width: 0; padding-bottom: 16px; }
.personText { display: block; flex: 1 1 auto; min-width: 0; }
.name { display: block; font-size: 15px; font-weight: 600; line-height: 20px; }
.jobTitle, .department, .email, .location { color: "[theme:neutralSecondary, default:#605e5c]"; display: block; font-size: 12px; line-height: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.jobTitle, .department, .email, .location, .phone, .reportCount { color: "[theme:neutralSecondary, default:#605e5c]"; display: block; font-size: 12px; line-height: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.email { color: "[theme:themePrimary, default:#0078d4]"; }
.reportCount { bottom: 8px; color: "[theme:neutralSecondary, default:#605e5c]"; font-size: 11px; position: absolute; right: 10px; }
.detailsChevron { bottom: 3px; color: "[theme:neutralSecondary, default:#605e5c]"; font-family: 'FabricMDL2Icons'; font-size: 9px; left: 50%; position: absolute; }
.personDetails { border-top: 1px solid "[theme:neutralLight, default:#edebe9]"; padding: 8px 10px 10px; text-align: left; }
.detailRow { margin-bottom: 5px; min-width: 0; }
.detailLabel { color: "[theme:neutralSecondary, default:#605e5c]"; display: block; font-size: 10px; line-height: 13px; }
.profileLink { color: "[theme:themePrimary, default:#0078d4]"; display: inline-block; font-size: 12px; margin-top: 3px; text-decoration: none; }
.profileLink:hover { text-decoration: underline; }
.toggle { align-items: center; background: "[theme:white, default:#ffffff]"; border: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; border-radius: 50%; bottom: -14px; color: "[theme:themePrimary, default:#0078d4]"; cursor: pointer; display: flex; height: 28px; justify-content: center; left: calc(50% - 14px); padding: 0; position: absolute; width: 28px; z-index: 2; }
.toggle:hover, .toggle:focus { background: "[theme:neutralLighter, default:#f3f2f1]"; outline: none; }
.toggleIcon { font-family: 'FabricMDL2Icons'; font-size: 12px; line-height: 1; }
@@ -57,7 +65,7 @@
.compactTree .tree li, .compactTree li { padding-left: 5px; padding-right: 5px; }
.compactTree .personCard, .compactTree .cardContent { min-height: 82px; }
.compactTree .cardContent { padding: 9px; }
.compactTree .photoWrap { flex-basis: 44px; height: 44px; margin-right: 9px; }
.compactTree .photoWrap { flex-basis: 44px; height: 44px; margin-left: 9px; }
.compactTree .initials { font-size: 15px; line-height: 44px; }
.compactTree .photo { height: 44px; width: 44px; }
.compactTree .name { font-size: 14px; line-height: 18px; }
@@ -66,13 +74,11 @@
.denseTree .tree li, .denseTree li { padding-left: 3px; padding-right: 3px; }
.denseTree .personCard, .denseTree .cardContent { min-height: 72px; }
.denseTree .cardContent { padding: 7px; }
.denseTree .photoWrap { flex-basis: 36px; height: 36px; margin-right: 7px; }
.denseTree .photoWrap { flex-basis: 36px; height: 36px; margin-left: 7px; }
.denseTree .initials { font-size: 13px; line-height: 36px; }
.denseTree .photo { height: 36px; width: 36px; }
.denseTree .personText { padding-bottom: 13px; }
.denseTree .name { font-size: 13px; line-height: 17px; }
.denseTree .jobTitle, .denseTree .department, .denseTree .email, .denseTree .location { font-size: 10px; line-height: 14px; }
.denseTree .reportCount { bottom: 6px; font-size: 10px; right: 7px; }
.denseTree .jobTitle, .denseTree .department, .denseTree .email, .denseTree .location, .denseTree .phone, .denseTree .reportCount { font-size: 10px; line-height: 14px; }
@media (max-width: 600px) {
.tree { display: block; min-width: 0; width: 100%; }
@@ -5,15 +5,17 @@ import { IOrganizationChartProps } from './IOrganizationChartProps';
export interface IOrganizationChartState {
collapsed: { [account: string]: boolean };
expandedDetails: { [account: string]: boolean };
cardWidth: number;
}
export default class OrganizationChart extends React.Component<IOrganizationChartProps, IOrganizationChartState> {
private viewportElement: HTMLDivElement;
private effectiveCardWidth: number = 230;
public constructor(props: IOrganizationChartProps) {
super(props);
this.state = { collapsed: {}, cardWidth: 230 };
this.state = { collapsed: {}, expandedDetails: {}, cardWidth: 230 };
}
public componentDidMount(): void {
@@ -30,9 +32,11 @@ export default class OrganizationChart extends React.Component<IOrganizationChar
}
public render(): React.ReactElement<IOrganizationChartProps> {
const densityClass: string = this.state.cardWidth <= 170
const effectiveCardWidth: number = this.getEffectiveCardWidth();
this.effectiveCardWidth = effectiveCardWidth;
const densityClass: string = effectiveCardWidth <= 170
? styles.denseTree
: (this.state.cardWidth <= 205 ? styles.compactTree : '');
: (effectiveCardWidth <= 205 ? styles.compactTree : '');
const treeClassName: string = styles.tree + (densityClass ? ' ' + densityClass : '');
return (
<section className={styles.organizationChart} aria-label={this.props.title}>
@@ -77,28 +81,65 @@ export default class OrganizationChart extends React.Component<IOrganizationChar
}
private renderPerson(profile: IUserProfile): JSX.Element {
const key: string = profile.accountName.toLowerCase();
const detailsExpanded: boolean = this.state.expandedDetails[key] === true;
const cardStyle: React.CSSProperties = { width: this.effectiveCardWidth };
const backgroundColor: string = this.normalizeColor(this.props.cardBackgroundColor);
const accentColor: string = this.normalizeColor(this.props.cardAccentColor);
if (backgroundColor) { cardStyle.backgroundColor = backgroundColor; }
if (accentColor) { cardStyle.borderBottomColor = accentColor; }
if (this.props.roundedCards) { cardStyle.borderRadius = '4px'; }
const content: JSX.Element = (
<span className={styles.cardContent}>
<button type='button' className={styles.cardContent} aria-expanded={detailsExpanded}
aria-label={(detailsExpanded ? this.props.collapseDetailsLabel : this.props.expandDetailsLabel) + ': ' +
profile.displayName}
onClick={(): void => this.toggleDetails(key)}>
<span className={styles.personText}>
<span className={styles.name}>{profile.displayName}</span>
{profile.title && <span className={styles.jobTitle}>{profile.title}</span>}
</span>
{this.props.showPictures && <span className={styles.photoWrap}>
<span className={styles.initials} aria-hidden='true'>{this.initials(profile.displayName)}</span>
{profile.pictureUrl && <img className={styles.photo} src={profile.pictureUrl} alt='' role='presentation'
onError={(event: React.SyntheticEvent<HTMLImageElement>): void => this.hideBrokenImage(event)} />}
</span>}
<span className={styles.personText}>
<span className={styles.name}>{profile.displayName}</span>
{profile.title && <span className={styles.jobTitle}>{profile.title}</span>}
{profile.department && <span className={styles.department}>{profile.department}</span>}
{this.props.showContactDetails && profile.email && <span className={styles.email}>{profile.email}</span>}
{this.props.showContactDetails && (profile.office || profile.location) &&
<span className={styles.location}>{profile.office || profile.location}</span>}
<span className={styles.detailsChevron} aria-hidden='true'>
{detailsExpanded ? <span>&#xE70E;</span> : <span>&#xE70D;</span>}
</span>
{profile.directReports.length > 0 &&
<span className={styles.reportCount}>{profile.directReports.length + ' ' + this.props.reportsLabel}</span>}
</span>
</button>
);
return (
<div className={styles.personCard} style={cardStyle}>
{content}
{detailsExpanded && this.renderDetails(profile)}
</div>
);
}
private renderDetails(profile: IUserProfile): JSX.Element {
return (
<div className={styles.personDetails} role='group'>
{profile.department && this.renderDetail(this.props.departmentLabel, profile.department, styles.department)}
{this.props.showContactDetails && (profile.office || profile.location) &&
this.renderDetail(this.props.officeLabel, profile.office || profile.location, styles.location)}
{this.props.showContactDetails && profile.email &&
this.renderDetail(this.props.emailLabel, profile.email, styles.email)}
{this.props.showContactDetails && (profile.workPhone || profile.cellPhone) &&
this.renderDetail(this.props.phoneLabel, profile.workPhone || profile.cellPhone, styles.phone)}
{profile.directReports.length > 0 &&
this.renderDetail(this.props.reportsLabel, String(profile.directReports.length), styles.reportCount)}
{profile.profileUrl && <a className={styles.profileLink} href={profile.profileUrl}>{this.props.profileLabel}</a>}
</div>
);
}
private renderDetail(label: string, value: string, className: string): JSX.Element {
return (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>{label}</span>
<span className={className} title={value}>{value}</span>
</div>
);
return profile.profileUrl
? <a className={styles.personCard} style={{ width: this.state.cardWidth }} href={profile.profileUrl}>{content}</a>
: <div className={styles.personCard} style={{ width: this.state.cardWidth }}>{content}</div>;
}
private onWindowResize = (): void => {
@@ -117,7 +158,7 @@ export default class OrganizationChart extends React.Component<IOrganizationChar
const maximumBreadth: number = this.getMaximumVisibleBreadth();
const horizontalSpacePerCard: number = 16;
cardWidth = Math.floor((availableWidth - (maximumBreadth * horizontalSpacePerCard)) / maximumBreadth);
cardWidth = Math.max(144, Math.min(230, cardWidth));
cardWidth = Math.max(144, Math.min(this.getBreadthCardWidth(maximumBreadth), cardWidth));
}
if (cardWidth !== this.state.cardWidth) {
@@ -125,6 +166,36 @@ export default class OrganizationChart extends React.Component<IOrganizationChar
}
}
private getEffectiveCardWidth(): number {
if (this.viewportElement && this.viewportElement.clientWidth <= 600) {
return this.state.cardWidth;
}
return Math.min(this.state.cardWidth, this.getBreadthCardWidth(this.getMaximumVisibleBreadth()));
}
private getBreadthCardWidth(maximumBreadth: number): number {
if (maximumBreadth <= 2) { return 230; }
if (maximumBreadth === 3) { return 205; }
if (maximumBreadth === 4) { return 180; }
if (maximumBreadth === 5) { return 164; }
return 144;
}
private toggleDetails(key: string): void {
const expandedDetails: { [account: string]: boolean } = {};
const existingKeys: string[] = Object.keys(this.state.expandedDetails);
for (let i: number = 0; i < existingKeys.length; i++) {
expandedDetails[existingKeys[i]] = this.state.expandedDetails[existingKeys[i]];
}
expandedDetails[key] = !expandedDetails[key];
this.setState({ expandedDetails: expandedDetails });
}
private normalizeColor(value: string): string {
const candidate: string = String(value || '').trim();
return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(candidate) ? candidate : '';
}
private getMaximumVisibleBreadth(): number {
const countsByLevel: number[] = [];
this.countVisibleNodes(this.props.data.root, 0, countsByLevel);
@@ -16,6 +16,10 @@ define([], function() {
CacheMinutesFieldLabel: 'Profilcache (Minuten, 0 = aus)',
ShowPicturesFieldLabel: 'Profilbilder anzeigen',
ShowContactDetailsFieldLabel: 'Kontaktinformationen anzeigen',
RoundedCardsFieldLabel: 'Abgerundete Karten',
CardBackgroundColorFieldLabel: 'Hintergrundfarbe der Karten',
CardAccentColorFieldLabel: 'Akzentfarbe der Karten',
ColorFieldDescription: 'Optionale Hexadezimalfarbe, zum Beispiel #ffffff.',
ConfigureMessage: 'Es konnte keine Startperson bestimmt werden.',
LoadingMessage: 'Organigramm wird geladen…',
EmptyMessage: 'Für die Startperson wurden keine Profildaten gefunden.',
@@ -24,6 +28,13 @@ define([], function() {
ReportsLabel: 'Mitarbeiter',
CollapseLabel: 'Untergeordnete Personen zuklappen',
ExpandLabel: 'Untergeordnete Personen aufklappen',
ExpandDetailsLabel: 'Personendetails anzeigen',
CollapseDetailsLabel: 'Personendetails ausblenden',
DepartmentLabel: 'Abteilung',
OfficeLabel: 'Büro / Standort',
EmailLabel: 'E-Mail',
PhoneLabel: 'Telefon',
ProfileLabel: 'SharePoint-Profil öffnen',
TruncatedLabel: 'Das Organigramm wurde durch die konfigurierte Ebenen- oder Personengrenze gekürzt.',
PeopleCountLabel: '{0} Personen'
};
@@ -16,6 +16,10 @@ define([], function() {
CacheMinutesFieldLabel: 'Profile cache (minutes, 0 = off)',
ShowPicturesFieldLabel: 'Show profile pictures',
ShowContactDetailsFieldLabel: 'Show contact information',
RoundedCardsFieldLabel: 'Rounded cards',
CardBackgroundColorFieldLabel: 'Card background color',
CardAccentColorFieldLabel: 'Card accent color',
ColorFieldDescription: 'Optional hexadecimal color, for example #ffffff.',
ConfigureMessage: 'A starting person could not be determined.',
LoadingMessage: 'Loading organization chart…',
EmptyMessage: 'No profile data was found for the starting person.',
@@ -24,6 +28,13 @@ define([], function() {
ReportsLabel: 'reports',
CollapseLabel: 'Collapse reports',
ExpandLabel: 'Expand reports',
ExpandDetailsLabel: 'Show person details',
CollapseDetailsLabel: 'Hide person details',
DepartmentLabel: 'Department',
OfficeLabel: 'Office / location',
EmailLabel: 'Email',
PhoneLabel: 'Phone',
ProfileLabel: 'Open SharePoint profile',
TruncatedLabel: 'The organization chart was limited by the configured depth or person count.',
PeopleCountLabel: '{0} people'
};
+11
View File
@@ -15,6 +15,10 @@ declare interface IOrganizationChartWebPartStrings {
CacheMinutesFieldLabel: string;
ShowPicturesFieldLabel: string;
ShowContactDetailsFieldLabel: string;
RoundedCardsFieldLabel: string;
CardBackgroundColorFieldLabel: string;
CardAccentColorFieldLabel: string;
ColorFieldDescription: string;
ConfigureMessage: string;
LoadingMessage: string;
EmptyMessage: string;
@@ -23,6 +27,13 @@ declare interface IOrganizationChartWebPartStrings {
ReportsLabel: string;
CollapseLabel: string;
ExpandLabel: string;
ExpandDetailsLabel: string;
CollapseDetailsLabel: string;
DepartmentLabel: string;
OfficeLabel: string;
EmailLabel: string;
PhoneLabel: string;
ProfileLabel: string;
TruncatedLabel: string;
PeopleCountLabel: string;
}
+9 -1
View File
@@ -47,9 +47,17 @@ assert(component.indexOf('peopleCountLabel') < 0 && component.indexOf('&#xE70D;'
component.indexOf('&#xE70E;') >= 0, 'Header count removal or modern toggle icons are missing.');
assert(styles.indexOf('.tree li:only-child { padding-top: 0; }') >= 0,
'Single-child connector spacing must remain continuous.');
assert(component.indexOf('getMaximumVisibleBreadth') >= 0 && component.indexOf('cardWidth') >= 0 &&
assert(component.indexOf('getMaximumVisibleBreadth') >= 0 && component.indexOf('getBreadthCardWidth') >= 0 &&
component.indexOf('maximumBreadth === 4') >= 0 && component.indexOf('cardWidth') >= 0 &&
styles.indexOf('.compactTree') >= 0 && styles.indexOf('.denseTree') >= 0,
'Responsive organization-chart card sizing is missing.');
assert(component.indexOf('expandedDetails') >= 0 && component.indexOf('toggleDetails') >= 0 &&
component.indexOf('renderDetails') >= 0 && component.indexOf("className={styles.cardContent}") >= 0,
'Teams-style expandable person cards are missing.');
assert(webPart.indexOf("PropertyPaneTextField('cardBackgroundColor'") >= 0 &&
webPart.indexOf("PropertyPaneTextField('cardAccentColor'") >= 0 &&
webPart.indexOf("PropertyPaneToggle('roundedCards'") >= 0,
'Configurable card design options are missing.');
assert(component.indexOf("role='presentation'") >= 0, 'Decorative profile images must be hidden from assistive technology.');
['bodyText', 'neutralSecondary', 'neutralLighter', 'themePrimary', 'neutralTertiary', 'white', 'neutralLight', 'focusBorder', 'errorText']
.forEach(function(slot) {