diff --git a/src/app/features/admin-institutions/admin-institutions.component.html b/src/app/features/admin-institutions/admin-institutions.component.html new file mode 100644 index 000000000..6d56b87ed --- /dev/null +++ b/src/app/features/admin-institutions/admin-institutions.component.html @@ -0,0 +1,33 @@ +
+ @if (isInstitutionLoading()) { +
+ +
+ } @else { +
+
+ + +

{{ institution().name }}

+
+
+ + + +
+ +
+ } +
diff --git a/src/app/features/admin-institutions/admin-institutions.component.scss b/src/app/features/admin-institutions/admin-institutions.component.scss new file mode 100644 index 000000000..da0c027b5 --- /dev/null +++ b/src/app/features/admin-institutions/admin-institutions.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; +} diff --git a/src/app/features/admin-institutions/admin-institutions.component.spec.ts b/src/app/features/admin-institutions/admin-institutions.component.spec.ts new file mode 100644 index 000000000..458f9580e --- /dev/null +++ b/src/app/features/admin-institutions/admin-institutions.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminInstitutionsComponent } from './admin-institutions.component'; + +describe('AdminInstitutionsComponent', () => { + let component: AdminInstitutionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdminInstitutionsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AdminInstitutionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/admin-institutions/admin-institutions.component.ts b/src/app/features/admin-institutions/admin-institutions.component.ts new file mode 100644 index 000000000..d17c7ffc1 --- /dev/null +++ b/src/app/features/admin-institutions/admin-institutions.component.ts @@ -0,0 +1,55 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslateModule } from '@ngx-translate/core'; + +import { TabsModule } from 'primeng/tabs'; + +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; + +import { resourceTabOptions } from '@osf/features/admin-institutions/constants/resource-tab-option.constant'; +import { LoadingSpinnerComponent } from '@shared/components'; +import { FetchInstitutionById, InstitutionsSearchSelectors } from '@shared/stores'; + +@Component({ + selector: 'osf-admin-institutions', + imports: [TabsModule, TranslateModule, RouterOutlet, NgOptimizedImage, LoadingSpinnerComponent], + templateUrl: './admin-institutions.component.html', + styleUrl: './admin-institutions.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminInstitutionsComponent implements OnInit { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + institution = select(InstitutionsSearchSelectors.getInstitution); + isInstitutionLoading = select(InstitutionsSearchSelectors.getInstitutionLoading); + + private readonly actions = createDispatchMap({ + fetchInstitution: FetchInstitutionById, + }); + + selectedTab = 'summary'; + + resourceTabOptions = resourceTabOptions; + + ngOnInit() { + const institutionId = this.route.snapshot.params['institution-id']; + + if (institutionId) { + this.actions.fetchInstitution(institutionId); + } + + this.selectedTab = this.route.snapshot.firstChild?.routeConfig?.path || 'summary'; + } + + onTabChange(selectedValue: string | number) { + const value = String(selectedValue); + this.selectedTab = value; + const selectedTab = this.resourceTabOptions.find((tab) => tab.value === value); + if (selectedTab) { + this.router.navigate([selectedTab.value], { relativeTo: this.route }); + } + } +} diff --git a/src/app/features/admin-institutions/constants/index.ts b/src/app/features/admin-institutions/constants/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/constants/resource-tab-option.constant.ts b/src/app/features/admin-institutions/constants/resource-tab-option.constant.ts new file mode 100644 index 000000000..6e9799ac0 --- /dev/null +++ b/src/app/features/admin-institutions/constants/resource-tab-option.constant.ts @@ -0,0 +1,9 @@ +import { CustomOption } from '@shared/models'; + +export const resourceTabOptions: CustomOption[] = [ + { label: 'adminInstitutions.summary.title', value: 'summary' }, + { label: 'common.search.tabs.users', value: 'users' }, + { label: 'common.search.tabs.projects', value: 'projects' }, + { label: 'common.search.tabs.registrations', value: 'registrations' }, + { label: 'common.search.tabs.preprints', value: 'preprints' }, +]; diff --git a/src/app/features/admin-institutions/mappers/index.ts b/src/app/features/admin-institutions/mappers/index.ts new file mode 100644 index 000000000..5c7fed63d --- /dev/null +++ b/src/app/features/admin-institutions/mappers/index.ts @@ -0,0 +1,2 @@ +export { mapInstitutionDepartment, mapInstitutionDepartments } from './institution-departments.mapper'; +export { mapInstitutionSummaryMetrics } from './institution-summary-metrics.mapper'; diff --git a/src/app/features/admin-institutions/mappers/institution-departments.mapper.ts b/src/app/features/admin-institutions/mappers/institution-departments.mapper.ts new file mode 100644 index 000000000..22b0f07df --- /dev/null +++ b/src/app/features/admin-institutions/mappers/institution-departments.mapper.ts @@ -0,0 +1,23 @@ +import { + InstitutionDepartment, + InstitutionDepartmentDataJsonApi, + InstitutionDepartmentsJsonApi, +} from '@osf/features/admin-institutions/models'; + +export function mapInstitutionDepartments(jsonApiData: InstitutionDepartmentsJsonApi): InstitutionDepartment[] { + return jsonApiData.data.map((department: InstitutionDepartmentDataJsonApi) => ({ + id: department.id, + name: department.attributes.name, + numberOfUsers: department.attributes.number_of_users, + selfLink: department.links.self, + })); +} + +export function mapInstitutionDepartment(department: InstitutionDepartmentDataJsonApi): InstitutionDepartment { + return { + id: department.id, + name: department.attributes.name, + numberOfUsers: department.attributes.number_of_users, + selfLink: department.links.self, + }; +} diff --git a/src/app/features/admin-institutions/mappers/institution-summary-index.mapper.ts b/src/app/features/admin-institutions/mappers/institution-summary-index.mapper.ts new file mode 100644 index 000000000..14c55de85 --- /dev/null +++ b/src/app/features/admin-institutions/mappers/institution-summary-index.mapper.ts @@ -0,0 +1,40 @@ +import { + InstitutionIndexCardFilterJsonApi, + InstitutionIndexValueSearchIncludedJsonApi, + InstitutionSearchFilter, + InstitutionSearchResultCountJsonApi, +} from '@osf/features/admin-institutions/models'; + +export function mapIndexCardResults(included: InstitutionIndexValueSearchIncludedJsonApi[]): InstitutionSearchFilter[] { + const indexCardMap = included.reduce( + (acc, item) => { + if (item.type === 'index-card') { + acc[item.id] = { + id: item.id, + label: + (item as InstitutionIndexCardFilterJsonApi)?.attributes?.resourceMetadata?.displayLabel?.[0]?.['@value'] || + (item as InstitutionIndexCardFilterJsonApi)?.attributes?.resourceMetadata?.name?.[0]?.['@value'] || + item.id, + }; + } + return acc; + }, + {} as Record + ); + + return included.reduce((result, item) => { + if (item.type === 'search-result') { + const indexCardId = (item as InstitutionSearchResultCountJsonApi).relationships?.indexCard?.data?.id; + const count = (item as InstitutionSearchResultCountJsonApi).attributes?.cardSearchResultCount ?? 0; + const indexCard = indexCardMap[indexCardId]; + if (indexCard) { + result.push({ + id: indexCard.id, + label: indexCard.label, + value: count, + } as InstitutionSearchFilter); + } + } + return result; + }, [] as InstitutionSearchFilter[]); +} diff --git a/src/app/features/admin-institutions/mappers/institution-summary-metrics.mapper.ts b/src/app/features/admin-institutions/mappers/institution-summary-metrics.mapper.ts new file mode 100644 index 000000000..c4416f7a8 --- /dev/null +++ b/src/app/features/admin-institutions/mappers/institution-summary-metrics.mapper.ts @@ -0,0 +1,22 @@ +import { + InstitutionSummaryMetrics, + InstitutionSummaryMetricsAttributesJsonApi, +} from '@osf/features/admin-institutions/models'; + +export function mapInstitutionSummaryMetrics( + attributes: InstitutionSummaryMetricsAttributesJsonApi +): InstitutionSummaryMetrics { + return { + reportYearmonth: attributes.report_yearmonth, + userCount: attributes.user_count, + publicProjectCount: attributes.public_project_count, + privateProjectCount: attributes.private_project_count, + publicRegistrationCount: attributes.public_registration_count, + embargoedRegistrationCount: attributes.embargoed_registration_count, + publishedPreprintCount: attributes.published_preprint_count, + publicFileCount: attributes.public_file_count, + storageByteCount: attributes.storage_byte_count, + monthlyLoggedInUserCount: attributes.monthly_logged_in_user_count, + monthlyActiveUserCount: attributes.monthly_active_user_count, + }; +} diff --git a/src/app/features/admin-institutions/models/index.ts b/src/app/features/admin-institutions/models/index.ts new file mode 100644 index 000000000..927d91012 --- /dev/null +++ b/src/app/features/admin-institutions/models/index.ts @@ -0,0 +1,6 @@ +export * from './institution-department.model'; +export * from './institution-departments-json-api.model'; +export * from './institution-index-value-search-json-api.model'; +export * from './institution-search-filter.model'; +export * from './institution-summary-metric.model'; +export * from './institution-summary-metrics-json-api.model'; diff --git a/src/app/features/admin-institutions/models/institution-department.model.ts b/src/app/features/admin-institutions/models/institution-department.model.ts new file mode 100644 index 000000000..f0341bd1d --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-department.model.ts @@ -0,0 +1,6 @@ +export interface InstitutionDepartment { + id: string; + name: string; + numberOfUsers: number; + selfLink: string; +} diff --git a/src/app/features/admin-institutions/models/institution-departments-json-api.model.ts b/src/app/features/admin-institutions/models/institution-departments-json-api.model.ts new file mode 100644 index 000000000..b44db76b7 --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-departments-json-api.model.ts @@ -0,0 +1,19 @@ +export interface InstitutionDepartmentAttributesJsonApi { + name: string; + number_of_users: number; +} + +export interface InstitutionDepartmentLinksJsonApi { + self: string; +} + +export interface InstitutionDepartmentDataJsonApi { + id: string; + type: 'institution-departments'; + attributes: InstitutionDepartmentAttributesJsonApi; + links: InstitutionDepartmentLinksJsonApi; +} + +export interface InstitutionDepartmentsJsonApi { + data: InstitutionDepartmentDataJsonApi[]; +} diff --git a/src/app/features/admin-institutions/models/institution-index-value-search-json-api.model.ts b/src/app/features/admin-institutions/models/institution-index-value-search-json-api.model.ts new file mode 100644 index 000000000..c056e112a --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-index-value-search-json-api.model.ts @@ -0,0 +1,40 @@ +import { JsonApiResponse } from '@core/models'; + +export interface InstitutionSearchResultCountJsonApi { + attributes: { + cardSearchResultCount: number; + }; + id: string; + type: string; + relationships: { + indexCard: { + data: { + id: string; + type: string; + }; + }; + }; +} + +export interface InstitutionIndexCardFilterJsonApi { + attributes: { + resourceIdentifier: string[]; + resourceMetadata: { + displayLabel: { '@value': string }[]; + '@id': string; + name: { '@value': string }[]; + }; + }; + id: string; + type: string; +} + +export type InstitutionIndexValueSearchIncludedJsonApi = + | InstitutionSearchResultCountJsonApi + | InstitutionIndexCardFilterJsonApi; + +export interface InstitutionIndexValueSearchJsonApi + extends JsonApiResponse { + data: null; + included: InstitutionIndexValueSearchIncludedJsonApi[]; +} diff --git a/src/app/features/admin-institutions/models/institution-search-filter.model.ts b/src/app/features/admin-institutions/models/institution-search-filter.model.ts new file mode 100644 index 000000000..8eeec21c4 --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-search-filter.model.ts @@ -0,0 +1,6 @@ +export interface InstitutionSearchFilter { + id: string; + label: string; + value: string | number; + count?: number; +} diff --git a/src/app/features/admin-institutions/models/institution-summary-metric.model.ts b/src/app/features/admin-institutions/models/institution-summary-metric.model.ts new file mode 100644 index 000000000..5e8af3486 --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-summary-metric.model.ts @@ -0,0 +1,13 @@ +export interface InstitutionSummaryMetrics { + reportYearmonth: string; + userCount: number; + publicProjectCount: number; + privateProjectCount: number; + publicRegistrationCount: number; + embargoedRegistrationCount: number; + publishedPreprintCount: number; + publicFileCount: number; + storageByteCount: number; + monthlyLoggedInUserCount: number; + monthlyActiveUserCount: number; +} diff --git a/src/app/features/admin-institutions/models/institution-summary-metrics-json-api.model.ts b/src/app/features/admin-institutions/models/institution-summary-metrics-json-api.model.ts new file mode 100644 index 000000000..c418e98ac --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-summary-metrics-json-api.model.ts @@ -0,0 +1,46 @@ +export interface InstitutionSummaryMetricsAttributesJsonApi { + report_yearmonth: string; + user_count: number; + public_project_count: number; + private_project_count: number; + public_registration_count: number; + embargoed_registration_count: number; + published_preprint_count: number; + public_file_count: number; + storage_byte_count: number; + monthly_logged_in_user_count: number; + monthly_active_user_count: number; +} + +export interface InstitutionSummaryMetricsRelationshipsJsonApi { + user: { + data: null; + }; + institution: { + links: { + related: { + href: string; + meta: Record; + }; + }; + data: { + id: string; + type: 'institutions'; + }; + }; +} + +export interface InstitutionSummaryMetricsDataJsonApi { + id: string; + type: 'institution-summary-metrics'; + attributes: InstitutionSummaryMetricsAttributesJsonApi; + relationships: InstitutionSummaryMetricsRelationshipsJsonApi; + links: Record; +} + +export interface InstitutionSummaryMetricsJsonApi { + data: InstitutionSummaryMetricsDataJsonApi; + meta: { + version: string; + }; +} diff --git a/src/app/features/admin-institutions/pages/index.ts b/src/app/features/admin-institutions/pages/index.ts new file mode 100644 index 000000000..c5679acff --- /dev/null +++ b/src/app/features/admin-institutions/pages/index.ts @@ -0,0 +1,5 @@ +export { InstitutionsPreprintsComponent } from './institutions-preprints/institutions-preprints.component'; +export { InstitutionsProjectsComponent } from './institutions-projects/institutions-projects.component'; +export { InstitutionsRegistrationsComponent } from './institutions-registrations/institutions-registrations.component'; +export { InstitutionsSummaryComponent } from './institutions-summary/institutions-summary.component'; +export { InstitutionsUsersComponent } from './institutions-users/institutions-users.component'; diff --git a/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.html b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.html new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.scss b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.spec.ts b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.spec.ts new file mode 100644 index 000000000..ff255377d --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionsPreprintsComponent } from './institutions-preprints.component'; + +describe('InstitutionsPreprintsComponent', () => { + let component: InstitutionsPreprintsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionsPreprintsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionsPreprintsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.ts b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.ts new file mode 100644 index 000000000..493f5ce8b --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'osf-institutions-preprints', + imports: [], + templateUrl: './institutions-preprints.component.html', + styleUrl: './institutions-preprints.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsPreprintsComponent {} diff --git a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.html b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.html new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.scss b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.spec.ts b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.spec.ts new file mode 100644 index 000000000..082911061 --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionsProjectsComponent } from './institutions-projects.component'; + +describe('InstitutionsProjectsComponent', () => { + let component: InstitutionsProjectsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionsProjectsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionsProjectsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts new file mode 100644 index 000000000..066666964 --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'osf-institutions-projects', + imports: [], + templateUrl: './institutions-projects.component.html', + styleUrl: './institutions-projects.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsProjectsComponent {} diff --git a/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.html b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.html new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.scss b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.spec.ts b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.spec.ts new file mode 100644 index 000000000..12db9ff06 --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionsRegistrationsComponent } from './institutions-registrations.component'; + +describe('InstitutionsRegistrationsComponent', () => { + let component: InstitutionsRegistrationsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionsRegistrationsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionsRegistrationsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.ts b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.ts new file mode 100644 index 000000000..5dbe432ab --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'osf-institutions-registrations', + imports: [], + templateUrl: './institutions-registrations.component.html', + styleUrl: './institutions-registrations.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsRegistrationsComponent {} diff --git a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.html b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.html new file mode 100644 index 000000000..5d2f02e8c --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.html @@ -0,0 +1,89 @@ +@if (summaryMetricsLoading()) { +
+ +
+} @else { +
+
+ @for (item of statisticsData; track $index) { + + } +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+} diff --git a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.scss b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.scss new file mode 100644 index 000000000..f865d569d --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.scss @@ -0,0 +1,15 @@ +.width-25 { + width: calc(25% - 1.5rem); + + @media (max-width: 576px) { + width: calc(50% - 0.5rem); + } +} + +.width-33 { + width: calc(33% - 1rem); + + @media (max-width: 576px) { + width: 100%; + } +} diff --git a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.spec.ts b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.spec.ts new file mode 100644 index 000000000..4a2b17100 --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionsSummaryComponent } from './institutions-summary.component'; + +describe('InstitutionsSummaryComponent', () => { + let component: InstitutionsSummaryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionsSummaryComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionsSummaryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts new file mode 100644 index 000000000..3b28b29ff --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts @@ -0,0 +1,195 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { ChangeDetectionStrategy, Component, effect, inject, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { BarChartComponent, LoadingSpinnerComponent, StatisticCardComponent } from '@shared/components'; +import { DoughnutChartComponent } from '@shared/components/doughnut-chart/doughnut-chart.component'; +import { DatasetInput, SelectOption } from '@shared/models'; + +import { + FetchHasOsfAddonSearch, + FetchInstitutionDepartments, + FetchInstitutionSearchResults, + FetchInstitutionSummaryMetrics, + FetchStorageRegionSearch, + InstitutionsAdminSelectors, +} from '../../store'; + +@Component({ + selector: 'osf-institutions-summary', + imports: [StatisticCardComponent, LoadingSpinnerComponent, DoughnutChartComponent, BarChartComponent, TranslatePipe], + templateUrl: './institutions-summary.component.html', + styleUrl: './institutions-summary.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsSummaryComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly translateService = inject(TranslateService); + + statisticsData: SelectOption[] = []; + + departments = select(InstitutionsAdminSelectors.getDepartments); + departmentsLoading = select(InstitutionsAdminSelectors.getDepartmentsLoading); + + summaryMetrics = select(InstitutionsAdminSelectors.getSummaryMetrics); + summaryMetricsLoading = select(InstitutionsAdminSelectors.getSummaryMetricsLoading); + + hasOsfAddonSearch = select(InstitutionsAdminSelectors.getHasOsfAddonSearch); + hasOsfAddonSearchLoading = select(InstitutionsAdminSelectors.getHasOsfAddonSearchLoading); + + storageRegionSearch = select(InstitutionsAdminSelectors.getStorageRegionSearch); + storageRegionSearchLoading = select(InstitutionsAdminSelectors.getStorageRegionSearchLoading); + + rightsSearch = select(InstitutionsAdminSelectors.getSearchResults); + rightsLoading = select(InstitutionsAdminSelectors.getSearchResultsLoading); + + protected departmentLabels: string[] = []; + protected departmentDataset: DatasetInput[] = []; + + protected projectsLabels: string[] = []; + protected projectDataset: DatasetInput[] = []; + + protected registrationsLabels: string[] = []; + protected registrationsDataset: DatasetInput[] = []; + + protected osfProjectsLabels: string[] = []; + protected osfProjectsDataset: DatasetInput[] = []; + + protected storageLabels: string[] = []; + protected storageDataset: DatasetInput[] = []; + + protected licenceLabels: string[] = []; + protected licenceDataset: DatasetInput[] = []; + + protected addonLabels: string[] = []; + protected addonDataset: DatasetInput[] = []; + + private readonly actions = createDispatchMap({ + fetchDepartments: FetchInstitutionDepartments, + fetchSummaryMetrics: FetchInstitutionSummaryMetrics, + fetchHasOsfAddonSearch: FetchHasOsfAddonSearch, + fetchStorageRegionSearch: FetchStorageRegionSearch, + fetchSearchResults: FetchInstitutionSearchResults, + }); + + constructor() { + effect(() => { + this.setStatisticSummaryData(); + this.setChartData(); + }); + } + + ngOnInit(): void { + const institutionId = this.route.parent?.snapshot.params['institution-id']; + + if (institutionId) { + this.actions.fetchSearchResults(institutionId, 'rights'); + this.actions.fetchDepartments(institutionId); + this.actions.fetchSummaryMetrics(institutionId); + this.actions.fetchHasOsfAddonSearch(institutionId); + this.actions.fetchStorageRegionSearch(institutionId); + } + } + + private setStatisticSummaryData(): void { + const summary = this.summaryMetrics(); + + if (summary) { + this.statisticsData = [ + { + label: 'adminInstitutions.summary.totalUsers', + value: summary.userCount, + }, + { + label: 'adminInstitutions.summary.totalMonthlyLoggedInUsers', + value: summary.monthlyLoggedInUserCount, + }, + { + label: 'adminInstitutions.summary.totalMonthlyActiveUsers', + value: summary.monthlyActiveUserCount, + }, + { + label: 'adminInstitutions.summary.osfPublicAndPrivateProjects', + value: summary.publicProjectCount + summary.privateProjectCount, + }, + { + label: 'adminInstitutions.summary.osfPublicAndEmbargoedRegistrations', + value: summary.publicRegistrationCount + summary.embargoedRegistrationCount, + }, + { + label: 'adminInstitutions.summary.osfPreprints', + value: summary.publishedPreprintCount, + }, + { + label: 'adminInstitutions.summary.totalPublicFileCount', + value: summary.publicFileCount, + }, + { + label: 'adminInstitutions.summary.totalStorageInGb', + value: this.convertBytesToGB(summary.storageByteCount), + }, + ]; + } + } + + private setChartData(): void { + const departments = this.departments(); + const summary = this.summaryMetrics(); + const storage = this.storageRegionSearch(); + const licenses = this.rightsSearch(); + const addons = this.hasOsfAddonSearch(); + + this.departmentLabels = departments.map((item) => item.name || ''); + this.departmentDataset = [{ label: '', data: departments.map((item) => item.numberOfUsers) }]; + + this.projectsLabels = ['resourceCard.labels.publicProjects', 'adminInstitutions.summary.privateProjects'].map( + (el) => this.translateService.instant(el) + ); + this.projectDataset = [{ label: '', data: [summary.publicProjectCount, summary.privateProjectCount] }]; + + this.registrationsLabels = [ + 'resourceCard.labels.publicRegistrations', + 'adminInstitutions.summary.embargoedRegistrations', + ].map((el) => this.translateService.instant(el)); + this.registrationsDataset = [ + { label: '', data: [summary.publicRegistrationCount, summary.embargoedRegistrationCount] }, + ]; + + this.osfProjectsLabels = [ + 'resourceCard.labels.publicRegistrations', + 'adminInstitutions.summary.embargoedRegistrations', + 'resourceCard.labels.publicProjects', + 'adminInstitutions.summary.privateProjects', + 'common.search.tabs.preprints', + ].map((el) => this.translateService.instant(el)); + this.osfProjectsDataset = [ + { + label: '', + data: [ + summary.publicRegistrationCount, + summary.embargoedRegistrationCount, + summary.publicProjectCount, + summary.privateProjectCount, + summary.publishedPreprintCount, + ], + }, + ]; + + this.storageLabels = storage.map((result) => result.label); + this.storageDataset = [{ label: '', data: storage.map((result) => +result.value) }]; + + this.licenceLabels = licenses.map((result) => result.label); + this.licenceDataset = [{ label: '', data: licenses.map((result) => +result.value) }]; + + this.addonLabels = addons.map((result) => result.label); + this.addonDataset = [{ label: '', data: addons.map((result) => +result.value) }]; + } + + private convertBytesToGB(bytes: number): string { + const gb = bytes / (1024 * 1024 * 1024); + return gb.toFixed(1); + } +} diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.html b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.html new file mode 100644 index 000000000..d6165891c --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.html @@ -0,0 +1,4 @@ +
+

Institution Users

+

Users content will be implemented here...

+
diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.spec.ts b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.spec.ts new file mode 100644 index 000000000..f36d30cc3 --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionsUsersComponent } from './institutions-users.component'; + +describe('InstitutionsUsersComponent', () => { + let component: InstitutionsUsersComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionsUsersComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionsUsersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts new file mode 100644 index 000000000..ba4465712 --- /dev/null +++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'osf-institutions-users', + imports: [], + templateUrl: './institutions-users.component.html', + styleUrl: './institutions-users.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsUsersComponent {} diff --git a/src/app/features/admin-institutions/routes.ts b/src/app/features/admin-institutions/routes.ts new file mode 100644 index 000000000..9d697be2e --- /dev/null +++ b/src/app/features/admin-institutions/routes.ts @@ -0,0 +1,50 @@ +import { provideStates } from '@ngxs/store'; + +import { Routes } from '@angular/router'; + +import { + InstitutionsPreprintsComponent, + InstitutionsProjectsComponent, + InstitutionsRegistrationsComponent, + InstitutionsSummaryComponent, + InstitutionsUsersComponent, +} from '@osf/features/admin-institutions/pages'; + +import { AdminInstitutionsComponent } from './admin-institutions.component'; + +import { InstitutionsAdminState } from 'src/app/features/admin-institutions/store'; + +export const routes: Routes = [ + { + path: '', + component: AdminInstitutionsComponent, + providers: [provideStates([InstitutionsAdminState])], + children: [ + { + path: '', + redirectTo: 'summary', + pathMatch: 'full', + }, + { + path: 'summary', + component: InstitutionsSummaryComponent, + }, + { + path: 'users', + component: InstitutionsUsersComponent, + }, + { + path: 'projects', + component: InstitutionsProjectsComponent, + }, + { + path: 'registrations', + component: InstitutionsRegistrationsComponent, + }, + { + path: 'preprints', + component: InstitutionsPreprintsComponent, + }, + ], + }, +]; diff --git a/src/app/features/admin-institutions/services/index.ts b/src/app/features/admin-institutions/services/index.ts new file mode 100644 index 000000000..6febec8a5 --- /dev/null +++ b/src/app/features/admin-institutions/services/index.ts @@ -0,0 +1 @@ +export * from './institutions-admin.service'; diff --git a/src/app/features/admin-institutions/services/institutions-admin.service.ts b/src/app/features/admin-institutions/services/institutions-admin.service.ts new file mode 100644 index 000000000..deb219531 --- /dev/null +++ b/src/app/features/admin-institutions/services/institutions-admin.service.ts @@ -0,0 +1,70 @@ +import { catchError, map, Observable, of } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@core/services'; +import { mapIndexCardResults } from '@osf/features/admin-institutions/mappers/institution-summary-index.mapper'; +import { departmens, summaryMetrics } from '@osf/features/admin-institutions/services/mock'; + +import { environment } from '../../../../environments/environment'; +import { + InstitutionDepartment, + InstitutionDepartmentsJsonApi, + InstitutionIndexValueSearchJsonApi, + InstitutionSearchFilter, + InstitutionSummaryMetrics, + InstitutionSummaryMetricsJsonApi, +} from '../models'; + +import { mapInstitutionDepartments, mapInstitutionSummaryMetrics } from 'src/app/features/admin-institutions/mappers'; + +@Injectable({ + providedIn: 'root', +}) +export class InstitutionsAdminService { + hardcodedUrl = 'https://api.test.osf.io/v2'; + + private jsonApiService = inject(JsonApiService); + + fetchDepartments(institutionId: string): Observable { + return this.jsonApiService + .get(`${this.hardcodedUrl}/institutions/${institutionId}/metrics/departments/`) + .pipe( + //TODO: remove mock data + catchError(() => { + return of(departmens as InstitutionDepartmentsJsonApi); + }), + map((res) => mapInstitutionDepartments(res)) + ); + } + + fetchSummary(institutionId: string): Observable { + return this.jsonApiService + .get(`${this.hardcodedUrl}/institutions/${institutionId}/metrics/summary/`) + .pipe( + //TODO: remove mock data + catchError(() => { + return of(summaryMetrics as InstitutionSummaryMetricsJsonApi); + }), + map((result) => mapInstitutionSummaryMetrics(result.data.attributes)) + ); + } + + fetchIndexValueSearch( + institutionId: string, + valueSearchPropertyPath: string, + additionalParams?: Record + ): Observable { + const params: Record = { + //TODO: change here https://test.osf.io to current environment + 'cardSearchFilter[affiliation]': `https://ror.org/05d5mza29,https://test.osf.io/institutions/${institutionId}/`, + valueSearchPropertyPath, + 'page[size]': '10', + ...additionalParams, + }; + + return this.jsonApiService + .get(`${environment.shareDomainUrl}/index-value-search`, params) + .pipe(map((response) => mapIndexCardResults(response?.included))); + } +} diff --git a/src/app/features/admin-institutions/services/mock.ts b/src/app/features/admin-institutions/services/mock.ts new file mode 100644 index 000000000..1538c7dc7 --- /dev/null +++ b/src/app/features/admin-institutions/services/mock.ts @@ -0,0 +1,90 @@ +export const departmens = { + data: [ + { + id: 'cos-N/A', + type: 'institution-departments', + attributes: { + name: 'N/A', + number_of_users: 159, + }, + links: { + self: 'https://api.test.osf.io/v2/institutions/cos/metrics/departments/', + }, + }, + { + id: 'cos-', + type: 'institution-departments', + attributes: { + name: '', + number_of_users: 9, + }, + links: { + self: 'https://api.test.osf.io/v2/institutions/cos/metrics/departments/', + }, + }, + { + id: 'cos-QA', + type: 'institution-departments', + attributes: { + name: 'QA', + number_of_users: 5, + }, + links: { + self: 'https://api.test.osf.io/v2/institutions/cos/metrics/departments/', + }, + }, + ], + meta: { + total: 3, + per_page: 10, + version: '2.20', + }, + links: { + self: 'https://api.test.osf.io/v2/institutions/cos/metrics/departments/', + first: null, + last: null, + prev: null, + next: null, + }, +}; + +export const summaryMetrics = { + data: { + id: 'cos', + type: 'institution-summary-metrics', + attributes: { + report_yearmonth: '2025-06', + user_count: 173, + public_project_count: 174, + private_project_count: 839, + public_registration_count: 360, + embargoed_registration_count: 76, + published_preprint_count: 1464, + public_file_count: 7088, + storage_byte_count: 12365253682, + monthly_logged_in_user_count: 14, + monthly_active_user_count: 15, + }, + relationships: { + user: { + data: null, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + meta: { + version: '2.20', + }, +}; diff --git a/src/app/features/admin-institutions/store/index.ts b/src/app/features/admin-institutions/store/index.ts new file mode 100644 index 000000000..9c942fc07 --- /dev/null +++ b/src/app/features/admin-institutions/store/index.ts @@ -0,0 +1,4 @@ +export * from './institutions-admin.actions'; +export * from './institutions-admin.model'; +export * from './institutions-admin.selectors'; +export * from './institutions-admin.state'; diff --git a/src/app/features/admin-institutions/store/institutions-admin.actions.ts b/src/app/features/admin-institutions/store/institutions-admin.actions.ts new file mode 100644 index 000000000..55cf51fb6 --- /dev/null +++ b/src/app/features/admin-institutions/store/institutions-admin.actions.ts @@ -0,0 +1,37 @@ +export class FetchInstitutionDepartments { + static readonly type = '[InstitutionsAdmin] Fetch Institution Departments'; + constructor(public institutionId: string) {} +} + +export class FetchInstitutionSummaryMetrics { + static readonly type = '[InstitutionsAdmin] Fetch Institution Summary Metrics'; + constructor(public institutionId: string) {} +} + +export class FetchInstitutionSearchResults { + static readonly type = '[InstitutionsAdmin] Fetch Institution Search Results'; + constructor( + public institutionId: string, + public valueSearchPropertyPath: string, + public additionalParams?: Record + ) {} +} + +export class FetchHasOsfAddonSearch { + static readonly type = '[InstitutionsAdmin] Fetch Has OSF Addon Search'; + constructor(public institutionId: string) {} +} + +export class FetchStorageRegionSearch { + static readonly type = '[InstitutionsAdmin] Fetch Storage Region Search'; + constructor(public institutionId: string) {} +} + +export class SetSelectedInstitutionId { + static readonly type = '[InstitutionsAdmin] Set Selected Institution Id'; + constructor(public institutionId: string) {} +} + +export class ClearInstitutionsAdminData { + static readonly type = '[InstitutionsAdmin] Clear Data'; +} diff --git a/src/app/features/admin-institutions/store/institutions-admin.model.ts b/src/app/features/admin-institutions/store/institutions-admin.model.ts new file mode 100644 index 000000000..5c30eb2dd --- /dev/null +++ b/src/app/features/admin-institutions/store/institutions-admin.model.ts @@ -0,0 +1,13 @@ +import { AsyncStateModel } from '@shared/models'; + +import { InstitutionDepartment, InstitutionSearchFilter, InstitutionSummaryMetrics } from '../models'; + +export interface InstitutionsAdminModel { + departments: AsyncStateModel; + summaryMetrics: AsyncStateModel; + hasOsfAddonSearch: AsyncStateModel; + storageRegionSearch: AsyncStateModel; + searchResults: AsyncStateModel; + selectedInstitutionId: string | null; + currentSearchPropertyPath: string | null; +} diff --git a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts new file mode 100644 index 000000000..89a4558c4 --- /dev/null +++ b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts @@ -0,0 +1,93 @@ +import { Selector } from '@ngxs/store'; + +import { InstitutionDepartment, InstitutionSearchFilter, InstitutionSummaryMetrics } from '../models'; + +import { InstitutionsAdminModel } from './institutions-admin.model'; +import { InstitutionsAdminState } from './institutions-admin.state'; + +export class InstitutionsAdminSelectors { + @Selector([InstitutionsAdminState]) + static getDepartments(state: InstitutionsAdminModel): InstitutionDepartment[] { + return state.departments.data; + } + + @Selector([InstitutionsAdminState]) + static getDepartmentsLoading(state: InstitutionsAdminModel): boolean { + return state.departments.isLoading; + } + + @Selector([InstitutionsAdminState]) + static getDepartmentsError(state: InstitutionsAdminModel): string | null { + return state.departments.error; + } + + @Selector([InstitutionsAdminState]) + static getSummaryMetrics(state: InstitutionsAdminModel): InstitutionSummaryMetrics { + return state.summaryMetrics.data; + } + + @Selector([InstitutionsAdminState]) + static getSummaryMetricsLoading(state: InstitutionsAdminModel): boolean { + return state.summaryMetrics.isLoading; + } + + @Selector([InstitutionsAdminState]) + static getSummaryMetricsError(state: InstitutionsAdminModel): string | null { + return state.summaryMetrics.error; + } + + @Selector([InstitutionsAdminState]) + static getHasOsfAddonSearch(state: InstitutionsAdminModel): InstitutionSearchFilter[] { + return state.hasOsfAddonSearch.data; + } + + @Selector([InstitutionsAdminState]) + static getHasOsfAddonSearchLoading(state: InstitutionsAdminModel): boolean { + return state.hasOsfAddonSearch.isLoading; + } + + @Selector([InstitutionsAdminState]) + static getHasOsfAddonSearchError(state: InstitutionsAdminModel): string | null { + return state.hasOsfAddonSearch.error; + } + + @Selector([InstitutionsAdminState]) + static getStorageRegionSearch(state: InstitutionsAdminModel): InstitutionSearchFilter[] { + return state.storageRegionSearch.data; + } + + @Selector([InstitutionsAdminState]) + static getStorageRegionSearchLoading(state: InstitutionsAdminModel): boolean { + return state.storageRegionSearch.isLoading; + } + + @Selector([InstitutionsAdminState]) + static getStorageRegionSearchError(state: InstitutionsAdminModel): string | null { + return state.storageRegionSearch.error; + } + + @Selector([InstitutionsAdminState]) + static getSearchResults(state: InstitutionsAdminModel): InstitutionSearchFilter[] { + return state.searchResults.data; + } + + @Selector([InstitutionsAdminState]) + static getSearchResultsLoading(state: InstitutionsAdminModel): boolean { + return state.searchResults.isLoading; + } + + @Selector([InstitutionsAdminState]) + static getSearchResultsError(state: InstitutionsAdminModel): string | null { + return state.searchResults.error; + } + + @Selector([InstitutionsAdminState]) + static getSelectedInstitutionId(state: InstitutionsAdminModel): string | null { + return state.selectedInstitutionId; + } + + @Selector([InstitutionsAdminState]) + static getCurrentSearchPropertyPath(state: InstitutionsAdminModel): string | null { + return state.currentSearchPropertyPath; + } +} diff --git a/src/app/features/admin-institutions/store/institutions-admin.state.ts b/src/app/features/admin-institutions/store/institutions-admin.state.ts new file mode 100644 index 000000000..016e75b8c --- /dev/null +++ b/src/app/features/admin-institutions/store/institutions-admin.state.ts @@ -0,0 +1,167 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, tap, throwError } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { InstitutionSummaryMetrics } from '../models'; +import { InstitutionsAdminService } from '../services/institutions-admin.service'; + +import { + FetchHasOsfAddonSearch, + FetchInstitutionDepartments, + FetchInstitutionSearchResults, + FetchInstitutionSummaryMetrics, + FetchStorageRegionSearch, +} from './institutions-admin.actions'; +import { InstitutionsAdminModel } from './institutions-admin.model'; + +@State({ + name: 'institutionsAdmin', + defaults: { + departments: { data: [], isLoading: false, error: null }, + summaryMetrics: { data: {} as InstitutionSummaryMetrics, isLoading: false, error: null }, + hasOsfAddonSearch: { data: [], isLoading: false, error: null }, + storageRegionSearch: { data: [], isLoading: false, error: null }, + searchResults: { data: [], isLoading: false, error: null }, + selectedInstitutionId: null, + currentSearchPropertyPath: null, + }, +}) +@Injectable() +export class InstitutionsAdminState { + private readonly institutionsAdminService = inject(InstitutionsAdminService); + + @Action(FetchInstitutionDepartments) + fetchDepartments(ctx: StateContext, action: FetchInstitutionDepartments) { + const state = ctx.getState(); + ctx.patchState({ + departments: { ...state.departments, isLoading: true, error: null }, + }); + + return this.institutionsAdminService.fetchDepartments(action.institutionId).pipe( + tap((response) => { + ctx.patchState({ + departments: { data: response, isLoading: false, error: null }, + }); + }), + catchError((error) => { + ctx.patchState({ + departments: { + ...state.departments, + isLoading: false, + error: error.message, + }, + }); + return throwError(() => error); + }) + ); + } + + @Action(FetchInstitutionSummaryMetrics) + fetchSummaryMetrics(ctx: StateContext, action: FetchInstitutionSummaryMetrics) { + const state = ctx.getState(); + ctx.patchState({ + summaryMetrics: { ...state.summaryMetrics, isLoading: true, error: null }, + }); + + return this.institutionsAdminService.fetchSummary(action.institutionId).pipe( + tap((response) => { + ctx.patchState({ + summaryMetrics: { data: response, isLoading: false, error: null }, + }); + }), + catchError((error) => { + ctx.patchState({ + summaryMetrics: { + ...state.summaryMetrics, + isLoading: false, + error: error.message, + }, + }); + return throwError(() => error); + }) + ); + } + + @Action(FetchInstitutionSearchResults) + fetchSearchResults(ctx: StateContext, action: FetchInstitutionSearchResults) { + const state = ctx.getState(); + ctx.patchState({ + searchResults: { ...state.searchResults, isLoading: true, error: null }, + currentSearchPropertyPath: action.valueSearchPropertyPath, + }); + + return this.institutionsAdminService + .fetchIndexValueSearch(action.institutionId, action.valueSearchPropertyPath, action.additionalParams) + .pipe( + tap((response) => { + ctx.patchState({ + searchResults: { data: response, isLoading: false, error: null }, + }); + }), + catchError((error) => { + ctx.patchState({ + searchResults: { + ...state.searchResults, + isLoading: false, + error: error.message, + }, + }); + return throwError(() => error); + }) + ); + } + + @Action(FetchHasOsfAddonSearch) + fetchHasOsfAddonSearch(ctx: StateContext, action: FetchHasOsfAddonSearch) { + const state = ctx.getState(); + ctx.patchState({ + hasOsfAddonSearch: { ...state.hasOsfAddonSearch, isLoading: true, error: null }, + }); + + return this.institutionsAdminService.fetchIndexValueSearch(action.institutionId, 'hasOsfAddon').pipe( + tap((response) => { + ctx.patchState({ + hasOsfAddonSearch: { data: response, isLoading: false, error: null }, + }); + }), + catchError((error) => { + ctx.patchState({ + hasOsfAddonSearch: { + ...state.hasOsfAddonSearch, + isLoading: false, + error: error.message, + }, + }); + return throwError(() => error); + }) + ); + } + + @Action(FetchStorageRegionSearch) + fetchStorageRegionSearch(ctx: StateContext, action: FetchStorageRegionSearch) { + const state = ctx.getState(); + ctx.patchState({ + storageRegionSearch: { ...state.storageRegionSearch, isLoading: true, error: null }, + }); + + return this.institutionsAdminService.fetchIndexValueSearch(action.institutionId, 'storageRegion').pipe( + tap((response) => { + ctx.patchState({ + storageRegionSearch: { data: response, isLoading: false, error: null }, + }); + }), + catchError((error) => { + ctx.patchState({ + storageRegionSearch: { + ...state.storageRegionSearch, + isLoading: false, + error: error.message, + }, + }); + return throwError(() => error); + }) + ); + } +} diff --git a/src/app/features/institutions/institutions.component.html b/src/app/features/institutions/institutions.component.html index 78b251309..67e7bd4cd 100644 --- a/src/app/features/institutions/institutions.component.html +++ b/src/app/features/institutions/institutions.component.html @@ -1,48 +1 @@ -
- - - @if (institutionsLoading()) { -
- -
- } @else { -
- - -
- @for (institution of institutions(); track $index) { - -
- - -

{{ institution.name }}

-
-
- } - - @if (!institutions().length) { -

{{ 'common.search.noResultsFound' | translate }}

- } -
- - @if (totalInstitutionsCount() > 10) { - - } -
- } -
+ diff --git a/src/app/features/institutions/institutions.component.ts b/src/app/features/institutions/institutions.component.ts index dba3a099d..0d0687196 100644 --- a/src/app/features/institutions/institutions.component.ts +++ b/src/app/features/institutions/institutions.component.ts @@ -1,134 +1,10 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { PaginatorState } from 'primeng/paginator'; - -import { debounceTime, distinctUntilChanged } from 'rxjs'; - -import { NgOptimizedImage } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - effect, - HostBinding, - inject, - signal, - untracked, -} from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { FormControl } from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; - -import { parseQueryFilterParams } from '@core/helpers'; -import { - CustomPaginatorComponent, - LoadingSpinnerComponent, - SearchInputComponent, - SubHeaderComponent, -} from '@shared/components'; -import { TABLE_PARAMS } from '@shared/constants'; -import { QueryParams } from '@shared/models'; -import { FetchInstitutions, InstitutionsSelectors } from '@shared/stores'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; @Component({ selector: 'osf-institutions', - imports: [ - SubHeaderComponent, - TranslatePipe, - SearchInputComponent, - NgOptimizedImage, - CustomPaginatorComponent, - LoadingSpinnerComponent, - RouterLink, - ], + imports: [RouterOutlet], templateUrl: './institutions.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class InstitutionsComponent { - @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; - - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly destroyRef = inject(DestroyRef); - - private readonly actions = createDispatchMap({ getInstitutions: FetchInstitutions }); - - searchControl = new FormControl(''); - - queryParams = toSignal(this.route.queryParams); - currentPage = signal(1); - currentPageSize = signal(TABLE_PARAMS.rows); - first = signal(0); - - institutions = select(InstitutionsSelectors.getInstitutions); - totalInstitutionsCount = select(InstitutionsSelectors.getInstitutionsTotalCount); - institutionsLoading = select(InstitutionsSelectors.isInstitutionsLoading); - - constructor() { - this.setupQueryParamsEffect(); - this.setupSearchSubscription(); - } - - onPageChange(event: PaginatorState): void { - this.currentPage.set(event.page ? this.currentPage() + 1 : 1); - this.first.set(event.first ?? 0); - this.updateQueryParams({ - page: this.currentPage(), - size: event.rows, - }); - } - - private setupQueryParamsEffect(): void { - effect(() => { - const rawQueryParams = this.queryParams(); - if (!rawQueryParams) return; - - const parsedQueryParams = parseQueryFilterParams(rawQueryParams); - - this.updateComponentState(parsedQueryParams); - - this.actions.getInstitutions(parsedQueryParams.page, parsedQueryParams.size, parsedQueryParams.search); - }); - } - - private updateQueryParams(updates: Partial): void { - const queryParams: Record = {}; - - if ('page' in updates) { - queryParams['page'] = updates.page!.toString(); - } - if ('size' in updates) { - queryParams['size'] = updates.size!.toString(); - } - if ('search' in updates) { - queryParams['search'] = updates.search || undefined; - } - - this.router.navigate([], { - relativeTo: this.route, - queryParams, - queryParamsHandling: 'merge', - }); - } - - private setupSearchSubscription(): void { - this.searchControl.valueChanges - .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe((searchControl) => { - this.updateQueryParams({ - search: searchControl ?? '', - page: 1, - }); - }); - } - - private updateComponentState(params: QueryParams): void { - untracked(() => { - this.currentPage.set(params.page); - this.currentPageSize.set(params.size); - this.searchControl.setValue(params.search); - }); - } -} +export class InstitutionsComponent {} diff --git a/src/app/features/institutions/institutions.routes.ts b/src/app/features/institutions/institutions.routes.ts index a07caff09..8fef4a89e 100644 --- a/src/app/features/institutions/institutions.routes.ts +++ b/src/app/features/institutions/institutions.routes.ts @@ -5,16 +5,27 @@ import { Routes } from '@angular/router'; import { InstitutionsComponent } from '@osf/features/institutions/institutions.component'; import { InstitutionsSearchState } from '@shared/stores'; -import { InstitutionsSearchComponent } from './pages'; +import { InstitutionsListComponent, InstitutionsSearchComponent } from './pages'; export const routes: Routes = [ { path: '', component: InstitutionsComponent, - }, - { - path: ':institution-id', - component: InstitutionsSearchComponent, - providers: [provideStates([InstitutionsSearchState])], + children: [ + { + path: '', + pathMatch: 'full', + component: InstitutionsListComponent, + }, + { + path: ':institution-id', + component: InstitutionsSearchComponent, + providers: [provideStates([InstitutionsSearchState])], + }, + { + path: ':institution-id/dashboard', + loadChildren: () => import('../admin-institutions/routes').then((inst) => inst.routes), + }, + ], }, ]; diff --git a/src/app/features/institutions/pages/index.ts b/src/app/features/institutions/pages/index.ts index f189ec9bc..c782d130d 100644 --- a/src/app/features/institutions/pages/index.ts +++ b/src/app/features/institutions/pages/index.ts @@ -1 +1,2 @@ +export { InstitutionsListComponent } from './institutions-list/institutions-list.component'; export { InstitutionsSearchComponent } from './institutions-search/institutions-search.component'; diff --git a/src/app/features/institutions/pages/institutions-list/institutions-list.component.html b/src/app/features/institutions/pages/institutions-list/institutions-list.component.html new file mode 100644 index 000000000..78b251309 --- /dev/null +++ b/src/app/features/institutions/pages/institutions-list/institutions-list.component.html @@ -0,0 +1,48 @@ +
+ + + @if (institutionsLoading()) { +
+ +
+ } @else { +
+ + +
+ @for (institution of institutions(); track $index) { + +
+ + +

{{ institution.name }}

+
+
+ } + + @if (!institutions().length) { +

{{ 'common.search.noResultsFound' | translate }}

+ } +
+ + @if (totalInstitutionsCount() > 10) { + + } +
+ } +
diff --git a/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts b/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts new file mode 100644 index 000000000..123c49bb9 --- /dev/null +++ b/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionsListComponent } from './institutions-list.component'; + +describe('InstitutionsListComponent', () => { + let component: InstitutionsListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionsListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionsListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/institutions/pages/institutions-list/institutions-list.component.ts b/src/app/features/institutions/pages/institutions-list/institutions-list.component.ts new file mode 100644 index 000000000..46a1f0b32 --- /dev/null +++ b/src/app/features/institutions/pages/institutions-list/institutions-list.component.ts @@ -0,0 +1,134 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { PaginatorState } from 'primeng/paginator'; + +import { debounceTime, distinctUntilChanged } from 'rxjs'; + +import { NgOptimizedImage } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + HostBinding, + inject, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { parseQueryFilterParams } from '@core/helpers'; +import { + CustomPaginatorComponent, + LoadingSpinnerComponent, + SearchInputComponent, + SubHeaderComponent, +} from '@shared/components'; +import { TABLE_PARAMS } from '@shared/constants'; +import { QueryParams } from '@shared/models'; +import { FetchInstitutions, InstitutionsSelectors } from '@shared/stores'; + +@Component({ + selector: 'osf-institutions-list', + imports: [ + SubHeaderComponent, + TranslatePipe, + SearchInputComponent, + NgOptimizedImage, + CustomPaginatorComponent, + LoadingSpinnerComponent, + RouterLink, + ], + templateUrl: './institutions-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsListComponent { + @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; + + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + private readonly actions = createDispatchMap({ getInstitutions: FetchInstitutions }); + + searchControl = new FormControl(''); + + queryParams = toSignal(this.route.queryParams); + currentPage = signal(1); + currentPageSize = signal(TABLE_PARAMS.rows); + first = signal(0); + + institutions = select(InstitutionsSelectors.getInstitutions); + totalInstitutionsCount = select(InstitutionsSelectors.getInstitutionsTotalCount); + institutionsLoading = select(InstitutionsSelectors.isInstitutionsLoading); + + constructor() { + this.setupQueryParamsEffect(); + this.setupSearchSubscription(); + } + + onPageChange(event: PaginatorState): void { + this.currentPage.set(event.page ? this.currentPage() + 1 : 1); + this.first.set(event.first ?? 0); + this.updateQueryParams({ + page: this.currentPage(), + size: event.rows, + }); + } + + private setupQueryParamsEffect(): void { + effect(() => { + const rawQueryParams = this.queryParams(); + if (!rawQueryParams) return; + + const parsedQueryParams = parseQueryFilterParams(rawQueryParams); + + this.updateComponentState(parsedQueryParams); + + this.actions.getInstitutions(parsedQueryParams.page, parsedQueryParams.size, parsedQueryParams.search); + }); + } + + private updateQueryParams(updates: Partial): void { + const queryParams: Record = {}; + + if ('page' in updates) { + queryParams['page'] = updates.page!.toString(); + } + if ('size' in updates) { + queryParams['size'] = updates.size!.toString(); + } + if ('search' in updates) { + queryParams['search'] = updates.search || undefined; + } + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'merge', + }); + } + + private setupSearchSubscription(): void { + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((searchControl) => { + this.updateQueryParams({ + search: searchControl ?? '', + page: 1, + }); + }); + } + + private updateComponentState(params: QueryParams): void { + untracked(() => { + this.currentPage.set(params.page); + this.currentPageSize.set(params.size); + this.searchControl.setValue(params.search); + }); + } +} diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts index 5fd29abe5..0b907846a 100644 --- a/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts @@ -15,16 +15,16 @@ import { FormControl, FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { + FilterChipsComponent, LoadingSpinnerComponent, ReusableFilterComponent, SearchHelpTutorialComponent, SearchInputComponent, -} from '@shared/components'; -import { FilterChipsComponent } from '@shared/components/filter-chips/filter-chips.component'; -import { SearchResultsContainerComponent } from '@shared/components/search-results-container/search-results-container.component'; -import { SEARCH_TAB_OPTIONS } from '@shared/constants'; -import { ResourceTab } from '@shared/enums'; -import { DiscoverableFilter } from '@shared/models'; + SearchResultsContainerComponent, +} from '@osf/shared/components'; +import { SEARCH_TAB_OPTIONS } from '@osf/shared/constants'; +import { ResourceTab } from '@osf/shared/enums'; +import { DiscoverableFilter } from '@osf/shared/models'; import { FetchInstitutionById, FetchResources, @@ -36,7 +36,7 @@ import { UpdateFilterValue, UpdateResourceType, UpdateSortBy, -} from '@shared/stores'; +} from '@osf/shared/stores'; @Component({ selector: 'osf-institutions-search', diff --git a/src/app/shared/components/bar-chart/bar-chart.component.html b/src/app/shared/components/bar-chart/bar-chart.component.html index 2dcd65140..f9c424f91 100644 --- a/src/app/shared/components/bar-chart/bar-chart.component.html +++ b/src/app/shared/components/bar-chart/bar-chart.component.html @@ -1,9 +1,35 @@ -

{{ title() | translate }}

+@if (!showExpandedSection()) { +

{{ title() | translate }}

+} @if (isLoading()) { } @else { -
- +
+
+ +
+ + @if (showExpandedSection()) { +
+ + + +

{{ title() | translate }}

+
+ +
+ @for (label of labels(); let i = $index; track i) { +
  • +
    + {{ label }} +
  • + } +
    +
    +
    +
    +
    + }
    } diff --git a/src/app/shared/components/bar-chart/bar-chart.component.scss b/src/app/shared/components/bar-chart/bar-chart.component.scss index bcbc327cf..478abc91c 100644 --- a/src/app/shared/components/bar-chart/bar-chart.component.scss +++ b/src/app/shared/components/bar-chart/bar-chart.component.scss @@ -1,15 +1,4 @@ :host { - // display: block; - // width: 100%; -} - -.chart-title { - font-size: 1.7rem; -} - -.chart { display: block; - width: 100%; height: 100%; - margin-top: 1.7rem; } diff --git a/src/app/shared/components/bar-chart/bar-chart.component.ts b/src/app/shared/components/bar-chart/bar-chart.component.ts index 898c5da11..59287845f 100644 --- a/src/app/shared/components/bar-chart/bar-chart.component.ts +++ b/src/app/shared/components/bar-chart/bar-chart.component.ts @@ -1,5 +1,6 @@ import { TranslatePipe } from '@ngx-translate/core'; +import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; import { ChartModule } from 'primeng/chart'; import { isPlatformBrowser } from '@angular/common'; @@ -15,6 +16,7 @@ import { } from '@angular/core'; import { DatasetInput } from '@osf/shared/models'; +import { PIE_CHART_PALETTE } from '@shared/utils'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; @@ -22,7 +24,15 @@ import { ChartData, ChartOptions } from 'chart.js'; @Component({ selector: 'osf-bar-chart', - imports: [ChartModule, TranslatePipe, LoadingSpinnerComponent], + imports: [ + ChartModule, + TranslatePipe, + LoadingSpinnerComponent, + AccordionContent, + AccordionHeader, + AccordionPanel, + Accordion, + ], templateUrl: './bar-chart.component.html', styleUrl: './bar-chart.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -34,7 +44,10 @@ export class BarChartComponent implements OnInit { datasets = input([]); showLegend = input(false); showGrid = input(false); + showTicks = input(true); + orientation = input<'horizontal' | 'vertical'>('horizontal'); + showExpandedSection = input(false); protected options = signal({}); protected data = signal({} as ChartData); @@ -75,6 +88,10 @@ export class BarChartComponent implements OnInit { }); } + getColor(index: number): string { + return PIE_CHART_PALETTE[index % PIE_CHART_PALETTE.length]; + } + private setChartOptions(textColorSecondary: string, surfaceBorder: string) { this.options.set({ indexAxis: this.orientation() === 'horizontal' ? 'y' : 'x', @@ -89,6 +106,7 @@ export class BarChartComponent implements OnInit { scales: { x: { ticks: { + display: this.showTicks(), color: textColorSecondary, }, grid: { @@ -98,6 +116,7 @@ export class BarChartComponent implements OnInit { }, y: { ticks: { + display: this.showTicks(), color: textColorSecondary, }, grid: { diff --git a/src/app/shared/components/doughnut-chart/doughnut-chart.component.html b/src/app/shared/components/doughnut-chart/doughnut-chart.component.html new file mode 100644 index 000000000..e8d3f10e2 --- /dev/null +++ b/src/app/shared/components/doughnut-chart/doughnut-chart.component.html @@ -0,0 +1,35 @@ +@if (!showExpandedSection()) { +

    {{ title() | translate }}

    +} + +@if (isLoading()) { + +} @else { +
    +
    + +
    + + @if (showExpandedSection()) { +
    + + + +

    {{ title() | translate }}

    +
    + +
    + @for (label of labels(); let i = $index; track i) { +
  • +
    + {{ label }} +
  • + } +
    +
    +
    +
    +
    + } +
    +} diff --git a/src/app/shared/components/doughnut-chart/doughnut-chart.component.scss b/src/app/shared/components/doughnut-chart/doughnut-chart.component.scss new file mode 100644 index 000000000..478abc91c --- /dev/null +++ b/src/app/shared/components/doughnut-chart/doughnut-chart.component.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 100%; +} diff --git a/src/app/shared/components/doughnut-chart/doughnut-chart.component.spec.ts b/src/app/shared/components/doughnut-chart/doughnut-chart.component.spec.ts new file mode 100644 index 000000000..0e8406fba --- /dev/null +++ b/src/app/shared/components/doughnut-chart/doughnut-chart.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DoughnutChartComponent } from './doughnut-chart.component'; + +describe('PieChartComponent', () => { + let component: DoughnutChartComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DoughnutChartComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DoughnutChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/doughnut-chart/doughnut-chart.component.ts b/src/app/shared/components/doughnut-chart/doughnut-chart.component.ts new file mode 100644 index 000000000..99e115110 --- /dev/null +++ b/src/app/shared/components/doughnut-chart/doughnut-chart.component.ts @@ -0,0 +1,97 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; +import { ChartModule } from 'primeng/chart'; + +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + input, + OnInit, + PLATFORM_ID, + signal, +} from '@angular/core'; + +import { DatasetInput } from '@osf/shared/models'; +import { PIE_CHART_PALETTE } from '@osf/shared/utils'; + +import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; + +import { ChartData, ChartOptions } from 'chart.js'; + +@Component({ + selector: 'osf-doughnut-chart', + imports: [ + ChartModule, + TranslatePipe, + LoadingSpinnerComponent, + Accordion, + AccordionHeader, + AccordionPanel, + AccordionContent, + ], + templateUrl: './doughnut-chart.component.html', + styleUrl: './doughnut-chart.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DoughnutChartComponent implements OnInit { + private readonly platformId = inject(PLATFORM_ID); + private readonly cd = inject(ChangeDetectorRef); + + isLoading = input(false); + title = input(''); + labels = input([]); + datasets = input([]); + showLegend = input(false); + showExpandedSection = input(false); + + protected options = signal({}); + protected data = signal({} as ChartData); + + ngOnInit() { + this.initChart(); + } + + initChart() { + if (isPlatformBrowser(this.platformId)) { + this.setChartData(); + this.setChartOptions(); + + this.cd.markForCheck(); + } + } + + getColor(index: number): string { + return PIE_CHART_PALETTE[index % PIE_CHART_PALETTE.length]; + } + + private setChartData() { + const chartDatasets = this.datasets().map((dataset) => ({ + label: dataset.label, + data: dataset.data, + backgroundColor: dataset?.color || PIE_CHART_PALETTE, + borderWidth: 0, + })); + + this.data.set({ + labels: this.labels(), + datasets: chartDatasets, + }); + } + + private setChartOptions() { + this.options.set({ + maintainAspectRatio: true, + responsive: true, + plugins: { + legend: { + display: this.showLegend(), + position: 'bottom', + }, + }, + }); + } +} diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index 80d1049bc..762c98247 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -28,6 +28,7 @@ export { SearchHelpTutorialComponent } from './search-help-tutorial/search-help- export { SearchInputComponent } from './search-input/search-input.component'; export { SearchResultsContainerComponent } from './search-results-container/search-results-container.component'; export { SelectComponent } from './select/select.component'; +export { StatisticCardComponent } from './statistic-card/statistic-card.component'; export { StepperComponent } from './stepper/stepper.component'; export { SubHeaderComponent } from './sub-header/sub-header.component'; export { SubjectsComponent } from './subjects/subjects.component'; diff --git a/src/app/shared/components/line-chart/line-chart.component.html b/src/app/shared/components/line-chart/line-chart.component.html index ff756e454..961d8bf4d 100644 --- a/src/app/shared/components/line-chart/line-chart.component.html +++ b/src/app/shared/components/line-chart/line-chart.component.html @@ -1,9 +1,9 @@ -

    {{ title() | translate }}

    +

    {{ title() | translate }}

    @if (isLoading()) { } @else {
    - +
    } diff --git a/src/app/shared/components/line-chart/line-chart.component.scss b/src/app/shared/components/line-chart/line-chart.component.scss index bcbc327cf..5d4e87f30 100644 --- a/src/app/shared/components/line-chart/line-chart.component.scss +++ b/src/app/shared/components/line-chart/line-chart.component.scss @@ -1,15 +1,3 @@ :host { - // display: block; - // width: 100%; -} - -.chart-title { - font-size: 1.7rem; -} - -.chart { display: block; - width: 100%; - height: 100%; - margin-top: 1.7rem; } diff --git a/src/app/shared/components/pie-chart/pie-chart.component.html b/src/app/shared/components/pie-chart/pie-chart.component.html index 2491f813b..48c7addd7 100644 --- a/src/app/shared/components/pie-chart/pie-chart.component.html +++ b/src/app/shared/components/pie-chart/pie-chart.component.html @@ -1,9 +1,9 @@ -

    {{ title() | translate }}

    +

    {{ title() | translate }}

    @if (isLoading()) { } @else {
    - +
    } diff --git a/src/app/shared/components/pie-chart/pie-chart.component.scss b/src/app/shared/components/pie-chart/pie-chart.component.scss index db8aeaa21..478abc91c 100644 --- a/src/app/shared/components/pie-chart/pie-chart.component.scss +++ b/src/app/shared/components/pie-chart/pie-chart.component.scss @@ -1,13 +1,4 @@ :host { display: block; - // width: 100%; -} - -.chart-title { - font-size: 1.7rem; -} - -.chart { - display: block; - margin-top: 1.7rem; + height: 100%; } diff --git a/src/app/shared/components/search-results-container/search-results-container.component.html b/src/app/shared/components/search-results-container/search-results-container.component.html index 7e98a712a..347523cbf 100644 --- a/src/app/shared/components/search-results-container/search-results-container.component.html +++ b/src/app/shared/components/search-results-container/search-results-container.component.html @@ -33,29 +33,21 @@

    > @if (isAnyFilterOptions()) { - filter by + > } - sort by + >

    diff --git a/src/app/shared/components/search-results-container/search-results-container.component.ts b/src/app/shared/components/search-results-container/search-results-container.component.ts index 070ae6d3f..b4fd1b5b6 100644 --- a/src/app/shared/components/search-results-container/search-results-container.component.ts +++ b/src/app/shared/components/search-results-container/search-results-container.component.ts @@ -4,7 +4,6 @@ import { Button } from 'primeng/button'; import { DataView } from 'primeng/dataview'; import { Select } from 'primeng/select'; -import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -18,16 +17,7 @@ import { SelectComponent } from '../select/select.component'; @Component({ selector: 'osf-search-results-container', - imports: [ - FormsModule, - NgOptimizedImage, - Button, - DataView, - Select, - ResourceCardComponent, - TranslatePipe, - SelectComponent, - ], + imports: [FormsModule, Button, DataView, Select, ResourceCardComponent, TranslatePipe, SelectComponent], templateUrl: './search-results-container.component.html', styleUrl: './search-results-container.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/shared/components/statistic-card/statistic-card.component.html b/src/app/shared/components/statistic-card/statistic-card.component.html new file mode 100644 index 000000000..00094df87 --- /dev/null +++ b/src/app/shared/components/statistic-card/statistic-card.component.html @@ -0,0 +1,6 @@ +
    +
    +

    {{ value() }}

    +
    +

    {{ label() }}

    +
    diff --git a/src/app/shared/components/statistic-card/statistic-card.component.scss b/src/app/shared/components/statistic-card/statistic-card.component.scss new file mode 100644 index 000000000..11732eb75 --- /dev/null +++ b/src/app/shared/components/statistic-card/statistic-card.component.scss @@ -0,0 +1,7 @@ +.bg-blue-2 { + background-color: var(--blue-2-bg); +} + +.col-blue-2 { + color: var(--blue-2); +} diff --git a/src/app/shared/components/statistic-card/statistic-card.component.spec.ts b/src/app/shared/components/statistic-card/statistic-card.component.spec.ts new file mode 100644 index 000000000..ff942c848 --- /dev/null +++ b/src/app/shared/components/statistic-card/statistic-card.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StatisticCardComponent } from './statistic-card.component'; + +describe('StatisticCardComponent', () => { + let component: StatisticCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StatisticCardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(StatisticCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/statistic-card/statistic-card.component.ts b/src/app/shared/components/statistic-card/statistic-card.component.ts new file mode 100644 index 000000000..fdca9ab6e --- /dev/null +++ b/src/app/shared/components/statistic-card/statistic-card.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { Primitive } from '@core/helpers'; + +@Component({ + selector: 'osf-statistic-card', + templateUrl: './statistic-card.component.html', + styleUrl: './statistic-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StatisticCardComponent { + value = input(); + label = input(''); +} diff --git a/src/app/shared/stores/institutions-search/institutions-search.state.ts b/src/app/shared/stores/institutions-search/institutions-search.state.ts index a5ae21ac4..84f0e5bab 100644 --- a/src/app/shared/stores/institutions-search/institutions-search.state.ts +++ b/src/app/shared/stores/institutions-search/institutions-search.state.ts @@ -1,4 +1,4 @@ -import { Action, NgxsOnInit, State, StateContext, Store } from '@ngxs/store'; +import { Action, NgxsOnInit, State, StateContext } from '@ngxs/store'; import { patch } from '@ngxs/store/operators'; import { BehaviorSubject, catchError, EMPTY, forkJoin, of, switchMap, tap, throwError } from 'rxjs'; @@ -6,18 +6,20 @@ import { BehaviorSubject, catchError, EMPTY, forkJoin, of, switchMap, tap, throw import { inject, Injectable } from '@angular/core'; import { ResourcesData } from '@osf/features/search/models'; -import { GetResourcesRequestTypeEnum, ResourceTab } from '@shared/enums'; -import { Institution } from '@shared/models'; -import { InstitutionsService, SearchService } from '@shared/services'; -import { FetchResources, FetchResourcesByLink, InstitutionsSearchSelectors, UpdateResourceType } from '@shared/stores'; -import { getResourceTypes } from '@shared/utils'; +import { GetResourcesRequestTypeEnum, ResourceTab } from '@osf/shared/enums'; +import { Institution } from '@osf/shared/models'; +import { InstitutionsService, SearchService } from '@osf/shared/services'; +import { getResourceTypes } from '@osf/shared/utils'; import { FetchInstitutionById, + FetchResources, + FetchResourcesByLink, LoadFilterOptions, LoadFilterOptionsAndSetValues, SetFilterValues, UpdateFilterValue, + UpdateResourceType, UpdateSortBy, } from './institutions-search.actions'; import { InstitutionsSearchModel } from './institutions-search.model'; @@ -44,7 +46,6 @@ import { InstitutionsSearchModel } from './institutions-search.model'; export class InstitutionsSearchState implements NgxsOnInit { private readonly institutionsService = inject(InstitutionsService); private readonly searchService = inject(SearchService); - private readonly store = inject(Store); private loadRequests = new BehaviorSubject<{ type: GetResourcesRequestTypeEnum; link?: string } | null>(null); private filterOptionsRequests = new BehaviorSubject(null); @@ -71,12 +72,12 @@ export class InstitutionsSearchState implements NgxsOnInit { const state = ctx.getState(); ctx.patchState({ resources: { ...state.resources, isLoading: true } }); const filtersParams: Record = {}; - const searchText = this.store.selectSnapshot(InstitutionsSearchSelectors.getSearchText); - const sortBy = this.store.selectSnapshot(InstitutionsSearchSelectors.getSortBy); - const resourceTab = this.store.selectSnapshot(InstitutionsSearchSelectors.getResourceType); + const searchText = state.searchText; + const sortBy = state.sortBy; + const resourceTab = state.resourceType; const resourceTypes = getResourceTypes(resourceTab); - filtersParams['cardSearchFilter[affiliation][]'] = this.store.selectSnapshot(InstitutionsSearchSelectors.getIris); + filtersParams['cardSearchFilter[affiliation][]'] = state.providerIri; Object.entries(state.filterValues).forEach(([key, value]) => { if (value) filtersParams[`cardSearchFilter[${key}][]`] = value; @@ -148,8 +149,8 @@ export class InstitutionsSearchState implements NgxsOnInit { } @Action(FetchResources) - getResources() { - if (!this.store.selectSnapshot(InstitutionsSearchSelectors.getIris)) return; + getResources(ctx: StateContext) { + if (!ctx.getState().providerIri) return; this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index ad612fb3b..7624a053a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1766,5 +1766,27 @@ "title": "Institutions", "description": "OSF Institutions enhances transparency and increases the visibility of research outputs, accelerating discovery and reuse. Institutional members focus on generating and sharing research, and let OSF Institutions handle the infrastructure.
    Read more", "searchInstitutions": "Search institutions" + }, + "adminInstitutions": { + "summary": { + "title": "Summary", + "totalUsersByDepartment": "Total Users by Department", + "publicPrivateProjects": "Public vs Private Projects", + "publicEmbargoedRegistrations": "Public vs Embargoed Registrations", + "totalOsfObjects": "Total OSF Objects", + "topTenLicenses": "Top 10 Licenses", + "topTenAddons": "Top 10 Add-ons", + "topStorageRegions": "Top Storage Regions", + "totalUsers": "Total Users", + "totalMonthlyLoggedInUsers": "Total Monthly Logged in Users", + "totalMonthlyActiveUsers": "Total Monthly Active Users", + "osfPublicAndPrivateProjects": "OSF Public and Private Projects", + "osfPublicAndEmbargoedRegistrations": "OSF Public and Embargoed Registrations", + "osfPreprints": "OSF Preprints", + "totalPublicFileCount": "Total Public File Count", + "totalStorageInGb": "Total Storage in GB", + "embargoedRegistrations": "Embargoed registrations", + "privateProjects": "Private projects" + } } }