From 846a85eb4c9492b9b478e86f8ca5665babc77707 Mon Sep 17 00:00:00 2001 From: sawankshrma Date: Sat, 21 Feb 2026 05:07:19 +0530 Subject: [PATCH 1/3] report page creation --- src/app/app-routing.module.ts | 2 + src/app/app.module.ts | 6 + .../report-activity.component.css | 447 ++++++++++++++++++ .../report-activity.component.html | 390 +++++++++++++++ .../report-activity.component.ts | 150 ++++++ .../report-config-modal.component.css | 67 +++ .../report-config-modal.component.html | 96 ++++ .../report-config-modal.component.ts | 137 ++++++ .../sidenav-buttons.component.ts | 3 + src/app/model/report-config.ts | 78 +++ src/app/pages/report/report.component.css | 78 +++ src/app/pages/report/report.component.html | 39 ++ src/app/pages/report/report.component.ts | 162 +++++++ 13 files changed, 1655 insertions(+) create mode 100644 src/app/component/report-activity/report-activity.component.css create mode 100644 src/app/component/report-activity/report-activity.component.html create mode 100644 src/app/component/report-activity/report-activity.component.ts create mode 100644 src/app/component/report-config-modal/report-config-modal.component.css create mode 100644 src/app/component/report-config-modal/report-config-modal.component.html create mode 100644 src/app/component/report-config-modal/report-config-modal.component.ts create mode 100644 src/app/model/report-config.ts create mode 100644 src/app/pages/report/report.component.css create mode 100644 src/app/pages/report/report.component.html create mode 100644 src/app/pages/report/report.component.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 1248cee8..fe90fbf0 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -10,6 +10,7 @@ import { UsageComponent } from './pages/usage/usage.component'; import { TeamsComponent } from './pages/teams/teams.component'; import { RoadmapComponent } from './pages/roadmap/roadmap.component'; import { SettingsComponent } from './pages/settings/settings.component'; +import { ReportComponent } from './pages/report/report.component'; const routes: Routes = [ { path: '', component: CircularHeatmapComponent }, @@ -24,6 +25,7 @@ const routes: Routes = [ { path: 'userday', component: UserdayComponent }, { path: 'roadmap', component: RoadmapComponent }, { path: 'settings', component: SettingsComponent }, + { path: 'report', component: ReportComponent }, ]; @NgModule({ diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 435bd2d2..066a6e2b 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,9 @@ import { ProgressSliderComponent } from './component/progress-slider/progress-sl import { KpiComponent } from './component/kpi/kpi.component'; import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { TeamsGroupsEditorModule } from './component/teams-groups-editor/teams-groups-editor.module'; +import { ReportComponent } from './pages/report/report.component'; +import { ReportActivityComponent } from './component/report-activity/report-activity.component'; +import { ReportConfigModalComponent } from './component/report-config-modal/report-config-modal.component'; @NgModule({ declarations: [ @@ -55,6 +58,9 @@ import { TeamsGroupsEditorModule } from './component/teams-groups-editor/teams-g ProgressSliderComponent, KpiComponent, SettingsComponent, + ReportComponent, + ReportActivityComponent, + ReportConfigModalComponent, ], imports: [ BrowserModule, diff --git a/src/app/component/report-activity/report-activity.component.css b/src/app/component/report-activity/report-activity.component.css new file mode 100644 index 00000000..20a4ed39 --- /dev/null +++ b/src/app/component/report-activity/report-activity.component.css @@ -0,0 +1,447 @@ +.content-box { + margin: 20px; + width: 95%; +} + +.activity-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.activity-header h1 { + margin: 0; + font-size: 24px; +} + +.activity-header .title-above mat-icon { + font-size: 28px; +} + +.activity-header .title-above { + display: flex; + flex-direction: row; + gap: 5px; + font-size: 20px; + color: var(--text-secondary); + font-weight: 500; + margin-bottom: 4px; +} + +.activity-subheader { + display: flex; + justify-content: space-between; + font-size: 14px; + margin-bottom: 10px; + color: var(--text-tertiary); +} +.activity-subheader .level { + min-width: fit-content; +} +.activity-subheader .uuid { + text-align: end; +} +.activity-subheader .uuid-label { + font-size: 12px; + margin-right: 0.1em; + } + +.close-button { + flex-shrink: 0; +} + +.teams-implemented-list { + list-style: none; +} + +/* Ensure panel titles don't wrap */ +mat-panel-title b { + white-space: nowrap; +} + +/* Implemented By Grid - dynamic columns based on content */ +.implemented-by-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 20px; + padding: 0; + width: 100%; +} + +/* Responsive design for smaller screens */ +@media (max-width: 550px) { + .implemented-by-grid { + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } +} + +@media (max-width: 350px) { + .implemented-by-grid { + grid-template-columns: 1fr; + gap: 10px; + } +} + +.difficulty-summary { + display: flex; + gap: 15px; + margin-left: auto; + margin-right: 0; + align-items: center; + transition: opacity 0.3s ease; +} + +/* Shared collapsing state for preview/summary elements */ +.hidden { + opacity: 0; + height: 0; + overflow: hidden; + margin: 0; + gap: 0; +} + +.difficulty-indicator { + display: flex; + align-items: center; + gap: 5px; +} + +.difficulty-icon { + font-size: 16px; + color: var(--text-secondary); +} + +.difficulty-level { + font-size: 0.8em; + padding: 2px 8px; + border-radius: 12px; + font-weight: normal; +} + +.difficulty-details { + display: flex; + justify-content: space-around; + flex-wrap: wrap; + gap: 15px; + padding: 0; +} + +.difficulty-detail-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + margin-bottom: 10px; + flex: 0 0 auto; + min-width: fit-content; +} + +.detail-label { + font-weight: 500; +} + +.difficulty-badge { + padding: 4px 12px; + border-radius: 16px; + font-size: 0.9em; + font-weight: 500; +} + +/* Level-based coloring for difficulty */ +.level-1 { background-color: #e8f5e8; color: #2e7d32; } /* Light green */ +.level-2 { background-color: #e0f2e7; color: #388e3c; } /* Medium green */ +.level-3 { background-color: #fff8e1; color: #f57c00; } /* Light orange */ +.level-4 { background-color: #fff3e0; color: #ef6c00; } /* Medium orange */ +.level-5 { background-color: #ffebee; color: #c62828; } /* Light red */ + +/* Usefulness Section Styling */ +.usefulness-section { + margin: 16px 0 0; + padding: 16px 24px; + box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12); + border-radius: 4px; +} + +.usefulness-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.usefulness-section h3 { + margin: 0; + font-size: 1em; + font-weight: 500; + color: var(--mat-expansion-panel-header-text-color, inherit); +} + +mat-icon.mat-icon { + width: fit-content; + height: fit-content; +} +.usefulness-stars { + display: flex; + align-items: center; + gap: 3px; +} + +.star { + font-size: 16px; + color: #ffc107; +} + +.star:not(.filled) { + color: #e0e0e0; +} + +.usefulness-label { + margin-left: 10px; + font-size: 0.8em; + color: var(--text-secondary); + font-weight: normal; +} + +/* References Section Styling */ +.references-summary { + margin-left: 20px; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 15px; + font-size: 0.85em; + color: var(--text-secondary); + font-weight: normal; + width: 100%; + max-width: calc(100% - 40px); + transition: opacity 0.3s ease; +} + +.ref-section { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + max-height: 37px; +} + +.ref-section strong { + color: var(--text-secondary); + font-size: 0.9em; + white-space: nowrap; +} + +.ref-values { + word-break: break-word; + overflow-wrap: break-word; + font-size: 0.8em; +} + +.cre-link { + text-decoration: none; + font-weight: 500; +} + +.cre-link:hover { + text-decoration: underline; +} + +.references-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + padding: 0; + width: 100%; +} + +.reference-column { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 60px; +} + +.reference-header { + font-size: 0.9em; + color: var(--text-secondary); + font-weight: normal; + margin-bottom: 5px; + border-bottom: 1px solid #e0e0e0; + padding-bottom: 5px; +} + +.reference-values { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.reference-value { + background-color: var(--background-tertiary); + border: 1px solid #e9ecef; + padding: 6px 10px; + border-radius: 6px; + font-size: 0.9em; + color: var(--text-secondary); + font-weight: 500; + display: inline-block; +} + +.reference-link { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + padding: 6px 10px; + border: 1px solid #e3f2fd; + border-radius: 6px; + transition: background-color 0.2s; + font-size: 0.9em; + background-color: var(--background-tertiary) +} + +.reference-link:hover { + background-color: #e3f2fd; +} + +.no-references { + color: #999; + font-style: italic; + padding: 6px 10px; + font-size: 0.9em; +} + +/* Responsive design for smaller screens */ +@media (max-width: 550px) { + .references-grid { + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } + + .references-summary { + /* flex-wrap: wrap; */ + gap: 10px; + } + + .ref-section { + flex: 0 1 48%; + } + +} + +@media (max-width: 350px) { + .references-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .references-summary { + flex-direction: column; + gap: 8px; + } + + .ref-section { + flex: 1; + flex-direction: row; + gap: 8px; + } + + .ref-section strong { + min-width: 80px; + flex-shrink: 0; + } +} + +/* Implementation Section Styling */ +.implementation-preview { + margin-left: 20px; + transition: opacity 0.3s ease; +} + +.tool-count-badge { + background-color: #e8f5e8; + color: #2e7d32; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75em; + font-weight: 500; +} + +.implementation-tools { + padding: 10px 0; +} + +.tool-item { + padding: 16px 0; + border-bottom: 1px solid #e0e0e0; +} + +.tool-item:last-child { + border-bottom: none; +} + +.tool-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.tool-name { + margin: 0; + font-size: 1.1em; + color: var(--text-primary); + font-weight: 500; +} + +.link-icon { + font-size: 18px; +} + +.tool-description { + color: var(--text-secondary); + line-height: 1.5; +} + +.tool-description p { + margin: 0; +} + +.tags-section { + margin-top: 20px; + padding: 16px 0; +} + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag-chip { + display: inline-flex; + align-items: center; + padding: 4px 8px; + background-color: var(--background-secondary); + color: var(--text-secondary); + border-radius: 16px; + font-size: 0.6em; + font-weight: 500; + border: 1px solid var(--text-tertiary); + transition: background-color 0.2s, transform 0.1s; +} + +/* Dark Mode Support */ +@media (prefers-color-scheme: dark) { + /* Level badges in dark mode - maintain readability */ + .level-1 { background-color: #2e7d32; color: #e8f5e8; } + .level-2 { background-color: #388e3c; color: #e0f2e7; } + .level-3 { background-color: #f57c00; color: #fff8e1; } + .level-4 { background-color: #ef6c00; color: #fff3e0; } + .level-5 { background-color: #c62828; color: #ffebee; } +} \ No newline at end of file diff --git a/src/app/component/report-activity/report-activity.component.html b/src/app/component/report-activity/report-activity.component.html new file mode 100644 index 00000000..a854d692 --- /dev/null +++ b/src/app/component/report-activity/report-activity.component.html @@ -0,0 +1,390 @@ +
+
+
+
+ {{ iconName }} + +
+

+ {{ currentActivity.name }} +

+
+ +
+ +
+ Level {{ currentActivity.level }} +
+ id: {{ currentActivity.uuid }} +
+
+ + + + + Description + + +

+
+ + + + + Risk + + +

+
+ + + + + Measure + + +

+
+ + + + + Assessment + + +

+
+ + + + + Dependencies + + +
+ +
+
+ + + + + Implementation Guide + + +

+
+ +
+
+

Usefulness

+
+ + {{ star <= (currentActivity.usefulness || 0) ? 'star' : 'star_border' }} + + {{ this.UsefulnessLabel }} +
+
+
+ + + + + Difficulty of Implementation +
+ + school + {{ this.KnowledgeLabel }} + + + schedule + {{ this.TimeLabel }} + + + people + {{ this.ResourceLabel }} + +
+
+
+
+
+ school + Knowledge: + {{ this.KnowledgeLabel }} +
+
+ schedule + Time: + {{ this.TimeLabel }} +
+
+ people + Resources: + {{ this.ResourceLabel }} +
+
+
+ + + + + Mapping +
+ + SAMM 2: + + {{ samm }}, + + - + + + ISO 2017: + + {{ iso }}, + + - + + + ISO 2022: + + {{ iso22 }}, + + - + + + OpenCRE: + + + View OpenCRE + + - + +
+
+
+
+
+
{{ SAMMVersion }}
+
+ {{ + samm + }} + - +
+
+ +
+
{{ ISOVersion }}
+
+ {{ iso }} + - +
+
+ +
+
{{ ISO22Version }}
+
+ {{ iso22 }} + - +
+
+ +
+
{{ openCREVersion }}
+ +
+
+ In other frameworks and standards the '{{ currentActivity?.name }}' activity is part of + sections above. +
+ + + + + + + Tools +
+ {{ currentActivity.implementation?.length || 0 }} tools +
+
+
+
+
+ +
+

+
+
+
+
+ + + + + Implemented by +
+ + {{ progressTitle }}: + + {{ teamName }}, + + + + No teams have started this activity yet + +
+
+
+
+
+
{{ progressTitle }}
+
+ + {{ teamName }} + +
+
+
+ +

No teams have started implementing this activity yet.

+
+
+
+ +
+
+ {{ tag }} +
+
+
diff --git a/src/app/component/report-activity/report-activity.component.ts b/src/app/component/report-activity/report-activity.component.ts new file mode 100644 index 00000000..b64fabd0 --- /dev/null +++ b/src/app/component/report-activity/report-activity.component.ts @@ -0,0 +1,150 @@ +import { + Component, + ViewChildren, + QueryList, + Input, + OnChanges, + SimpleChanges, + Output, + EventEmitter, + OnInit, + HostListener, +} from '@angular/core'; +import { MatAccordion } from '@angular/material/expansion'; +import { Activity } from '../../model/activity-store'; +import { ActivityAttributeVisibility } from '../../model/report-config'; +import { LoaderService } from '../../service/loader/data-loader.service'; +import { TeamName, ProgressTitle } from '../../model/types'; + +@Component({ + selector: 'app-report-activity', + templateUrl: './report-activity.component.html', + styleUrls: ['./report-activity.component.css'], +}) +export class ReportActivityComponent implements OnInit, OnChanges { + @Input() activity: Activity | null = null; + @Input() iconName: string = ''; + @Input() config!: ActivityAttributeVisibility; + @Output() activityClicked = new EventEmitter(); + showCloseButton = false; + + currentActivity: Partial = {}; + TimeLabel: string = ''; + KnowledgeLabel: string = ''; + ResourceLabel: string = ''; + UsefulnessLabel: string = ''; + SAMMVersion: string = 'OWASP SAMM v2'; + ISOVersion: string = 'ISO 27001:2017'; + ISO22Version: string = 'ISO 27001:2022'; + openCREVersion: string = 'OpenCRE'; + isNarrowScreen: boolean = false; + teamsImplemented: Map = new Map(); + teamsByProgressTitle: Map = new Map(); + progressTitlesWithTeams: ProgressTitle[] = []; + + @ViewChildren(MatAccordion) accordion!: QueryList; + + constructor(private loader: LoaderService) {} + + ngOnInit() { + // Set activity data if provided + if (this.activity) { + this.setActivityData(this.activity); + } + // Check initial screen size + this.checkWidthForActivityPanel(); + // Set up observers to watch for layout changes + } + + @HostListener('window:resize', ['$event']) + onResize(event: any) { + this.checkWidthForActivityPanel(); + } + + ngOnChanges(changes: SimpleChanges) { + // Handle changes to activity input + if (changes['activity'] && changes['activity'].currentValue) { + this.setActivityData(changes['activity'].currentValue); + } + } + + setActivityData(activity: Activity) { + this.currentActivity = activity; + + // Get datastore for labels + const dataStore = this.loader.datastore; + if (dataStore) { + /* eslint-disable */ + this.KnowledgeLabel = dataStore.getMetaString('knowledgeLabels', activity.difficultyOfImplementation.knowledge - 1); + this.TimeLabel = dataStore.getMetaString('labels', activity.difficultyOfImplementation.time - 1); + this.ResourceLabel = dataStore.getMetaString('labels', activity.difficultyOfImplementation.resources - 1); + this.UsefulnessLabel = dataStore.getMetaString('labels', activity.usefulness - 1); + /* eslint-enable */ + + // Get teams that have implemented this activity + this.updateTeamsImplemented(); + } + } + + updateTeamsImplemented() { + this.teamsImplemented.clear(); + this.teamsByProgressTitle.clear(); + this.progressTitlesWithTeams = []; + + const dataStore = this.loader.datastore; + if (!dataStore || !dataStore.progressStore || !dataStore.meta || !this.currentActivity.uuid) { + return; + } + + const teams = dataStore.meta.teams; + const progressStore = dataStore.progressStore; + const activityUuid = this.currentActivity.uuid; + + // Get all progress titles (excluding the first one which is "Not started") + const inProgressTitles = progressStore.getInProgressTitles(); + const completedTitle = progressStore.getCompletedProgressTitle(); + const allProgressTitles = [...inProgressTitles, completedTitle]; + + // Check each team to see if they have started or completed this activity + for (const teamName of teams) { + const progressTitle = progressStore.getTeamProgressTitle(activityUuid, teamName); + const progressValue = progressStore.getTeamActivityProgressValue(activityUuid, teamName); + + // Only include teams that have made progress (value > 0) + if (progressValue > 0) { + this.teamsImplemented.set(teamName, progressTitle); + + // Group teams by progress title + if (!this.teamsByProgressTitle.has(progressTitle)) { + this.teamsByProgressTitle.set(progressTitle, []); + } + this.teamsByProgressTitle.get(progressTitle)!.push(teamName); + } + } + + // Create ordered list of progress titles that have teams (skip "Not started") + for (const progressTitle of allProgressTitles) { + if (this.teamsByProgressTitle.has(progressTitle)) { + this.progressTitlesWithTeams.push(progressTitle); + } + } + } + + onActivityClicked(activityName: string) { + this.activityClicked.emit(activityName); + } + + onCloseRequested() { + // No-op for report view + } + + // Check if screen is narrow and update property + private checkWidthForActivityPanel(): void { + let elemtn: HTMLElement | null = document.querySelector('app-activity-description'); + if (!elemtn) return; + + const currentWidth = elemtn.offsetWidth; + const wasNarrow = this.isNarrowScreen; + this.isNarrowScreen = currentWidth < 500; + } +} diff --git a/src/app/component/report-config-modal/report-config-modal.component.css b/src/app/component/report-config-modal/report-config-modal.component.css new file mode 100644 index 00000000..df2d1760 --- /dev/null +++ b/src/app/component/report-config-modal/report-config-modal.component.css @@ -0,0 +1,67 @@ +.config-content { + max-height: 70vh; + overflow-y: auto; + padding: 0 40px; + background-color: var(--background-primary); +} + +mat-dialog-title{ + font-size:20px; +} + +.config-section { + padding: 16px 0; +} + +.config-section h3 { + margin: 0 0 4px 0; + font-size: 1.1em; + font-weight: 500; +} + +.config-hint { + margin: 0 0 12px 0; + font-size: 0.85em; + color: var(--text-secondary); +} + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; +} + +.search-field { + width: 80%; + margin-bottom: 8px; +} + +.activity-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 300px; + overflow-y: auto; + padding: 4px 0; + border: 1px solid var(--text-tertiary); + border-radius: 4px; + padding: 8px; +} + +.activity-checkbox-label { + display: flex; + flex-direction: column; +} + +.activity-name { + font-weight: 500; +} + +.activity-meta { + font-size: 0.8em; + color: var(--text-secondary); +} + +mat-divider { + margin: 4px 0; +} \ No newline at end of file diff --git a/src/app/component/report-config-modal/report-config-modal.component.html b/src/app/component/report-config-modal/report-config-modal.component.html new file mode 100644 index 00000000..298ca173 --- /dev/null +++ b/src/app/component/report-config-modal/report-config-modal.component.html @@ -0,0 +1,96 @@ +

Report Configuration

+ + + +
+

Levels

+

Uncheck levels you want to exclude from the report.

+
+ + Level {{ level }} + +
+
+ + + + +
+

Dimensions

+

Uncheck dimensions to exclude all their activities.

+
+ + {{ dim }} + +
+
+ + + + +
+

Subdimensions

+

Uncheck subdimensions to exclude their activities.

+
+ + {{ subdim }} + +
+
+ + + + +
+

Individual Activities

+

Search and uncheck individual activities to exclude them.

+ + Search activities or dimensions + + search + +
+ + + {{ activity.name }} + {{ activity.dimension }} · Level {{ activity.level }} + + +
+
+ + + + +
+

Visible Attributes

+

Choose which attribute sections to show for each activity.

+
+ + {{ attr.label }} + +
+
+
+ + + + + diff --git a/src/app/component/report-config-modal/report-config-modal.component.ts b/src/app/component/report-config-modal/report-config-modal.component.ts new file mode 100644 index 00000000..f2e5c043 --- /dev/null +++ b/src/app/component/report-config-modal/report-config-modal.component.ts @@ -0,0 +1,137 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { ReportConfig, ActivityAttributeVisibility } from '../../model/report-config'; +import { Activity } from '../../model/activity-store'; + +export interface ReportConfigModalData { + config: ReportConfig; + allActivities: Activity[]; + allDimensions: string[]; + allSubdimensions: string[]; + allLevels: number[]; +} + +@Component({ + selector: 'app-report-config-modal', + templateUrl: './report-config-modal.component.html', + styleUrls: ['./report-config-modal.component.css'], +}) +export class ReportConfigModalComponent { + config: ReportConfig; + allActivities: Activity[]; + allDimensions: string[]; + allSubdimensions: string[]; + allLevels: number[]; + + // Attribute display labels + attributeLabels: { key: keyof ActivityAttributeVisibility; label: string }[] = [ + { key: 'showDescription', label: 'Description' }, + { key: 'showRisk', label: 'Risk' }, + { key: 'showMeasure', label: 'Measure' }, + { key: 'showAssessment', label: 'Assessment' }, + { key: 'showImplementationGuide', label: 'Implementation Guide' }, + { key: 'showDifficulty', label: 'Difficulty of Implementation' }, + { key: 'showUsefulness', label: 'Usefulness' }, + { key: 'showDependencies', label: 'Dependencies' }, + { key: 'showTools', label: 'Tools' }, + { key: 'showMapping', label: 'Framework Mapping' }, + { key: 'showImplementedBy', label: 'Implemented By' }, + { key: 'showTags', label: 'Tags' }, + { key: 'showComments', label: 'Comments' }, + ]; + + // Activity search + activitySearchQuery: string = ''; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ReportConfigModalData + ) { + // Deep copy config to avoid mutating the original until save + this.config = JSON.parse(JSON.stringify(data.config)); + this.allActivities = data.allActivities; + this.allDimensions = data.allDimensions; + this.allSubdimensions = data.allSubdimensions; + this.allLevels = data.allLevels; + } + + // --- Level toggling --- + isLevelExcluded(level: number): boolean { + return this.config.excludedLevels.includes(level); + } + + toggleLevel(level: number): void { + const idx = this.config.excludedLevels.indexOf(level); + if (idx >= 0) { + this.config.excludedLevels.splice(idx, 1); + } else { + this.config.excludedLevels.push(level); + } + } + + // --- Dimension toggling --- + isDimensionExcluded(dim: string): boolean { + return this.config.excludedDimensions.includes(dim); + } + + toggleDimension(dim: string): void { + const idx = this.config.excludedDimensions.indexOf(dim); + if (idx >= 0) { + this.config.excludedDimensions.splice(idx, 1); + } else { + this.config.excludedDimensions.push(dim); + } + } + + // --- Subdimension toggling --- + isSubdimensionExcluded(subdim: string): boolean { + return this.config.excludedSubdimensions.includes(subdim); + } + + toggleSubdimension(subdim: string): void { + const idx = this.config.excludedSubdimensions.indexOf(subdim); + if (idx >= 0) { + this.config.excludedSubdimensions.splice(idx, 1); + } else { + this.config.excludedSubdimensions.push(subdim); + } + } + + // --- Activity toggling --- + isActivityExcluded(uuid: string): boolean { + return this.config.excludedActivities.includes(uuid); + } + + toggleActivity(uuid: string): void { + const idx = this.config.excludedActivities.indexOf(uuid); + if (idx >= 0) { + this.config.excludedActivities.splice(idx, 1); + } else { + this.config.excludedActivities.push(uuid); + } + } + + get filteredActivities(): Activity[] { + if (!this.activitySearchQuery.trim()) { + return this.allActivities; + } + const query = this.activitySearchQuery.toLowerCase(); + return this.allActivities.filter( + a => a.name.toLowerCase().includes(query) || a.dimension.toLowerCase().includes(query) + ); + } + + // --- Attribute toggling --- + toggleAttribute(key: keyof ActivityAttributeVisibility): void { + this.config.attributes[key] = !this.config.attributes[key]; + } + + // --- Actions --- + onSave(): void { + this.dialogRef.close(this.config); + } + + onCancel(): void { + this.dialogRef.close(null); + } +} diff --git a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts index b53ad3ec..423e0970 100644 --- a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts +++ b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts @@ -20,6 +20,7 @@ export class SidenavButtonsComponent implements OnInit { 'Roadmap', 'DSOMM User Day', 'About Us', + 'Report', ]; Icons: string[] = [ 'pie_chart', @@ -31,6 +32,7 @@ export class SidenavButtonsComponent implements OnInit { 'landscape', 'school', 'info', + 'summarize', ]; Routing: string[] = [ '/circular-heatmap', @@ -42,6 +44,7 @@ export class SidenavButtonsComponent implements OnInit { '/roadmap', '/userday', '/about', + '/report', ]; isNightMode = false; diff --git a/src/app/model/report-config.ts b/src/app/model/report-config.ts new file mode 100644 index 00000000..ba50c31d --- /dev/null +++ b/src/app/model/report-config.ts @@ -0,0 +1,78 @@ +export interface ActivityAttributeVisibility { + showDescription: boolean; + showRisk: boolean; + showMeasure: boolean; + showAssessment: boolean; + showImplementationGuide: boolean; + showDifficulty: boolean; + showUsefulness: boolean; + showDependencies: boolean; + showTools: boolean; + showMapping: boolean; + showImplementedBy: boolean; + showTags: boolean; + showComments: boolean; +} + +export interface ReportConfig { + excludedLevels: number[]; + excludedDimensions: string[]; + excludedSubdimensions: string[]; + excludedActivities: string[]; + attributes: ActivityAttributeVisibility; +} + +const STORAGE_KEY = 'ReportConfig'; + +export function getDefaultReportConfig(): ReportConfig { + return { + excludedLevels: [], + excludedDimensions: [], + excludedSubdimensions: [], + excludedActivities: [], + attributes: { + showDescription: true, + showRisk: false, + showMeasure: false, + showAssessment: false, + showImplementationGuide: false, + showDifficulty: false, + showUsefulness: false, + showDependencies: false, + showTools: false, + showMapping: false, + showImplementedBy: true, + showTags: false, + showComments: false, + }, + }; +} + +export function getReportConfig(): ReportConfig { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + // Merge with defaults to ensure all keys exist + const defaults = getDefaultReportConfig(); + return { + excludedLevels: parsed.excludedLevels ?? defaults.excludedLevels, + excludedDimensions: parsed.excludedDimensions ?? defaults.excludedDimensions, + excludedSubdimensions: parsed.excludedSubdimensions ?? defaults.excludedSubdimensions, + excludedActivities: parsed.excludedActivities ?? defaults.excludedActivities, + attributes: { ...defaults.attributes, ...(parsed.attributes ?? {}) }, + }; + } + } catch (e) { + console.error('Error reading ReportConfig from localStorage:', e); + } + return getDefaultReportConfig(); +} + +export function saveReportConfig(config: ReportConfig): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); + } catch (e) { + console.error('Error saving ReportConfig to localStorage:', e); + } +} diff --git a/src/app/pages/report/report.component.css b/src/app/pages/report/report.component.css new file mode 100644 index 00000000..e29898f2 --- /dev/null +++ b/src/app/pages/report/report.component.css @@ -0,0 +1,78 @@ +.report-container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +.report-toolbar { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.report-toolbar button mat-icon { + margin-right: 4px; +} + +.activity-count { + font-size: 0.9em; + color: var(--text-secondary); +} + +.loading-container { + display: flex; + justify-content: center; + padding: 60px; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 60px 20px; + color: var(--text-secondary); +} + +.empty-state mat-icon { + font-size: 48px; + width: 48px; + height: 48px; +} + +.dimension-section { + margin-bottom: 32px; +} + +.dimension-title { + font-size: 1.5em; + font-weight: 600; + margin: 0 0 16px 0; + padding-bottom: 8px; + border-bottom: 2px solid var(--text-tertiary); +} + +.subdimension-section { + margin-bottom: 24px; + margin-left: 16px; +} + +.subdimension-title { + font-size: 1.2em; + font-weight: 500; + margin: 0 0 12px 0; + color: var(--text-secondary); +} + +app-report-activity { + display: block; + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--text-tertiary); +} + +app-report-activity:last-child { + border-bottom: none; +} diff --git a/src/app/pages/report/report.component.html b/src/app/pages/report/report.component.html new file mode 100644 index 00000000..b95942b9 --- /dev/null +++ b/src/app/pages/report/report.component.html @@ -0,0 +1,39 @@ + + +
+
+ + + {{ totalFilteredActivities }} activities + +
+ +
+ +
+ +
+ filter_list_off +

No activities match the current report configuration.

+ +
+ +
+
+

{{ dimension.name }}

+ +
+

{{ subdimension.name }}

+ + + +
+
+
+
diff --git a/src/app/pages/report/report.component.ts b/src/app/pages/report/report.component.ts new file mode 100644 index 00000000..bddbf8f8 --- /dev/null +++ b/src/app/pages/report/report.component.ts @@ -0,0 +1,162 @@ +import { Component, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { LoaderService } from '../../service/loader/data-loader.service'; +import { Activity } from '../../model/activity-store'; +import { DataStore } from '../../model/data-store'; +import { ReportConfig, getReportConfig, saveReportConfig } from '../../model/report-config'; +import { + ReportConfigModalComponent, + ReportConfigModalData, +} from '../../component/report-config-modal/report-config-modal.component'; + +export interface ReportDimension { + name: string; + subdimensions: ReportSubdimension[]; +} + +export interface ReportSubdimension { + name: string; + activities: Activity[]; +} + +@Component({ + selector: 'app-report', + templateUrl: './report.component.html', + styleUrls: ['./report.component.css'], +}) +export class ReportComponent implements OnInit { + reportConfig: ReportConfig; + allActivities: Activity[] = []; + filteredDimensions: ReportDimension[] = []; + isLoading: boolean = true; + + // For the config modal + allDimensionNames: string[] = []; + allSubdimensionNames: string[] = []; + allLevels: number[] = []; + + constructor(private loader: LoaderService, private dialog: MatDialog) { + this.reportConfig = getReportConfig(); + } + + ngOnInit(): void { + this.loadActivities(); + } + + loadActivities(): void { + this.isLoading = true; + this.loader + .load() + .then((dataStore: DataStore) => { + if (!dataStore.activityStore) { + this.isLoading = false; + return; + } + + this.allActivities = dataStore.activityStore.getAllActivities(); + + // Collect unique dimensions, subdimensions, levels + const dimensionSet = new Set(); + const subdimensionSet = new Set(); + const levelSet = new Set(); + + for (const activity of this.allActivities) { + dimensionSet.add(activity.category); + subdimensionSet.add(activity.dimension); + levelSet.add(activity.level); + } + + this.allDimensionNames = Array.from(dimensionSet).sort(); + this.allSubdimensionNames = Array.from(subdimensionSet).sort(); + this.allLevels = Array.from(levelSet).sort((a, b) => a - b); + + this.applyFilters(); + this.isLoading = false; + }) + .catch(err => { + console.error('Error loading activities for report:', err); + this.isLoading = false; + }); + } + + applyFilters(): void { + const config = this.reportConfig; + + // Filter activities using hierarchical exclusion + const filtered = this.allActivities.filter(activity => { + // 1. Check dimension (category) + if (config.excludedDimensions.includes(activity.category)) return false; + // 2. Check subdimension (dimension) + if (config.excludedSubdimensions.includes(activity.dimension)) return false; + // 3. Check level + if (config.excludedLevels.includes(activity.level)) return false; + // 4. Check individual activity + if (config.excludedActivities.includes(activity.uuid)) return false; + return true; + }); + + // Group by dimension (category) → subdimension (dimension) + const dimensionMap = new Map>(); + + for (const activity of filtered) { + if (!dimensionMap.has(activity.category)) { + dimensionMap.set(activity.category, new Map()); + } + const subdimMap = dimensionMap.get(activity.category)!; + if (!subdimMap.has(activity.dimension)) { + subdimMap.set(activity.dimension, []); + } + subdimMap.get(activity.dimension)!.push(activity); + } + + // Convert to array structure sorted by name + this.filteredDimensions = []; + const sortedDimensions = Array.from(dimensionMap.keys()).sort(); + for (const dimName of sortedDimensions) { + const subdimMap = dimensionMap.get(dimName)!; + const subdimensions: ReportSubdimension[] = []; + const sortedSubdims = Array.from(subdimMap.keys()).sort(); + for (const subdimName of sortedSubdims) { + const activities = subdimMap.get(subdimName)!; + // Sort activities by level, then by name + activities.sort((a, b) => a.level - b.level || a.name.localeCompare(b.name)); + subdimensions.push({ name: subdimName, activities }); + } + this.filteredDimensions.push({ name: dimName, subdimensions }); + } + } + + openConfigModal(): void { + const modalData: ReportConfigModalData = { + config: this.reportConfig, + allActivities: this.allActivities, + allDimensions: this.allDimensionNames, + allSubdimensions: this.allSubdimensionNames, + allLevels: this.allLevels, + }; + + const dialogRef = this.dialog.open(ReportConfigModalComponent, { + width: '700px', + maxHeight: '90vh', + data: modalData, + }); + + dialogRef.afterClosed().subscribe((result: ReportConfig | null) => { + if (result) { + this.reportConfig = result; + saveReportConfig(result); + this.applyFilters(); + } + }); + } + + get totalFilteredActivities(): number { + let count = 0; + for (const dim of this.filteredDimensions) { + for (const subdim of dim.subdimensions) { + count += subdim.activities.length; + } + } + return count; + } +} From a7e65014203ca9db0dbdbf18b1a7d78e093c0047 Mon Sep 17 00:00:00 2001 From: sawankshrma Date: Mon, 23 Feb 2026 18:35:44 +0530 Subject: [PATCH 2/3] create report page layout --- src/app/app.module.ts | 4 +- .../report-activity.component.css | 447 ------------------ .../report-activity.component.html | 390 --------------- .../report-activity.component.ts | 150 ------ .../report-config-modal.component.css | 52 ++ .../report-config-modal.component.html | 83 ++-- .../report-config-modal.component.ts | 91 ++-- .../sidenav-buttons.component.ts | 6 +- src/app/model/report-config.ts | 50 +- src/app/pages/report/report.component.css | 232 ++++++++- src/app/pages/report/report.component.html | 107 ++++- src/app/pages/report/report.component.ts | 222 +++++++-- 12 files changed, 677 insertions(+), 1157 deletions(-) delete mode 100644 src/app/component/report-activity/report-activity.component.css delete mode 100644 src/app/component/report-activity/report-activity.component.html delete mode 100644 src/app/component/report-activity/report-activity.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 066a6e2b..12330878 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { ReactiveFormsModule, FormsModule } from '@angular/forms'; import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatMenuModule } from '@angular/material/menu'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @@ -32,7 +33,6 @@ import { KpiComponent } from './component/kpi/kpi.component'; import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { TeamsGroupsEditorModule } from './component/teams-groups-editor/teams-groups-editor.module'; import { ReportComponent } from './pages/report/report.component'; -import { ReportActivityComponent } from './component/report-activity/report-activity.component'; import { ReportConfigModalComponent } from './component/report-config-modal/report-config-modal.component'; @NgModule({ @@ -59,7 +59,6 @@ import { ReportConfigModalComponent } from './component/report-config-modal/repo KpiComponent, SettingsComponent, ReportComponent, - ReportActivityComponent, ReportConfigModalComponent, ], imports: [ @@ -70,6 +69,7 @@ import { ReportConfigModalComponent } from './component/report-config-modal/repo MatDialogModule, ReactiveFormsModule, MatToolbarModule, + MatMenuModule, FormsModule, HttpClientModule, TeamsGroupsEditorModule, diff --git a/src/app/component/report-activity/report-activity.component.css b/src/app/component/report-activity/report-activity.component.css deleted file mode 100644 index 20a4ed39..00000000 --- a/src/app/component/report-activity/report-activity.component.css +++ /dev/null @@ -1,447 +0,0 @@ -.content-box { - margin: 20px; - width: 95%; -} - -.activity-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 12px; -} - -.activity-header h1 { - margin: 0; - font-size: 24px; -} - -.activity-header .title-above mat-icon { - font-size: 28px; -} - -.activity-header .title-above { - display: flex; - flex-direction: row; - gap: 5px; - font-size: 20px; - color: var(--text-secondary); - font-weight: 500; - margin-bottom: 4px; -} - -.activity-subheader { - display: flex; - justify-content: space-between; - font-size: 14px; - margin-bottom: 10px; - color: var(--text-tertiary); -} -.activity-subheader .level { - min-width: fit-content; -} -.activity-subheader .uuid { - text-align: end; -} -.activity-subheader .uuid-label { - font-size: 12px; - margin-right: 0.1em; - } - -.close-button { - flex-shrink: 0; -} - -.teams-implemented-list { - list-style: none; -} - -/* Ensure panel titles don't wrap */ -mat-panel-title b { - white-space: nowrap; -} - -/* Implemented By Grid - dynamic columns based on content */ -.implemented-by-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 20px; - padding: 0; - width: 100%; -} - -/* Responsive design for smaller screens */ -@media (max-width: 550px) { - .implemented-by-grid { - grid-template-columns: repeat(2, 1fr); - gap: 15px; - } -} - -@media (max-width: 350px) { - .implemented-by-grid { - grid-template-columns: 1fr; - gap: 10px; - } -} - -.difficulty-summary { - display: flex; - gap: 15px; - margin-left: auto; - margin-right: 0; - align-items: center; - transition: opacity 0.3s ease; -} - -/* Shared collapsing state for preview/summary elements */ -.hidden { - opacity: 0; - height: 0; - overflow: hidden; - margin: 0; - gap: 0; -} - -.difficulty-indicator { - display: flex; - align-items: center; - gap: 5px; -} - -.difficulty-icon { - font-size: 16px; - color: var(--text-secondary); -} - -.difficulty-level { - font-size: 0.8em; - padding: 2px 8px; - border-radius: 12px; - font-weight: normal; -} - -.difficulty-details { - display: flex; - justify-content: space-around; - flex-wrap: wrap; - gap: 15px; - padding: 0; -} - -.difficulty-detail-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; - margin-bottom: 10px; - flex: 0 0 auto; - min-width: fit-content; -} - -.detail-label { - font-weight: 500; -} - -.difficulty-badge { - padding: 4px 12px; - border-radius: 16px; - font-size: 0.9em; - font-weight: 500; -} - -/* Level-based coloring for difficulty */ -.level-1 { background-color: #e8f5e8; color: #2e7d32; } /* Light green */ -.level-2 { background-color: #e0f2e7; color: #388e3c; } /* Medium green */ -.level-3 { background-color: #fff8e1; color: #f57c00; } /* Light orange */ -.level-4 { background-color: #fff3e0; color: #ef6c00; } /* Medium orange */ -.level-5 { background-color: #ffebee; color: #c62828; } /* Light red */ - -/* Usefulness Section Styling */ -.usefulness-section { - margin: 16px 0 0; - padding: 16px 24px; - box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12); - border-radius: 4px; -} - -.usefulness-header { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - width: 100%; -} - -.usefulness-section h3 { - margin: 0; - font-size: 1em; - font-weight: 500; - color: var(--mat-expansion-panel-header-text-color, inherit); -} - -mat-icon.mat-icon { - width: fit-content; - height: fit-content; -} -.usefulness-stars { - display: flex; - align-items: center; - gap: 3px; -} - -.star { - font-size: 16px; - color: #ffc107; -} - -.star:not(.filled) { - color: #e0e0e0; -} - -.usefulness-label { - margin-left: 10px; - font-size: 0.8em; - color: var(--text-secondary); - font-weight: normal; -} - -/* References Section Styling */ -.references-summary { - margin-left: 20px; - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 15px; - font-size: 0.85em; - color: var(--text-secondary); - font-weight: normal; - width: 100%; - max-width: calc(100% - 40px); - transition: opacity 0.3s ease; -} - -.ref-section { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - min-width: 0; - max-height: 37px; -} - -.ref-section strong { - color: var(--text-secondary); - font-size: 0.9em; - white-space: nowrap; -} - -.ref-values { - word-break: break-word; - overflow-wrap: break-word; - font-size: 0.8em; -} - -.cre-link { - text-decoration: none; - font-weight: 500; -} - -.cre-link:hover { - text-decoration: underline; -} - -.references-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 20px; - padding: 0; - width: 100%; -} - -.reference-column { - display: flex; - flex-direction: column; - gap: 10px; - min-height: 60px; -} - -.reference-header { - font-size: 0.9em; - color: var(--text-secondary); - font-weight: normal; - margin-bottom: 5px; - border-bottom: 1px solid #e0e0e0; - padding-bottom: 5px; -} - -.reference-values { - display: flex; - flex-direction: column; - gap: 6px; - flex: 1; -} - -.reference-value { - background-color: var(--background-tertiary); - border: 1px solid #e9ecef; - padding: 6px 10px; - border-radius: 6px; - font-size: 0.9em; - color: var(--text-secondary); - font-weight: 500; - display: inline-block; -} - -.reference-link { - display: flex; - align-items: center; - gap: 8px; - text-decoration: none; - padding: 6px 10px; - border: 1px solid #e3f2fd; - border-radius: 6px; - transition: background-color 0.2s; - font-size: 0.9em; - background-color: var(--background-tertiary) -} - -.reference-link:hover { - background-color: #e3f2fd; -} - -.no-references { - color: #999; - font-style: italic; - padding: 6px 10px; - font-size: 0.9em; -} - -/* Responsive design for smaller screens */ -@media (max-width: 550px) { - .references-grid { - grid-template-columns: repeat(2, 1fr); - gap: 15px; - } - - .references-summary { - /* flex-wrap: wrap; */ - gap: 10px; - } - - .ref-section { - flex: 0 1 48%; - } - -} - -@media (max-width: 350px) { - .references-grid { - grid-template-columns: 1fr; - gap: 10px; - } - - .references-summary { - flex-direction: column; - gap: 8px; - } - - .ref-section { - flex: 1; - flex-direction: row; - gap: 8px; - } - - .ref-section strong { - min-width: 80px; - flex-shrink: 0; - } -} - -/* Implementation Section Styling */ -.implementation-preview { - margin-left: 20px; - transition: opacity 0.3s ease; -} - -.tool-count-badge { - background-color: #e8f5e8; - color: #2e7d32; - padding: 2px 8px; - border-radius: 12px; - font-size: 0.75em; - font-weight: 500; -} - -.implementation-tools { - padding: 10px 0; -} - -.tool-item { - padding: 16px 0; - border-bottom: 1px solid #e0e0e0; -} - -.tool-item:last-child { - border-bottom: none; -} - -.tool-title { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} - -.tool-name { - margin: 0; - font-size: 1.1em; - color: var(--text-primary); - font-weight: 500; -} - -.link-icon { - font-size: 18px; -} - -.tool-description { - color: var(--text-secondary); - line-height: 1.5; -} - -.tool-description p { - margin: 0; -} - -.tags-section { - margin-top: 20px; - padding: 16px 0; -} - -.tags-container { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.tag-chip { - display: inline-flex; - align-items: center; - padding: 4px 8px; - background-color: var(--background-secondary); - color: var(--text-secondary); - border-radius: 16px; - font-size: 0.6em; - font-weight: 500; - border: 1px solid var(--text-tertiary); - transition: background-color 0.2s, transform 0.1s; -} - -/* Dark Mode Support */ -@media (prefers-color-scheme: dark) { - /* Level badges in dark mode - maintain readability */ - .level-1 { background-color: #2e7d32; color: #e8f5e8; } - .level-2 { background-color: #388e3c; color: #e0f2e7; } - .level-3 { background-color: #f57c00; color: #fff8e1; } - .level-4 { background-color: #ef6c00; color: #fff3e0; } - .level-5 { background-color: #c62828; color: #ffebee; } -} \ No newline at end of file diff --git a/src/app/component/report-activity/report-activity.component.html b/src/app/component/report-activity/report-activity.component.html deleted file mode 100644 index a854d692..00000000 --- a/src/app/component/report-activity/report-activity.component.html +++ /dev/null @@ -1,390 +0,0 @@ -
-
-
-
- {{ iconName }} - -
-

- {{ currentActivity.name }} -

-
- -
- -
- Level {{ currentActivity.level }} -
- id: {{ currentActivity.uuid }} -
-
- - - - - Description - - -

-
- - - - - Risk - - -

-
- - - - - Measure - - -

-
- - - - - Assessment - - -

-
- - - - - Dependencies - - -
- -
-
- - - - - Implementation Guide - - -

-
- -
-
-

Usefulness

-
- - {{ star <= (currentActivity.usefulness || 0) ? 'star' : 'star_border' }} - - {{ this.UsefulnessLabel }} -
-
-
- - - - - Difficulty of Implementation -
- - school - {{ this.KnowledgeLabel }} - - - schedule - {{ this.TimeLabel }} - - - people - {{ this.ResourceLabel }} - -
-
-
-
-
- school - Knowledge: - {{ this.KnowledgeLabel }} -
-
- schedule - Time: - {{ this.TimeLabel }} -
-
- people - Resources: - {{ this.ResourceLabel }} -
-
-
- - - - - Mapping -
- - SAMM 2: - - {{ samm }}, - - - - - - ISO 2017: - - {{ iso }}, - - - - - - ISO 2022: - - {{ iso22 }}, - - - - - - OpenCRE: - - - View OpenCRE - - - - -
-
-
-
-
-
{{ SAMMVersion }}
-
- {{ - samm - }} - - -
-
- -
-
{{ ISOVersion }}
-
- {{ iso }} - - -
-
- -
-
{{ ISO22Version }}
-
- {{ iso22 }} - - -
-
- -
-
{{ openCREVersion }}
- -
-
- In other frameworks and standards the '{{ currentActivity?.name }}' activity is part of - sections above. -
- - - - - - - Tools -
- {{ currentActivity.implementation?.length || 0 }} tools -
-
-
-
-
- -
-

-
-
-
-
- - - - - Implemented by -
- - {{ progressTitle }}: - - {{ teamName }}, - - - - No teams have started this activity yet - -
-
-
-
-
-
{{ progressTitle }}
-
- - {{ teamName }} - -
-
-
- -

No teams have started implementing this activity yet.

-
-
-
- -
-
- {{ tag }} -
-
-
diff --git a/src/app/component/report-activity/report-activity.component.ts b/src/app/component/report-activity/report-activity.component.ts deleted file mode 100644 index b64fabd0..00000000 --- a/src/app/component/report-activity/report-activity.component.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - Component, - ViewChildren, - QueryList, - Input, - OnChanges, - SimpleChanges, - Output, - EventEmitter, - OnInit, - HostListener, -} from '@angular/core'; -import { MatAccordion } from '@angular/material/expansion'; -import { Activity } from '../../model/activity-store'; -import { ActivityAttributeVisibility } from '../../model/report-config'; -import { LoaderService } from '../../service/loader/data-loader.service'; -import { TeamName, ProgressTitle } from '../../model/types'; - -@Component({ - selector: 'app-report-activity', - templateUrl: './report-activity.component.html', - styleUrls: ['./report-activity.component.css'], -}) -export class ReportActivityComponent implements OnInit, OnChanges { - @Input() activity: Activity | null = null; - @Input() iconName: string = ''; - @Input() config!: ActivityAttributeVisibility; - @Output() activityClicked = new EventEmitter(); - showCloseButton = false; - - currentActivity: Partial = {}; - TimeLabel: string = ''; - KnowledgeLabel: string = ''; - ResourceLabel: string = ''; - UsefulnessLabel: string = ''; - SAMMVersion: string = 'OWASP SAMM v2'; - ISOVersion: string = 'ISO 27001:2017'; - ISO22Version: string = 'ISO 27001:2022'; - openCREVersion: string = 'OpenCRE'; - isNarrowScreen: boolean = false; - teamsImplemented: Map = new Map(); - teamsByProgressTitle: Map = new Map(); - progressTitlesWithTeams: ProgressTitle[] = []; - - @ViewChildren(MatAccordion) accordion!: QueryList; - - constructor(private loader: LoaderService) {} - - ngOnInit() { - // Set activity data if provided - if (this.activity) { - this.setActivityData(this.activity); - } - // Check initial screen size - this.checkWidthForActivityPanel(); - // Set up observers to watch for layout changes - } - - @HostListener('window:resize', ['$event']) - onResize(event: any) { - this.checkWidthForActivityPanel(); - } - - ngOnChanges(changes: SimpleChanges) { - // Handle changes to activity input - if (changes['activity'] && changes['activity'].currentValue) { - this.setActivityData(changes['activity'].currentValue); - } - } - - setActivityData(activity: Activity) { - this.currentActivity = activity; - - // Get datastore for labels - const dataStore = this.loader.datastore; - if (dataStore) { - /* eslint-disable */ - this.KnowledgeLabel = dataStore.getMetaString('knowledgeLabels', activity.difficultyOfImplementation.knowledge - 1); - this.TimeLabel = dataStore.getMetaString('labels', activity.difficultyOfImplementation.time - 1); - this.ResourceLabel = dataStore.getMetaString('labels', activity.difficultyOfImplementation.resources - 1); - this.UsefulnessLabel = dataStore.getMetaString('labels', activity.usefulness - 1); - /* eslint-enable */ - - // Get teams that have implemented this activity - this.updateTeamsImplemented(); - } - } - - updateTeamsImplemented() { - this.teamsImplemented.clear(); - this.teamsByProgressTitle.clear(); - this.progressTitlesWithTeams = []; - - const dataStore = this.loader.datastore; - if (!dataStore || !dataStore.progressStore || !dataStore.meta || !this.currentActivity.uuid) { - return; - } - - const teams = dataStore.meta.teams; - const progressStore = dataStore.progressStore; - const activityUuid = this.currentActivity.uuid; - - // Get all progress titles (excluding the first one which is "Not started") - const inProgressTitles = progressStore.getInProgressTitles(); - const completedTitle = progressStore.getCompletedProgressTitle(); - const allProgressTitles = [...inProgressTitles, completedTitle]; - - // Check each team to see if they have started or completed this activity - for (const teamName of teams) { - const progressTitle = progressStore.getTeamProgressTitle(activityUuid, teamName); - const progressValue = progressStore.getTeamActivityProgressValue(activityUuid, teamName); - - // Only include teams that have made progress (value > 0) - if (progressValue > 0) { - this.teamsImplemented.set(teamName, progressTitle); - - // Group teams by progress title - if (!this.teamsByProgressTitle.has(progressTitle)) { - this.teamsByProgressTitle.set(progressTitle, []); - } - this.teamsByProgressTitle.get(progressTitle)!.push(teamName); - } - } - - // Create ordered list of progress titles that have teams (skip "Not started") - for (const progressTitle of allProgressTitles) { - if (this.teamsByProgressTitle.has(progressTitle)) { - this.progressTitlesWithTeams.push(progressTitle); - } - } - } - - onActivityClicked(activityName: string) { - this.activityClicked.emit(activityName); - } - - onCloseRequested() { - // No-op for report view - } - - // Check if screen is narrow and update property - private checkWidthForActivityPanel(): void { - let elemtn: HTMLElement | null = document.querySelector('app-activity-description'); - if (!elemtn) return; - - const currentWidth = elemtn.offsetWidth; - const wasNarrow = this.isNarrowScreen; - this.isNarrowScreen = currentWidth < 500; - } -} diff --git a/src/app/component/report-config-modal/report-config-modal.component.css b/src/app/component/report-config-modal/report-config-modal.component.css index df2d1760..97ed3b65 100644 --- a/src/app/component/report-config-modal/report-config-modal.component.css +++ b/src/app/component/report-config-modal/report-config-modal.component.css @@ -13,6 +13,29 @@ mat-dialog-title{ padding: 16px 0; } +.config-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + margin-top: 12px; +} + +.config-row-label { + font-size: 0.95em; + white-space: nowrap; +} + +.slider-row { + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.word-cap-slider { + width: 100%; +} + .config-section h3 { margin: 0 0 4px 0; font-size: 1.1em; @@ -25,6 +48,12 @@ mat-dialog-title{ color: var(--text-secondary); } +.select-all-actions { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + .checkbox-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); @@ -64,4 +93,27 @@ mat-dialog-title{ mat-divider { margin: 4px 0; +} + +.column-toggle { + border-radius: 999px; + padding: 2px; + border: 1px solid var(--text-tertiary); + background: var(--background-secondary); +} + +/* buttons */ +.column-toggle .mat-button-toggle { + border-radius: 999px; + border: none; + padding: 0 16px; + color: var(--text-secondary); + background: transparent; +} + +/* selected */ +.column-toggle .mat-button-toggle-checked { + background: var(--primary-color); + color: var(--text-on-primary); + box-shadow: 0 2px 6px rgba(0,0,0,0.25); } \ No newline at end of file diff --git a/src/app/component/report-config-modal/report-config-modal.component.html b/src/app/component/report-config-modal/report-config-modal.component.html index 298ca173..ad3ec340 100644 --- a/src/app/component/report-config-modal/report-config-modal.component.html +++ b/src/app/component/report-config-modal/report-config-modal.component.html @@ -1,16 +1,56 @@

Report Configuration

- + + +
+

Display Configuration

+ +
+ Column Grouping: + + By Progress Stage + By Team + +
+ + + Show Description + + +
+ Description Word Cap: {{ config.descriptionWordCap }} + + +
+
+ + + +
-

Levels

-

Uncheck levels you want to exclude from the report.

+

Teams

+

Select which teams to include in the report.

+
+ + + + + + +
- - Level {{ level }} + + {{ team }}
@@ -22,9 +62,7 @@

Levels

Dimensions

Uncheck dimensions to exclude all their activities.

- {{ dim }} @@ -38,9 +76,7 @@

Dimensions

Subdimensions

Uncheck subdimensions to exclude their activities.

- {{ subdim }} @@ -59,9 +95,7 @@

Individual Activities

search
- {{ activity.name }} @@ -73,19 +107,6 @@

Individual Activities

- -
-

Visible Attributes

-

Choose which attribute sections to show for each activity.

-
- - {{ attr.label }} - -
-
@@ -93,4 +114,4 @@

Visible Attributes

-
+ \ No newline at end of file diff --git a/src/app/component/report-config-modal/report-config-modal.component.ts b/src/app/component/report-config-modal/report-config-modal.component.ts index f2e5c043..016679b8 100644 --- a/src/app/component/report-config-modal/report-config-modal.component.ts +++ b/src/app/component/report-config-modal/report-config-modal.component.ts @@ -1,14 +1,21 @@ import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { ReportConfig, ActivityAttributeVisibility } from '../../model/report-config'; +import { + ReportConfig, + ColumnGrouping, + MAX_DESCRIPTION_WORD_CAP +} from '../../model/report-config'; import { Activity } from '../../model/activity-store'; +import { ProgressTitle, TeamGroups } from '../../model/types'; export interface ReportConfigModalData { config: ReportConfig; allActivities: Activity[]; + allTeams: string[]; allDimensions: string[]; allSubdimensions: string[]; - allLevels: number[]; + allProgressTitles: ProgressTitle[]; + teamGroups: TeamGroups; } @Component({ @@ -19,29 +26,13 @@ export interface ReportConfigModalData { export class ReportConfigModalComponent { config: ReportConfig; allActivities: Activity[]; + allTeams: string[]; allDimensions: string[]; allSubdimensions: string[]; - allLevels: number[]; - - // Attribute display labels - attributeLabels: { key: keyof ActivityAttributeVisibility; label: string }[] = [ - { key: 'showDescription', label: 'Description' }, - { key: 'showRisk', label: 'Risk' }, - { key: 'showMeasure', label: 'Measure' }, - { key: 'showAssessment', label: 'Assessment' }, - { key: 'showImplementationGuide', label: 'Implementation Guide' }, - { key: 'showDifficulty', label: 'Difficulty of Implementation' }, - { key: 'showUsefulness', label: 'Usefulness' }, - { key: 'showDependencies', label: 'Dependencies' }, - { key: 'showTools', label: 'Tools' }, - { key: 'showMapping', label: 'Framework Mapping' }, - { key: 'showImplementedBy', label: 'Implemented By' }, - { key: 'showTags', label: 'Tags' }, - { key: 'showComments', label: 'Comments' }, - ]; - - // Activity search + allProgressTitles: ProgressTitle[]; + teamGroups: TeamGroups; activitySearchQuery: string = ''; + maxWordCap: number = MAX_DESCRIPTION_WORD_CAP; constructor( public dialogRef: MatDialogRef, @@ -50,25 +41,57 @@ export class ReportConfigModalComponent { // Deep copy config to avoid mutating the original until save this.config = JSON.parse(JSON.stringify(data.config)); this.allActivities = data.allActivities; + this.allTeams = data.allTeams; this.allDimensions = data.allDimensions; this.allSubdimensions = data.allSubdimensions; - this.allLevels = data.allLevels; + this.allProgressTitles = data.allProgressTitles || []; + this.teamGroups = data.teamGroups || {}; } - // --- Level toggling --- - isLevelExcluded(level: number): boolean { - return this.config.excludedLevels.includes(level); + setColumnGrouping(grouping: ColumnGrouping): void { + this.config.columnGrouping = grouping; } - toggleLevel(level: number): void { - const idx = this.config.excludedLevels.indexOf(level); + wordCapLabel(value: number): string { + return `${value}`; + } + + onWordCapChange(event: any): void { + if (event.value != null) { + this.config.descriptionWordCap = event.value; + } + } + + // --- Team toggling --- + isTeamSelected(team: string): boolean { + return this.config.selectedTeams.includes(team); + } + + toggleTeam(team: string): void { + const idx = this.config.selectedTeams.indexOf(team); if (idx >= 0) { - this.config.excludedLevels.splice(idx, 1); + this.config.selectedTeams.splice(idx, 1); } else { - this.config.excludedLevels.push(level); + this.config.selectedTeams.push(team); } } + selectAllTeams(): void { + this.config.selectedTeams = [...this.allTeams]; + } + + deselectAllTeams(): void { + this.config.selectedTeams = []; + } + + get groupNames(): string[] { + return Object.keys(this.teamGroups); + } + + selectGroup(group: string): void { + this.config.selectedTeams = [...(this.teamGroups[group] || [])]; + } + // --- Dimension toggling --- isDimensionExcluded(dim: string): boolean { return this.config.excludedDimensions.includes(dim); @@ -120,11 +143,9 @@ export class ReportConfigModalComponent { a => a.name.toLowerCase().includes(query) || a.dimension.toLowerCase().includes(query) ); } - - // --- Attribute toggling --- - toggleAttribute(key: keyof ActivityAttributeVisibility): void { - this.config.attributes[key] = !this.config.attributes[key]; - } + toggleAttribute(key: "showDescription"): void { + this.config[key] = !this.config[key]; +} // --- Actions --- onSave(): void { diff --git a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts index 423e0970..64aaae23 100644 --- a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts +++ b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts @@ -15,36 +15,36 @@ export class SidenavButtonsComponent implements OnInit { 'Matrix', 'Mappings', 'Teams', + 'Report', 'Settings', 'Usage', 'Roadmap', 'DSOMM User Day', 'About Us', - 'Report', ]; Icons: string[] = [ 'pie_chart', 'table_chart', 'timeline', 'people', + 'summarize', 'list', 'description', 'landscape', 'school', 'info', - 'summarize', ]; Routing: string[] = [ '/circular-heatmap', '/matrix', '/mapping', '/teams', + '/report', '/settings', '/usage', '/roadmap', '/userday', '/about', - '/report', ]; isNightMode = false; diff --git a/src/app/model/report-config.ts b/src/app/model/report-config.ts index ba50c31d..ff03d88c 100644 --- a/src/app/model/report-config.ts +++ b/src/app/model/report-config.ts @@ -1,50 +1,28 @@ -export interface ActivityAttributeVisibility { - showDescription: boolean; - showRisk: boolean; - showMeasure: boolean; - showAssessment: boolean; - showImplementationGuide: boolean; - showDifficulty: boolean; - showUsefulness: boolean; - showDependencies: boolean; - showTools: boolean; - showMapping: boolean; - showImplementedBy: boolean; - showTags: boolean; - showComments: boolean; -} +export type ColumnGrouping = 'byProgress' | 'byTeam'; export interface ReportConfig { - excludedLevels: number[]; + columnGrouping: ColumnGrouping; + descriptionWordCap: number; + selectedTeams: string[]; excludedDimensions: string[]; excludedSubdimensions: string[]; excludedActivities: string[]; - attributes: ActivityAttributeVisibility; + showDescription: boolean; } const STORAGE_KEY = 'ReportConfig'; +const DEFAULT_DESCRIPTION_WORD_CAP = 25; +export const MAX_DESCRIPTION_WORD_CAP = 600; export function getDefaultReportConfig(): ReportConfig { return { - excludedLevels: [], + columnGrouping: 'byProgress', + descriptionWordCap: DEFAULT_DESCRIPTION_WORD_CAP, + selectedTeams: [], excludedDimensions: [], excludedSubdimensions: [], excludedActivities: [], - attributes: { - showDescription: true, - showRisk: false, - showMeasure: false, - showAssessment: false, - showImplementationGuide: false, - showDifficulty: false, - showUsefulness: false, - showDependencies: false, - showTools: false, - showMapping: false, - showImplementedBy: true, - showTags: false, - showComments: false, - }, + showDescription: true, }; } @@ -56,11 +34,13 @@ export function getReportConfig(): ReportConfig { // Merge with defaults to ensure all keys exist const defaults = getDefaultReportConfig(); return { - excludedLevels: parsed.excludedLevels ?? defaults.excludedLevels, + columnGrouping: parsed.columnGrouping ?? defaults.columnGrouping, + descriptionWordCap: parsed.descriptionWordCap ?? defaults.descriptionWordCap, + selectedTeams: parsed.selectedTeams ?? defaults.selectedTeams, excludedDimensions: parsed.excludedDimensions ?? defaults.excludedDimensions, excludedSubdimensions: parsed.excludedSubdimensions ?? defaults.excludedSubdimensions, excludedActivities: parsed.excludedActivities ?? defaults.excludedActivities, - attributes: { ...defaults.attributes, ...(parsed.attributes ?? {}) }, + showDescription: parsed.showDescription ?? defaults.showDescription, }; } } catch (e) { diff --git a/src/app/pages/report/report.component.css b/src/app/pages/report/report.component.css index e29898f2..f4fc66f3 100644 --- a/src/app/pages/report/report.component.css +++ b/src/app/pages/report/report.component.css @@ -4,10 +4,18 @@ margin: 0 auto; } +.legend { + display: block; + margin-top: 8px; + font-size: 0.9em; + color: var(--text-secondary); +} + +/* Toolbar */ .report-toolbar { display: flex; align-items: center; - gap: 16px; + gap: 12px; margin-bottom: 24px; flex-wrap: wrap; } @@ -42,37 +50,223 @@ height: 48px; } +.section-title { + font-size: 1.3em; + font-weight: 600; + margin: 24px 0 8px 0; + padding-bottom: 4px; + border-bottom: 2px solid var(--text-tertiary); + color: var(--primary-color); +} + .dimension-section { margin-bottom: 32px; } -.dimension-title { - font-size: 1.5em; +.subdimension-group { + margin-bottom: 16px; +} + +.subdimension-title { + font-size: 1.05em; + font-weight: 500; + margin: 16px 0 8px 0; + padding-left: 0; + color: var(--text-primary); + border-bottom: 1px solid var(--text-tertiary); +} + +.report-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85em; + margin-bottom: 8px; +} + +.report-table th, +.report-table td { + border: 1px solid var(--text-tertiary); + padding: 4px 8px; + text-align: left; + vertical-align: top; + color: var(--text-primary); +} + +.report-table thead th { + background-color: var(--background-tertiary); font-weight: 600; - margin: 0 0 16px 0; - padding-bottom: 8px; - border-bottom: 2px solid var(--text-tertiary); + font-size: 0.9em; + white-space: nowrap; } -.subdimension-section { - margin-bottom: 24px; - margin-left: 16px; +/* Column widths */ +.col-level { + width: 50px; + text-align: center; } -.subdimension-title { - font-size: 1.2em; +.col-activity { + min-width: 180px; +} + + +.col-description { + min-width: 220px; +} + +.col-progress { + width: 100px; + font-size: 0.85em; +} + +.col-team { + width: 70px; + text-align: center; + font-size: 0.85em; +} + +/* Cell styles */ +.cell-level { + text-align: center; font-weight: 500; - margin: 0 0 12px 0; +} + +.cell-activity { + font-weight: 400; +} + +.cell-subdimension { + color: var(--text-secondary); + font-size: 0.9em; +} + +.cell-description { + font-size: 0.9em; color: var(--text-secondary); } -app-report-activity { - display: block; - margin-bottom: 24px; - padding-bottom: 24px; - border-bottom: 1px solid var(--text-tertiary); +::ng-deep .description-content p:first-child { + margin-top: 0; + padding-top: 0; +} + +::ng-deep .description-content p:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +::ng-deep .description-content img { + max-width: 100%; +} + +.cell-progress { + font-size: 0.85em; +} + +.cell-team { + text-align: center; + font-size: 1.1em; +} + +.cell-center { + text-align: center; +} + +/* Overview */ +.overview-section { + margin-bottom: 32px; } -app-report-activity:last-child { - border-bottom: none; +.overview-table { + max-width: 500px; } + +.completion-bar { + display: inline-block; + width: 60px; + height: 8px; + background: var(--text-tertiary); + border-radius: 4px; + vertical-align: middle; + margin-right: 6px; + overflow: hidden; +} + +.completion-fill { + display: block; + height: 100%; + background: var(--primary-color); /* green anyways */ + border-radius: 4px; +} + +/* ============ PRINT STYLES ============ */ +@media print { + + .no-print, + app-top-header, + .report-toolbar { + display: none !important; + } + + .report-container { + padding: 0; + max-width: 100%; + margin: 0; + } + + .legend { + font-size: 0.7em; + } + + .section-title { + font-size: 14pt; + margin: 12pt 0 4pt 0; + page-break-after: avoid; + color: #1a5276; + } + + .subdimension-title { + font-size: 12pt; + margin: 8pt 0 4pt 0; + color: #666; + border-bottom: 1px solid #ccc; + } + + .report-table { + font-size: 9pt; + page-break-inside: auto; + } + + .report-table tr { + page-break-inside: avoid; + } + + .report-table th, + .report-table td { + padding: 2px 4px; + border: 1px solid #999; + color: #000; + } + + .report-table thead th { + background-color: #f5f6fa; + } + + .dimension-section { + page-break-before: auto; + margin-bottom: 12pt; + } + + .subdimension-group { + page-break-inside: avoid; + margin-bottom: 12pt; + } + + .overview-section { + page-break-after: avoid; + } + + .completion-bar { + display: none; + } +} \ No newline at end of file diff --git a/src/app/pages/report/report.component.html b/src/app/pages/report/report.component.html index b95942b9..47cbcb26 100644 --- a/src/app/pages/report/report.component.html +++ b/src/app/pages/report/report.component.html @@ -1,13 +1,20 @@
-
+
+ {{ totalFilteredActivities }} activities + + · {{ reportConfig.selectedTeams.length }} teams +
@@ -21,19 +28,91 @@
-
-
-

{{ dimension.name }}

+
+ + +
+

Overview

+ + + + + + + + + + + + + + + + + +
LevelTotal ActivitiesCompletedCompletion
{{ row.level }}{{ row.totalActivities }}{{ row.completedCount }} + + + + {{ row.completionPercent }}% +
+ + + '—' - No progress | '◐' - Partly Implemented | '✓' - Fully Implemented + + +
+ +
+

{{ dimension.name }}

-
-

{{ subdimension.name }}

+
+

{{ subDimension.name }}

+ + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelActivityDescription{{ title }}{{ team }}
{{ activity.level }}{{ activity.name }} +
+
+ {{ getTeamsForProgress(activity, title) }} + + {{ getTeamProgressIcon(activity, team) }} +
-
+
+
-
+
\ No newline at end of file diff --git a/src/app/pages/report/report.component.ts b/src/app/pages/report/report.component.ts index bddbf8f8..50d559f9 100644 --- a/src/app/pages/report/report.component.ts +++ b/src/app/pages/report/report.component.ts @@ -1,22 +1,33 @@ import { Component, OnInit } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { LoaderService } from '../../service/loader/data-loader.service'; +import { SettingsService } from '../../service/settings/settings.service'; import { Activity } from '../../model/activity-store'; +import { MarkdownText } from '../../model/markdown-text'; import { DataStore } from '../../model/data-store'; +import { ProgressStore } from '../../model/progress-store'; import { ReportConfig, getReportConfig, saveReportConfig } from '../../model/report-config'; import { ReportConfigModalComponent, ReportConfigModalData, } from '../../component/report-config-modal/report-config-modal.component'; +import { ProgressTitle } from '../../model/types'; -export interface ReportDimension { +export interface ReportSubDimension { name: string; - subdimensions: ReportSubdimension[]; + activities: Activity[]; } -export interface ReportSubdimension { +export interface ReportDimension { name: string; - activities: Activity[]; + subDimensions: ReportSubDimension[]; +} + +export interface LevelOverview { + level: number; + totalActivities: number; + completedCount: number; + completionPercent: number; } @Component({ @@ -28,14 +39,24 @@ export class ReportComponent implements OnInit { reportConfig: ReportConfig; allActivities: Activity[] = []; filteredDimensions: ReportDimension[] = []; + levelOverview: LevelOverview[] = []; isLoading: boolean = true; // For the config modal allDimensionNames: string[] = []; allSubdimensionNames: string[] = []; - allLevels: number[] = []; + allTeams: string[] = []; + + allProgressTitles: ProgressTitle[] = []; - constructor(private loader: LoaderService, private dialog: MatDialog) { + // Max level from settings + maxLevel: number = 0; + + constructor( + private loader: LoaderService, + private settings: SettingsService, + private dialog: MatDialog + ) { this.reportConfig = getReportConfig(); } @@ -43,6 +64,10 @@ export class ReportComponent implements OnInit { this.loadActivities(); } + get progressStore(): ProgressStore | undefined { + return this.loader.datastore?.progressStore ?? undefined; + } + loadActivities(): void { this.isLoading = true; this.loader @@ -53,22 +78,32 @@ export class ReportComponent implements OnInit { return; } - this.allActivities = dataStore.activityStore.getAllActivities(); + this.maxLevel = this.settings.getMaxLevel() || dataStore.getMaxLevel(); + this.allActivities = dataStore.activityStore.getAllActivitiesUpToLevel(this.maxLevel); - // Collect unique dimensions, subdimensions, levels const dimensionSet = new Set(); const subdimensionSet = new Set(); - const levelSet = new Set(); for (const activity of this.allActivities) { dimensionSet.add(activity.category); subdimensionSet.add(activity.dimension); - levelSet.add(activity.level); } this.allDimensionNames = Array.from(dimensionSet).sort(); this.allSubdimensionNames = Array.from(subdimensionSet).sort(); - this.allLevels = Array.from(levelSet).sort((a, b) => a - b); + this.allTeams = dataStore?.meta?.teams || []; + + // Collect progress titles + if (dataStore.progressStore) { + const inProgress = dataStore.progressStore.getInProgressTitles(); + const completed = dataStore.progressStore.getCompletedProgressTitle(); + this.allProgressTitles = [...inProgress, completed].filter(t => !!t); + } + + // Auto-select all teams if none selected yet + if (this.reportConfig.selectedTeams.length === 0 && this.allTeams.length > 0) { + this.reportConfig.selectedTeams = [...this.allTeams]; + } this.applyFilters(); this.isLoading = false; @@ -88,8 +123,6 @@ export class ReportComponent implements OnInit { if (config.excludedDimensions.includes(activity.category)) return false; // 2. Check subdimension (dimension) if (config.excludedSubdimensions.includes(activity.dimension)) return false; - // 3. Check level - if (config.excludedLevels.includes(activity.level)) return false; // 4. Check individual activity if (config.excludedActivities.includes(activity.uuid)) return false; return true; @@ -100,39 +133,157 @@ export class ReportComponent implements OnInit { for (const activity of filtered) { if (!dimensionMap.has(activity.category)) { - dimensionMap.set(activity.category, new Map()); + dimensionMap.set(activity.category, new Map()); } - const subdimMap = dimensionMap.get(activity.category)!; - if (!subdimMap.has(activity.dimension)) { - subdimMap.set(activity.dimension, []); + const subMap = dimensionMap.get(activity.category)!; + if (!subMap.has(activity.dimension)) { + subMap.set(activity.dimension, []); } - subdimMap.get(activity.dimension)!.push(activity); + subMap.get(activity.dimension)!.push(activity); } - // Convert to array structure sorted by name this.filteredDimensions = []; const sortedDimensions = Array.from(dimensionMap.keys()).sort(); for (const dimName of sortedDimensions) { - const subdimMap = dimensionMap.get(dimName)!; - const subdimensions: ReportSubdimension[] = []; - const sortedSubdims = Array.from(subdimMap.keys()).sort(); - for (const subdimName of sortedSubdims) { - const activities = subdimMap.get(subdimName)!; - // Sort activities by level, then by name - activities.sort((a, b) => a.level - b.level || a.name.localeCompare(b.name)); - subdimensions.push({ name: subdimName, activities }); + const subMap = dimensionMap.get(dimName)!; + const subDimensions: ReportSubDimension[] = []; + const sortedSubDimensions = Array.from(subMap.keys()).sort(); + + for (const subDimName of sortedSubDimensions) { + const activities = subMap.get(subDimName)!; + activities.sort((a, b) => { + if (a.level !== b.level) return a.level - b.level; + return a.name.localeCompare(b.name); + }); + subDimensions.push({ name: subDimName, activities }); + } + this.filteredDimensions.push({ name: dimName, subDimensions }); + } + + this.buildLevelOverview(filtered); + } + + buildLevelOverview(activities: Activity[]): void { + const levelMap = new Map(); + + for (const activity of activities) { + if (!levelMap.has(activity.level)) { + levelMap.set(activity.level, { total: 0, completed: 0 }); + } + const entry = levelMap.get(activity.level)!; + entry.total++; + + if (this.reportConfig.selectedTeams.length > 0) { + const allCompleted = this.reportConfig.selectedTeams.every(team => + this.isActivityCompletedByTeam(activity, team) + ); + if (allCompleted) { + entry.completed++; + } + } + } + + this.levelOverview = Array.from(levelMap.entries()) + .sort(([a], [b]) => a - b) + .map(([level, data]) => ({ + level, + totalActivities: data.total, + completedCount: data.completed, + completionPercent: data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0, + })); + } + + // --- Progress helpers --- + + isActivityCompletedByTeam(activity: Activity, teamName: string): boolean { + if (!this.progressStore || !activity.uuid) return false; + const completedTitle = this.progressStore.getCompletedProgressTitle(); + if (!completedTitle) return false; + const teamTitle = this.progressStore.getTeamProgressTitle(activity.uuid, teamName); + return teamTitle === completedTitle; + } + + getTeamProgressIcon(activity: Activity, teamName: string): string { + if (!this.progressStore || !activity.uuid) return '—'; + const progressValue = this.progressStore.getTeamActivityProgressValue(activity.uuid, teamName); + if (progressValue >= 1) return '✓'; + if (progressValue > 0) return '◐'; + return '—'; + } + + getTeamsForProgress(activity: Activity, progressTitle: ProgressTitle): string { + if (!this.progressStore || !activity.uuid) return ''; + const teams: string[] = []; + for (const team of this.reportConfig.selectedTeams) { + const teamTitle = this.progressStore.getTeamProgressTitle(activity.uuid, team); + if (teamTitle === progressTitle) { + teams.push(team); } - this.filteredDimensions.push({ name: dimName, subdimensions }); } + return teams.join(', ') || '—'; + } + + + truncateWords(text: any, max: number): string { + if (!text) return ''; + const str = String(text); + const words = str.split(/\s+/); + if (words.length <= max) return str; + return words.slice(0, max).join(' ') + '...'; + } + + renderCappedDescription(description: any, wordCap: number): string { + if (!description) return ''; + // First, render the full markdown + const rendered = new MarkdownText(String(description)).render(); + // Then, apply the word cap on the rendered HTML + const container = document.createElement('div'); + container.innerHTML = rendered; + const textContent = (container.textContent || '').trim(); + const words = textContent.split(/\s+/).filter(w => w.length > 0); + + if (words.length <= wordCap) { + return rendered; + } + + // Truncate text nodes in the DOM, preserving HTML structure + let remaining = wordCap; + const truncateNode = (node: Node): boolean => { + if (remaining <= 0) { + node.parentNode?.removeChild(node); + return true; + } + if (node.nodeType === Node.TEXT_NODE) { + const nodeWords = (node.textContent || '').split(/\s+/).filter(w => w.length > 0); + if (nodeWords.length <= remaining) { + remaining -= nodeWords.length; + return false; + } + node.textContent = nodeWords.slice(0, remaining).join(' ') + '…'; + remaining = 0; + return false; + } + // Element node — walk children + const children = Array.from(node.childNodes); + for (const child of children) { + truncateNode(child); + } + return false; + }; + truncateNode(container); + + return container.innerHTML; } openConfigModal(): void { const modalData: ReportConfigModalData = { config: this.reportConfig, allActivities: this.allActivities, + allTeams: this.allTeams, allDimensions: this.allDimensionNames, allSubdimensions: this.allSubdimensionNames, - allLevels: this.allLevels, + allProgressTitles: this.allProgressTitles, + teamGroups: this.loader.datastore?.meta?.teamGroups || {}, }; const dialogRef = this.dialog.open(ReportConfigModalComponent, { @@ -150,11 +301,20 @@ export class ReportComponent implements OnInit { }); } + printReport(): void { + alert(`For best results, please ensure the following before printing: +- Close the app Menu. +- Enable Light Mode. +- In the browser print settings, set margins to "None" and deselect Header and Footer. + `); + window.print(); + } + get totalFilteredActivities(): number { let count = 0; for (const dim of this.filteredDimensions) { - for (const subdim of dim.subdimensions) { - count += subdim.activities.length; + for (const sub of dim.subDimensions) { + count += sub.activities.length; } } return count; From 92031216d14732e648bbe4f55181fe305e316767 Mon Sep 17 00:00:00 2001 From: sawankshrma Date: Mon, 23 Feb 2026 18:38:08 +0530 Subject: [PATCH 3/3] lint fix --- .../report-config-modal.component.html | 53 +++++++++++++------ .../report-config-modal.component.ts | 12 ++--- src/app/pages/report/report.component.html | 14 ++--- src/app/pages/report/report.component.ts | 3 +- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/app/component/report-config-modal/report-config-modal.component.html b/src/app/component/report-config-modal/report-config-modal.component.html index ad3ec340..98f18c81 100644 --- a/src/app/component/report-config-modal/report-config-modal.component.html +++ b/src/app/component/report-config-modal/report-config-modal.component.html @@ -1,28 +1,38 @@

Report Configuration

-

Display Configuration

Column Grouping: - + By Progress Stage By Team
- - Show Description - + + Show Description +
- Description Word Cap: {{ config.descriptionWordCap }} - + Description Word Cap: {{ config.descriptionWordCap }} +
@@ -36,7 +46,10 @@

Teams

-
- + {{ team }}
@@ -62,7 +78,9 @@

Teams

Dimensions

Uncheck dimensions to exclude all their activities.

- {{ dim }} @@ -76,7 +94,9 @@

Dimensions

Subdimensions

Uncheck subdimensions to exclude their activities.

- {{ subdim }} @@ -95,7 +115,9 @@

Individual Activities

search
- {{ activity.name }} @@ -106,7 +128,6 @@

Individual Activities

- @@ -114,4 +135,4 @@

Individual Activities

-
\ No newline at end of file + diff --git a/src/app/component/report-config-modal/report-config-modal.component.ts b/src/app/component/report-config-modal/report-config-modal.component.ts index 016679b8..2b5b704c 100644 --- a/src/app/component/report-config-modal/report-config-modal.component.ts +++ b/src/app/component/report-config-modal/report-config-modal.component.ts @@ -1,10 +1,6 @@ import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { - ReportConfig, - ColumnGrouping, - MAX_DESCRIPTION_WORD_CAP -} from '../../model/report-config'; +import { ReportConfig, ColumnGrouping, MAX_DESCRIPTION_WORD_CAP } from '../../model/report-config'; import { Activity } from '../../model/activity-store'; import { ProgressTitle, TeamGroups } from '../../model/types'; @@ -143,9 +139,9 @@ export class ReportConfigModalComponent { a => a.name.toLowerCase().includes(query) || a.dimension.toLowerCase().includes(query) ); } - toggleAttribute(key: "showDescription"): void { - this.config[key] = !this.config[key]; -} + toggleAttribute(key: 'showDescription'): void { + this.config[key] = !this.config[key]; + } // --- Actions --- onSave(): void { diff --git a/src/app/pages/report/report.component.html b/src/app/pages/report/report.component.html index 47cbcb26..7fc7f833 100644 --- a/src/app/pages/report/report.component.html +++ b/src/app/pages/report/report.component.html @@ -29,7 +29,6 @@
-

Overview

@@ -57,10 +56,9 @@

Overview

- + '—' - No progress | '◐' - Partly Implemented | '✓' - Fully Implemented -
@@ -91,8 +89,11 @@

{{ subDimension.name }}

{{ activity.level }} {{ activity.name }} -
+
@@ -113,6 +114,5 @@

{{ subDimension.name }}

-
-
\ No newline at end of file +
diff --git a/src/app/pages/report/report.component.ts b/src/app/pages/report/report.component.ts index 50d559f9..b094a5fa 100644 --- a/src/app/pages/report/report.component.ts +++ b/src/app/pages/report/report.component.ts @@ -223,7 +223,6 @@ export class ReportComponent implements OnInit { return teams.join(', ') || '—'; } - truncateWords(text: any, max: number): string { if (!text) return ''; const str = String(text); @@ -246,7 +245,7 @@ export class ReportComponent implements OnInit { return rendered; } - // Truncate text nodes in the DOM, preserving HTML structure + // Truncate text nodes in the DOM, preserving HTML structure let remaining = wordCap; const truncateNode = (node: Node): boolean => { if (remaining <= 0) {