diff --git a/client/src/app/domain/models/comittees/committee.ts b/client/src/app/domain/models/comittees/committee.ts index 75260074c3..777273ed35 100644 --- a/client/src/app/domain/models/comittees/committee.ts +++ b/client/src/app/domain/models/comittees/committee.ts @@ -9,7 +9,7 @@ export class Committee extends BaseModel { public name!: string; public description!: string; public external_id!: string; - public parent_id!: string; + public parent_id!: Id; public child_ids!: Id[]; public meeting_ids!: Id[]; // (meeting/committee_id)[]; diff --git a/client/src/app/site/base/base-sort.service/base-sort-list.service.ts b/client/src/app/site/base/base-sort.service/base-sort-list.service.ts index 3bc36693ce..d4e3d07d13 100644 --- a/client/src/app/site/base/base-sort.service/base-sort-list.service.ts +++ b/client/src/app/site/base/base-sort.service/base-sort-list.service.ts @@ -65,6 +65,23 @@ export abstract class BaseSortListService return this.sortDefinition?.sortAscending; } + /** + * Set the additional infos regarding sorting order + * + * @param additional info, can be any JSON serializable value + */ + public set additionalInfo(additional: unknown) { + this.sortDefinition!.additionalInfo = additional; + this.updateSortDefinitions(); + } + + /** + * @returns Additional info for sorting order + */ + public get additionalInfo(): unknown { + return this.sortDefinition?.additionalInfo; + } + public get hasSortOptionSelected(): boolean { const defaultDef = this._defaultDefinitionSubject.value; const current = this.sortDefinition; @@ -192,7 +209,7 @@ export abstract class BaseSortListService .pipe(distinctUntilChanged((prev, curr) => prev?.sortProperty === curr?.sortProperty)) .subscribe(defaultDef => { if (this._isDefaultSorting && defaultDef) { - this.setSorting(defaultDef.sortProperty, defaultDef.sortAscending); + this.setSorting(defaultDef.sortProperty, defaultDef.sortAscending, defaultDef.additionalInfo); } else if (defaultDef && this.sortDefinition?.sortProperty === defaultDef?.sortProperty) { this.updateSortDefinitions(); } @@ -253,12 +270,13 @@ export abstract class BaseSortListService * @param property a sorting property of a view model * @param ascending ascending or descending */ - public setSorting(property: OsSortProperty, ascending: boolean): void { + public setSorting(property: OsSortProperty, ascending: boolean, additionalInfo?: unknown): void { if (!this.sortDefinition) { - this.sortDefinition = { sortProperty: property, sortAscending: ascending }; + this.sortDefinition = { sortProperty: property, sortAscending: ascending, additionalInfo }; } else { this.sortDefinition!.sortProperty = property; this.sortDefinition!.sortAscending = ascending; + this.sortDefinition!.additionalInfo = additionalInfo; this.updateSortDefinitions(); } this.hasLoaded.resolve(true); @@ -368,31 +386,36 @@ export abstract class BaseSortListService } private async loadDefinition(): Promise { - let [sortProperty, sortAscending]: [OsSortProperty, boolean] = await Promise.all([ + let [sortProperty, sortAscending, additionalInfo]: [OsSortProperty, boolean, any] = await Promise.all([ this.store.get>(this.calcStorageKey(`sorting_property`, this.storageKey)), - this.store.get(this.calcStorageKey(`sorting_ascending`, this.storageKey)) + this.store.get(this.calcStorageKey(`sorting_ascending`, this.storageKey)), + this.store.get(this.calcStorageKey(`sorting_additional_info`, this.storageKey)) ]); const defaultDef = await this.getDefaultDefinition(); sortAscending = sortAscending ?? defaultDef.sortAscending; sortProperty = sortProperty ?? defaultDef.sortProperty; + additionalInfo = additionalInfo ?? defaultDef.additionalInfo; this.sortDefinition = { sortAscending, - sortProperty + sortProperty, + additionalInfo }; this.updateSortDefinitions(); this.hasLoaded.resolve(true); } private async setSortingAfterMeetingChange(meetingId: Id): Promise { - let [sortProperty, sortAscending]: [OsSortProperty, boolean] = await Promise.all([ + let [sortProperty, sortAscending, additionalInfo]: [OsSortProperty, boolean, any] = await Promise.all([ this.store.get>(`sorting_property_${this.storageKey}_${meetingId}`), - this.store.get(`sorting_ascending_${this.storageKey}_${meetingId}`) + this.store.get(`sorting_ascending_${this.storageKey}_${meetingId}`), + this.store.get(`sorting_additional_info_${this.storageKey}_${meetingId}`) ]); const defaultDef = await this.getDefaultDefinition(); sortProperty = sortProperty ?? defaultDef.sortProperty; sortAscending = sortAscending ?? defaultDef.sortAscending; - this.setSorting(sortProperty, sortAscending); + additionalInfo = additionalInfo ?? defaultDef.additionalInfo; + this.setSorting(sortProperty, sortAscending, additionalInfo); } /** @@ -403,7 +426,7 @@ export abstract class BaseSortListService return Array.isArray(a) && Array.isArray(b) ? a.equals(b) : a === b; } - private compareHelperFunction(itemA: V, itemB: V, alternativeProperty: OsSortProperty): number { + protected compareHelperFunction(itemA: V, itemB: V, alternativeProperty: OsSortProperty): number { return ( this.sortItems( itemA, @@ -426,6 +449,10 @@ export abstract class BaseSortListService this.store.set(this.calcStorageKey(`sorting_property`, this.storageKey), this.sortDefinition?.sortProperty); } this.store.set(this.calcStorageKey(`sorting_ascending`, this.storageKey), this.sortDefinition?.sortAscending); + this.store.set( + this.calcStorageKey(`sorting_additional_info`, this.storageKey), + this.sortDefinition?.additionalInfo + ); } private calculateDefaultStatus(): void { diff --git a/client/src/app/site/base/base-sort.service/os-sort.ts b/client/src/app/site/base/base-sort.service/os-sort.ts index 470403eaef..a4d65d1f4a 100644 --- a/client/src/app/site/base/base-sort.service/os-sort.ts +++ b/client/src/app/site/base/base-sort.service/os-sort.ts @@ -6,6 +6,7 @@ export type SortDefinition = keyof T | OsSortingDefinition; export interface OsSortingDefinition { sortProperty: OsSortProperty; sortAscending: boolean; + additionalInfo?: unknown; } /** diff --git a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/committee-list.module.ts b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/committee-list.module.ts index 2264b9c039..a95a37ecfd 100644 --- a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/committee-list.module.ts +++ b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/committee-list.module.ts @@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { OpenSlidesTranslationModule } from 'src/app/site/modules/translations'; import { DirectivesModule } from 'src/app/ui/directives'; import { ChipComponent } from 'src/app/ui/modules/chip'; @@ -32,6 +33,7 @@ import { CommitteeListServiceModule } from './services/committee-list-service.mo OpenSlidesTranslationModule.forChild(), MatDividerModule, MatMenuModule, + MatTooltipModule, MatIconModule, MatButtonModule, DirectivesModule diff --git a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.html b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.html index 1cdc6a3bc9..0a3d41b372 100644 --- a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.html +++ b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.html @@ -22,10 +22,25 @@

{{ 'Committees' | translate }}

{{ selectedRows.length }} {{ 'selected' | translate }} + +
+
+ @if (selectedView === 'hierarchy') { + + } @else { + + } +
+
{{ 'Committees' | translate }} [sortService]="sortService" [(selectedRows)]="selectedRows" > -
+
@if (!isMultiSelect && committee.canAccess()) { } + @if (selectedView === 'hierarchy') { + @if (committee.child_ids?.length) { + + } + + + }
{{ committee.name }} diff --git a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.scss b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.scss index 43c160d8a7..3b6bb5d904 100644 --- a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.scss +++ b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.scss @@ -17,3 +17,77 @@ flex-flow: column; width: 75px; } + +.committee-hierarchy { + --committee-level-padding: 38px; + .cell-title:not(.is-child) { + &:after, + &:before { + content: ''; + display: inline-block; + height: 30%; + border: solid #ddd; + border-width: 0 0 2px 2px; + margin-right: 8px; + } + &:after { + position: absolute; + left: 0; + bottom: 0; + border-width: 0 0 0 2px; + } + } + + .cell-title.is-child { + padding-left: calc(var(--committee-level) * var(--committee-level-padding)); + + &:before, + &:after { + content: ''; + display: inline-block; + height: 30%; + border: solid #ddd; + border-width: 0 0 2px 2px; + margin-right: 8px; + } + &:after { + position: absolute; + left: calc(var(--committee-level) * var(--committee-level-padding)); + bottom: 0; + border-width: 0 0 0 2px; + } + } + + .cell-title .stretch-to-fill-parent { + left: calc(calc(var(--committee-level) * var(--committee-level-padding)) + 29px); + } + + .cell-title .toggle-button { + position: absolute; + top: 0; + bottom: 0; + left: calc(calc(var(--committee-level) * var(--committee-level-padding)) - 23px); + } + + .cell-title .hl { + display: inline-block; + width: 24px; + border: solid #ddd; + border-width: 0 0 2px 0; + margin-right: 10px; + margin-left: 10px; + } + + .cell-title.no-children { + &:after, + &:before { + height: 50%; + margin-right: 0px; + } + + .hl { + margin-left: 0px; + width: 42px; + } + } +} diff --git a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.ts b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.ts index e187bea4d0..93988e7c98 100644 --- a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.ts +++ b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/components/committee-list/committee-list.component.ts @@ -33,6 +33,17 @@ export class CommitteeListComponent extends BaseListViewComponent return this.translate.instant(`Agenda items are in process. Please wait ...`); } + private _selectedView = `list`; + + public get selectedView(): string { + return this._selectedView; + } + + public set selectedView(value) { + this._selectedView = value; + this.filterService.hierarchyFilterActive = value === 'hierarchy'; + } + public constructor( protected override translate: TranslateService, public committeeController: CommitteeControllerService, @@ -50,6 +61,15 @@ export class CommitteeListComponent extends BaseListViewComponent super.setTitle(`Committees`); this.canMultiSelect = true; this.listStorageIndex = COMMITTEE_LIST_STORAGE_INDEX; + this.subscriptions.push( + this.sortService.hierarchySort.subscribe(active => { + this.selectedView = active ? `hierarchy` : `list`; + }) + ); + } + + public onChangeView(type: string): void { + this.sortService.hierarchySort = type === `hierarchy`; } public editSingle(committee: ViewCommittee): void { diff --git a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/services/committee-list-filter.service/committee-filter.service.ts b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/services/committee-list-filter.service/committee-filter.service.ts index 7f4eae321f..19a98c9d49 100644 --- a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/services/committee-list-filter.service/committee-filter.service.ts +++ b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/services/committee-list-filter.service/committee-filter.service.ts @@ -1,5 +1,8 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { _ } from '@ngx-translate/core'; +import { map, Observable } from 'rxjs'; +import { Id } from 'src/app/domain/definitions/key-types'; +import { StorageService } from 'src/app/gateways/storage.service'; import { BaseFilterListService, OsFilter } from 'src/app/site/base/base-filter.service'; import { OrganizationTagControllerService } from 'src/app/site/pages/organization/pages/organization-tags/services/organization-tag-controller.service'; import { ActiveFiltersService } from 'src/app/site/services/active-filters.service'; @@ -13,6 +16,18 @@ import { CommitteeListServiceModule } from '../committee-list-service.module'; export class CommitteeFilterService extends BaseFilterListService { protected storageKey = `CommitteeList`; + private _hierarchyFilterActive = false; + + public get hierarchyFilterActive(): boolean { + return this._hierarchyFilterActive; + } + + public set hierarchyFilterActive(value) { + this._hierarchyFilterActive = value; + } + + private expandedCommittees: Set = new Set(); + private orgaTagFilterOptions: OsFilter = { property: `organization_tag_ids`, label: _(`Tags`), @@ -20,6 +35,8 @@ export class CommitteeFilterService extends BaseFilterListService options: [] }; + private store = inject(StorageService); + public constructor(organizationTagRepo: OrganizationTagControllerService, store: ActiveFiltersService) { super(store); this.updateFilterForRepo({ @@ -50,4 +67,48 @@ export class CommitteeFilterService extends BaseFilterListService this.orgaTagFilterOptions ]; } + + public override get outputObservable(): Observable { + return super.outputObservable.pipe( + map(output => { + if (this.hierarchyFilterActive) { + return output.filter( + el => + !el.all_parent_ids?.length || + el.all_parent_ids?.every(id => this.expandedCommittees.has(id)) + ); + } + + return output; + }) + ); + } + + public override storeActiveFilters(): void { + super.storeActiveFilters(); + this.store.set(`${this.storageKey}_expanded`, Array.from(this.expandedCommittees)); + } + + public override async initFilters(inputData: Observable): Promise { + const expanded = await this.store.get(`${this.storageKey}_expanded`); + if (expanded) { + this.expandedCommittees = new Set(expanded); + } + + await super.initFilters(inputData); + } + + public isExpanded(id: Id): boolean { + return this.expandedCommittees.has(id); + } + + public toggleExpanded(id: Id): void { + if (this.expandedCommittees.has(id)) { + this.expandedCommittees.delete(id); + } else { + this.expandedCommittees.add(id); + } + + this.storeActiveFilters(); + } } diff --git a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/services/committee-list-sort.service/committee-sort.service.ts b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/services/committee-list-sort.service/committee-sort.service.ts index 924d27f7a9..51f1cdba97 100644 --- a/client/src/app/site/pages/organization/pages/committees/pages/committee-list/services/committee-list-sort.service/committee-sort.service.ts +++ b/client/src/app/site/pages/organization/pages/committees/pages/committee-list/services/committee-list-sort.service/committee-sort.service.ts @@ -1,5 +1,7 @@ import { Injectable, ProviderToken } from '@angular/core'; import { _ } from '@ngx-translate/core'; +import { map, Observable } from 'rxjs'; +import { Id } from 'src/app/domain/definitions/key-types'; import { BaseRepository } from 'src/app/gateways/repositories/base-repository'; import { CommitteeRepositoryService } from 'src/app/gateways/repositories/committee-repository.service'; import { BaseSortListService, OsSortingOption } from 'src/app/site/base/base-sort.service'; @@ -12,6 +14,16 @@ import { ViewCommittee } from '../../../../view-models'; export class CommitteeSortService extends BaseSortListService { protected storageKey = `CommitteeList`; + public get hierarchySort(): Observable { + return this.sortingUpdatedObservable.pipe(map(sorting => (sorting?.additionalInfo as any)?.hierarchySort)); + } + + public set hierarchySort(value: boolean) { + this.additionalInfo = { + hierarchySort: value + }; + } + protected repositoryToken: ProviderToken> = CommitteeRepositoryService; private readonly staticSortOptions: OsSortingOption[] = [ @@ -23,11 +35,42 @@ export class CommitteeSortService extends BaseSortListService { public constructor() { super({ sortProperty: `name`, - sortAscending: true + sortAscending: true, + additionalInfo: { + hierarchySort: true + } }); } protected getSortOptions(): OsSortingOption[] { return this.staticSortOptions; } + + /** + * Sorts the given array according to this services sort settings and returns it. + */ + public override async sort(array: ViewCommittee[]): Promise { + const additionalInfo = (await this.getDefaultDefinition()).additionalInfo; + if ((this.additionalInfo as any)?.hierarchySort ?? (additionalInfo as any)?.hierarchySort) { + const input = [...array]; + return (await this.doHierarchySort(input, null)).flat(Infinity); + } + + return super.sort(array); + } + + private async doHierarchySort(remaining: ViewCommittee[], parentId: Id): Promise { + const result = []; + let i = remaining.length; + while (i--) { + const entry = remaining[i]; + if (entry && (entry.parent_id ?? null) === parentId) { + remaining.splice(i, 1); + result.push([entry, await this.doHierarchySort(remaining, entry.id)]); + } + } + + const alternativeProperty = (await this.getDefaultDefinition()).sortProperty; + return result.sort((a, b) => this.compareHelperFunction(a[0], b[0], alternativeProperty)); + } }