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 aed71fa0..f7fb235a 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,6 +33,10 @@ 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 { MatTooltipModule } from '@angular/material/tooltip';
+import { ReportComponent } from './pages/report/report.component';
+import { ReportConfigModalComponent } from './component/report-config-modal/report-config-modal.component';
+import { TeamSelectorComponent } from './component/team-selector/team-selector.component';
+import { ColResizeDirective } from './directive/col-resize.directive';
@NgModule({
declarations: [
@@ -56,6 +61,10 @@ import { MatTooltipModule } from '@angular/material/tooltip';
ProgressSliderComponent,
KpiComponent,
SettingsComponent,
+ ReportComponent,
+ ReportConfigModalComponent,
+ TeamSelectorComponent,
+ ColResizeDirective,
],
imports: [
BrowserModule,
@@ -65,6 +74,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
MatDialogModule,
ReactiveFormsModule,
MatToolbarModule,
+ MatMenuModule,
FormsModule,
HttpClientModule,
TeamsGroupsEditorModule,
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..c20b55e4
--- /dev/null
+++ b/src/app/component/report-config-modal/report-config-modal.component.css
@@ -0,0 +1,145 @@
+.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-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;
+ font-weight: 500;
+}
+
+.config-hint {
+ margin: 0 0 12px 0;
+ font-size: 0.85em;
+ color: var(--text-secondary);
+}
+
+.select-all-actions {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.config-row-label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.config-row-label mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ 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);
+}
+
+.references-section {
+ margin-top: 12px;
+}
+
+.references-subtoggle {
+ display: flex;
+ gap: 12px;
+ margin-top: 6px;
+ margin-left: 28px;
+ flex-wrap: wrap;
+}
+
+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
new file mode 100644
index 00000000..e2fc14c3
--- /dev/null
+++ b/src/app/component/report-config-modal/report-config-modal.component.html
@@ -0,0 +1,212 @@
+
Report Configuration
+
+
+
+
+
Display Configuration
+
+
+ Column Grouping:
+
+ By Progress Stage
+ By Team
+
+
+
+
+
+
+
+
+
Activity Attributes
+
Choose which activity fields to show as columns in the report.
+
+
+
+ UUID
+
+
+ Description
+
+
+ Risk
+
+
+ Measure
+
+
+ Difficulty of Implementation
+
+
+ Usefulness
+
+
+ Implementation
+
+
+ Depends On
+
+
+
+
+
+
+ References
+
+
+
+ ISO 27001:2017
+
+
+ ISO 27001:2022
+
+
+ SAMM2
+
+
+ OpenCRE
+
+
+
+
+
+
+
+
+ Tags
+
+
+
+
+ Word Cap
+ info
+ :
+ {{ config.descriptionWordCap }}
+
+
+
+
+
+
+
+
+
+
+
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 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..43a85f30
--- /dev/null
+++ b/src/app/component/report-config-modal/report-config-modal.component.ts
@@ -0,0 +1,136 @@
+import { Component, Inject } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import {
+ ReportConfig,
+ ActivityAttributes,
+ 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[];
+ allProgressTitles: ProgressTitle[];
+ teamGroups: TeamGroups;
+}
+
+@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[];
+ allTeams: string[];
+ allDimensions: string[];
+ allSubdimensions: string[];
+ allProgressTitles: ProgressTitle[];
+ teamGroups: TeamGroups;
+ activitySearchQuery: string = '';
+ maxWordCap: number = MAX_DESCRIPTION_WORD_CAP;
+
+ 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.allTeams = data.allTeams;
+ this.allDimensions = data.allDimensions;
+ this.allSubdimensions = data.allSubdimensions;
+ this.allProgressTitles = data.allProgressTitles || [];
+ this.teamGroups = data.teamGroups || {};
+ }
+
+ setColumnGrouping(grouping: ColumnGrouping): void {
+ this.config.columnGrouping = grouping;
+ }
+
+ wordCapLabel(value: number): string {
+ return `${value}`;
+ }
+
+ onWordCapChange(event: any): void {
+ if (event.value != null) {
+ this.config.descriptionWordCap = event.value;
+ }
+ }
+
+ // --- Activity attribute toggling ---
+ toggleActivityAttribute(key: keyof ActivityAttributes): void {
+ (this.config.activityAttributes as any)[key] = !this.config.activityAttributes[key];
+ }
+
+ get hasAnyMarkdownAttribute(): boolean {
+ const a = this.config.activityAttributes;
+ return a.showDescription || a.showRisk || a.showMeasure || a.showEvidence;
+ }
+
+ // --- 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)
+ );
+ }
+
+ // --- 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..64aaae23 100644
--- a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts
+++ b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts
@@ -15,6 +15,7 @@ export class SidenavButtonsComponent implements OnInit {
'Matrix',
'Mappings',
'Teams',
+ 'Report',
'Settings',
'Usage',
'Roadmap',
@@ -26,6 +27,7 @@ export class SidenavButtonsComponent implements OnInit {
'table_chart',
'timeline',
'people',
+ 'summarize',
'list',
'description',
'landscape',
@@ -37,6 +39,7 @@ export class SidenavButtonsComponent implements OnInit {
'/matrix',
'/mapping',
'/teams',
+ '/report',
'/settings',
'/usage',
'/roadmap',
diff --git a/src/app/component/team-selector/team-selector.component.css b/src/app/component/team-selector/team-selector.component.css
new file mode 100644
index 00000000..6a35dc6f
--- /dev/null
+++ b/src/app/component/team-selector/team-selector.component.css
@@ -0,0 +1,43 @@
+.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);
+}
+
+.select-all-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.group-section {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: 4px;
+ padding-left: 12px;
+ border-left: 1px solid var(--text-tertiary);
+}
+
+.group-label {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.checkbox-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 8px;
+}
\ No newline at end of file
diff --git a/src/app/component/team-selector/team-selector.component.html b/src/app/component/team-selector/team-selector.component.html
new file mode 100644
index 00000000..be96913b
--- /dev/null
+++ b/src/app/component/team-selector/team-selector.component.html
@@ -0,0 +1,29 @@
+
+
Teams
+
Select which teams to include in the report.
+
+
+
+ 0" class="group-section">
+ Group:
+
+
+
+
+
+
+
+
+ {{ team }}
+
+
+
diff --git a/src/app/component/team-selector/team-selector.component.ts b/src/app/component/team-selector/team-selector.component.ts
new file mode 100644
index 00000000..52eef4a3
--- /dev/null
+++ b/src/app/component/team-selector/team-selector.component.ts
@@ -0,0 +1,54 @@
+import { Component, Input, Output, EventEmitter } from '@angular/core';
+import { TeamGroups } from '../../model/types';
+
+@Component({
+ selector: 'app-team-selector',
+ templateUrl: './team-selector.component.html',
+ styleUrls: ['./team-selector.component.css'],
+})
+export class TeamSelectorComponent {
+ @Input() allTeams: string[] = [];
+ @Input() selectedTeams: string[] = [];
+ @Input() teamGroups: TeamGroups = {};
+
+ @Output() selectedTeamsChange = new EventEmitter();
+
+ selectedGroupName: string = '';
+
+ isTeamSelected(team: string): boolean {
+ return this.selectedTeams.includes(team);
+ }
+
+ toggleTeam(team: string): void {
+ const idx = this.selectedTeams.indexOf(team);
+ if (idx >= 0) {
+ this.selectedTeams.splice(idx, 1);
+ } else {
+ this.selectedTeams.push(team);
+ }
+ this.selectedGroupName = '';
+ this.selectedTeamsChange.emit([...this.selectedTeams]);
+ }
+
+ selectAllTeams(): void {
+ this.selectedTeams = [...this.allTeams];
+ this.selectedGroupName = '';
+ this.selectedTeamsChange.emit([...this.selectedTeams]);
+ }
+
+ deselectAllTeams(): void {
+ this.selectedTeams = [];
+ this.selectedGroupName = '';
+ this.selectedTeamsChange.emit([...this.selectedTeams]);
+ }
+
+ get groupNames(): string[] {
+ return Object.keys(this.teamGroups);
+ }
+
+ selectGroup(group: string): void {
+ this.selectedTeams = [...(this.teamGroups[group] || [])];
+ this.selectedGroupName = group;
+ this.selectedTeamsChange.emit([...this.selectedTeams]);
+ }
+}
diff --git a/src/app/directive/col-resize.directive.ts b/src/app/directive/col-resize.directive.ts
new file mode 100644
index 00000000..c232dc1c
--- /dev/null
+++ b/src/app/directive/col-resize.directive.ts
@@ -0,0 +1,68 @@
+import { Directive, ElementRef, Renderer2, OnInit } from '@angular/core';
+
+@Directive({
+ selector: '[appColResize]',
+})
+export class ColResizeDirective implements OnInit {
+ private startX = 0;
+ private startWidth = 0;
+ private th!: HTMLElement;
+
+ private mouseMoveListener: (() => void) | null = null;
+ private mouseUpListener: (() => void) | null = null;
+
+ constructor(private el: ElementRef, private renderer: Renderer2) {}
+
+ ngOnInit(): void {
+ this.th = this.el.nativeElement;
+ this.renderer.setStyle(this.th, 'position', 'relative');
+
+ const handle = this.renderer.createElement('div');
+ this.renderer.addClass(handle, 'col-resize-handle');
+ this.renderer.appendChild(this.th, handle);
+
+ this.renderer.listen(handle, 'mousedown', (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ this.startX = event.clientX;
+ this.startWidth = this.th.offsetWidth;
+
+ this.mouseMoveListener = this.renderer.listen('document', 'mousemove', (e: MouseEvent) => {
+ const diff = e.clientX - this.startX;
+ const newWidth = Math.max(40, this.startWidth + diff);
+ this.applyWidthToAllTables(newWidth);
+ });
+
+ this.mouseUpListener = this.renderer.listen('document', 'mouseup', () => {
+ if (this.mouseMoveListener) {
+ this.mouseMoveListener();
+ this.mouseMoveListener = null;
+ }
+ if (this.mouseUpListener) {
+ this.mouseUpListener();
+ this.mouseUpListener = null;
+ }
+ });
+ });
+ }
+
+ private applyWidthToAllTables(width: number): void {
+ const colIndex = this.getColumnIndex(this.th);
+ if (colIndex < 0) return;
+
+ const tables = document.querySelectorAll('.resizable-table');
+ tables.forEach(table => {
+ const ths = table.querySelectorAll('thead th');
+ if (colIndex < ths.length) {
+ (ths[colIndex] as HTMLElement).style.width = `${width}px`;
+ }
+ });
+ }
+
+ private getColumnIndex(th: HTMLElement): number {
+ const row = th.parentElement;
+ if (!row) return -1;
+ const cells = Array.from(row.children);
+ return cells.indexOf(th);
+ }
+}
diff --git a/src/app/model/report-config.ts b/src/app/model/report-config.ts
new file mode 100644
index 00000000..64fc19b9
--- /dev/null
+++ b/src/app/model/report-config.ts
@@ -0,0 +1,126 @@
+export type ColumnGrouping = 'byProgress' | 'byTeam';
+
+export interface ActivityAttributes {
+ showUuid: boolean;
+ showDescription: boolean;
+ showRisk: boolean;
+ showMeasure: boolean;
+ showDifficultyOfImplementation: boolean;
+ showUsefulness: boolean;
+ showImplementation: boolean;
+ showDependsOn: boolean;
+ showReferences: boolean;
+ showReferencesIso27001_2017: boolean;
+ showReferencesIso27001_2022: boolean;
+ showReferencesSamm2: boolean;
+ showReferencesOpenCRE: boolean;
+ showEvidence: boolean;
+ showTags: boolean;
+}
+
+export interface ReportConfig {
+ columnGrouping: ColumnGrouping;
+ descriptionWordCap: number;
+ selectedTeams: string[];
+ excludedDimensions: string[];
+ excludedSubdimensions: string[];
+ excludedActivities: string[];
+ activityAttributes: ActivityAttributes;
+}
+
+const STORAGE_KEY = 'ReportConfig';
+const DEFAULT_DESCRIPTION_WORD_CAP = 25;
+export const MAX_DESCRIPTION_WORD_CAP = 600;
+
+export function getDefaultActivityAttributes(): ActivityAttributes {
+ return {
+ showUuid: false,
+ showDescription: true,
+ showRisk: false,
+ showMeasure: false,
+ showDifficultyOfImplementation: false,
+ showUsefulness: false,
+ showImplementation: false,
+ showDependsOn: false,
+ showReferences: false,
+ showReferencesIso27001_2017: true,
+ showReferencesIso27001_2022: true,
+ showReferencesSamm2: true,
+ showReferencesOpenCRE: true,
+ showEvidence: false,
+ showTags: false,
+ };
+}
+
+export function getDefaultReportConfig(): ReportConfig {
+ return {
+ columnGrouping: 'byProgress',
+ descriptionWordCap: DEFAULT_DESCRIPTION_WORD_CAP,
+ selectedTeams: [],
+ excludedDimensions: [],
+ excludedSubdimensions: [],
+ excludedActivities: [],
+ activityAttributes: getDefaultActivityAttributes(),
+ };
+}
+
+export function getReportConfig(): ReportConfig {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored) as Partial;
+ const defaults = getDefaultReportConfig();
+ const defaultAttrs = getDefaultActivityAttributes();
+
+ const parsedAttrs = (parsed.activityAttributes ??
+ parsed['activityAtrributes' as keyof ReportConfig] ??
+ {}) as Partial;
+
+ const legacyShowDesc = (parsed as any).showDescription;
+
+ const activityAttributes: ActivityAttributes = {
+ showUuid: parsedAttrs.showUuid ?? defaultAttrs.showUuid,
+ showDescription:
+ parsedAttrs.showDescription ?? legacyShowDesc ?? defaultAttrs.showDescription,
+ showRisk: parsedAttrs.showRisk ?? defaultAttrs.showRisk,
+ showMeasure: parsedAttrs.showMeasure ?? defaultAttrs.showMeasure,
+ showDifficultyOfImplementation:
+ parsedAttrs.showDifficultyOfImplementation ?? defaultAttrs.showDifficultyOfImplementation,
+ showUsefulness: parsedAttrs.showUsefulness ?? defaultAttrs.showUsefulness,
+ showImplementation: parsedAttrs.showImplementation ?? defaultAttrs.showImplementation,
+ showDependsOn: parsedAttrs.showDependsOn ?? defaultAttrs.showDependsOn,
+ showReferences: parsedAttrs.showReferences ?? defaultAttrs.showReferences,
+ showReferencesIso27001_2017:
+ parsedAttrs.showReferencesIso27001_2017 ?? defaultAttrs.showReferencesIso27001_2017,
+ showReferencesIso27001_2022:
+ parsedAttrs.showReferencesIso27001_2022 ?? defaultAttrs.showReferencesIso27001_2022,
+ showReferencesSamm2: parsedAttrs.showReferencesSamm2 ?? defaultAttrs.showReferencesSamm2,
+ showReferencesOpenCRE:
+ parsedAttrs.showReferencesOpenCRE ?? defaultAttrs.showReferencesOpenCRE,
+ showEvidence: parsedAttrs.showEvidence ?? defaultAttrs.showEvidence,
+ showTags: parsedAttrs.showTags ?? defaultAttrs.showTags,
+ };
+
+ return {
+ 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,
+ activityAttributes,
+ };
+ }
+ } 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..6f8b0870
--- /dev/null
+++ b/src/app/pages/report/report.component.css
@@ -0,0 +1,330 @@
+.report-container {
+ padding: 20px;
+ max-width: 1200px;
+ 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: 12px;
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+}
+
+.report-toolbar button mat-icon {
+ margin-right: 4px;
+}
+
+.activity-count {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+}
+
+.team-selector-section {
+ margin-bottom: 16px;
+ padding: 0 4px;
+}
+
+.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;
+}
+
+.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;
+}
+
+.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;
+}
+
+.resizable-table {
+ table-layout: auto;
+}
+
+.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);
+ overflow: hidden;
+ word-wrap: break-word;
+}
+
+.report-table thead th {
+ background-color: var(--background-tertiary);
+ font-weight: 600;
+ font-size: 0.9em;
+ white-space: nowrap;
+}
+
+/* Column default widths */
+.col-level {
+ width: 50px;
+ text-align: center;
+}
+
+.col-activity {
+ width: 180px;
+}
+
+.col-description {
+ width: 220px;
+}
+
+.col-progress,
+.col-team {
+ width: 100px;
+}
+
+.col-tags {
+ width: 100px;
+}
+
+.col-small {
+ width: 80px;
+ text-align: center;
+}
+
+.col-ref {
+ width: 200px;
+}
+
+.cell-tags {
+ font-size: 0.85em;
+ color: var(--text-secondary);
+}
+
+.cell-ref {
+ font-size: 0.8em;
+ color: var(--text-secondary);
+}
+
+::ng-deep .ref-link {
+ text-decoration: none;
+ font-weight: 600;
+ color: var(--primary-color);
+ margin: 0 2px;
+}
+
+::ng-deep .ref-link:hover {
+ opacity: 0.7;
+}
+
+::ng-deep .ref-uuid {
+ font-family: monospace;
+ font-size: 0.85em;
+ color: var(--text-tertiary);
+ margin-right: 2px;
+}
+
+/* Resize handle */
+::ng-deep .col-resize-handle {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ cursor: col-resize;
+ background: transparent;
+ z-index: 1;
+}
+
+::ng-deep .col-resize-handle:hover {
+ background: var(--primary-color);
+ opacity: 0.5;
+}
+
+/* Cell styles */
+.cell-level {
+ text-align: center;
+ font-weight: 500;
+}
+
+.cell-activity {
+ font-weight: 400;
+}
+
+.cell-team {
+ font-weight: 400;
+}
+
+.cell-subdimension {
+ color: var(--text-secondary);
+ font-size: 0.9em;
+}
+
+.cell-description {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+}
+
+::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-center {
+ text-align: center;
+}
+
+/* Overview */
+.overview-section {
+ margin-bottom: 32px;
+}
+
+.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
new file mode 100644
index 00000000..d5eb4529
--- /dev/null
+++ b/src/app/pages/report/report.component.html
@@ -0,0 +1,263 @@
+
+
+
+
+
+
+
+ {{ totalFilteredActivities }} activities
+ 0">
+ · {{ reportConfig.selectedTeams.length }} teams
+
+
+
+
+
+
+
+
+
+
+
+
filter_list_off
+
No activities match the current report configuration.
+
+
+
+
0" class="report-body">
+
+
+ Overview
+
+
+
+ | Level |
+ Total Activities |
+ Completed |
+ Completion |
+
+
+
+
+ | {{ row.level }} |
+ {{ row.totalActivities }} |
+ {{ row.completedCount }} |
+
+
+
+
+ {{ row.completionPercent }}%
+ |
+
+
+
+
+
+
+ {{ dimension.name }}
+
+
+
{{ subDimension.name }}
+
+
+
+ | Level |
+ Activity |
+
+ UUID
+ |
+
+ Description
+ |
+
+
+
+
+ {{ title }}
+ |
+
+
+
+
+
+ {{ team }}
+ |
+
+
+
+ Risk
+ |
+
+ Measure
+ |
+
+ Difficulty
+ |
+
+ Usefulness
+ |
+
+
+
+ Implementation
+ |
+
+
+
+ Depends On
+ |
+
+
+
+ References
+ |
+
+
+
+ Evidence
+ |
+
+
+
+ Tags
+ |
+
+
+
+
+ | {{ activity.level }} |
+ {{ activity.name }} |
+
+
+
+ {{ activity.uuid }}
+ |
+
+
+
+
+ |
+
+
+
+
+ {{ getTeamsForProgress(activity, title) }}
+ |
+
+
+
+
+
+ {{ getTeamProgressName(activity, team) }}
+ |
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+ {{ formatDifficulty(activity.difficultyOfImplementation) }}
+ |
+
+
+
+ {{ activity.usefulness || '—' }}
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+ {{ formatTags(activity.tags) }}
+ |
+
+
+
+
+
+
+
diff --git a/src/app/pages/report/report.component.ts b/src/app/pages/report/report.component.ts
new file mode 100644
index 00000000..51d9c147
--- /dev/null
+++ b/src/app/pages/report/report.component.ts
@@ -0,0 +1,420 @@
+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,
+ DifficultyOfImplementation,
+ Implementation,
+ ActivityStore,
+} 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, TeamGroups } from '../../model/types';
+
+export interface ReportSubDimension {
+ name: string;
+ activities: Activity[];
+}
+
+export interface ReportDimension {
+ name: string;
+ subDimensions: ReportSubDimension[];
+}
+
+export interface LevelOverview {
+ level: number;
+ totalActivities: number;
+ completedCount: number;
+ completionPercent: number;
+}
+
+@Component({
+ selector: 'app-report',
+ templateUrl: './report.component.html',
+ styleUrls: ['./report.component.css'],
+})
+export class ReportComponent implements OnInit {
+ reportConfig: ReportConfig;
+ allActivities: Activity[] = [];
+ filteredDimensions: ReportDimension[] = [];
+ levelOverview: LevelOverview[] = [];
+ isLoading: boolean = true;
+
+ // For the config modal
+ allDimensionNames: string[] = [];
+ allSubdimensionNames: string[] = [];
+ allTeams: string[] = [];
+ teamGroups: TeamGroups = {};
+
+ allProgressTitles: ProgressTitle[] = [];
+
+ // Max level from settings
+ maxLevel: number = 0;
+
+ private activityStore: ActivityStore | null = null;
+
+ constructor(
+ private loader: LoaderService,
+ private settings: SettingsService,
+ private dialog: MatDialog
+ ) {
+ this.reportConfig = getReportConfig();
+ }
+
+ ngOnInit(): void {
+ this.loadActivities();
+ }
+
+ get progressStore(): ProgressStore | undefined {
+ return this.loader.datastore?.progressStore ?? undefined;
+ }
+
+ loadActivities(): void {
+ this.isLoading = true;
+ this.loader
+ .load()
+ .then((dataStore: DataStore) => {
+ if (!dataStore.activityStore) {
+ this.isLoading = false;
+ return;
+ }
+
+ this.maxLevel = this.settings.getMaxLevel() || dataStore.getMaxLevel();
+ this.allActivities = dataStore.activityStore.getAllActivitiesUpToLevel(this.maxLevel);
+ this.activityStore = dataStore.activityStore;
+
+ const dimensionSet = new Set();
+ const subdimensionSet = new Set();
+
+ for (const activity of this.allActivities) {
+ dimensionSet.add(activity.category);
+ subdimensionSet.add(activity.dimension);
+ }
+
+ this.allDimensionNames = Array.from(dimensionSet).sort();
+ this.allSubdimensionNames = Array.from(subdimensionSet).sort();
+ this.allTeams = dataStore?.meta?.teams || [];
+ this.teamGroups = dataStore?.meta?.teamGroups || {};
+
+ // 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;
+ })
+ .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;
+ // 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 subMap = dimensionMap.get(activity.category)!;
+ if (!subMap.has(activity.dimension)) {
+ subMap.set(activity.dimension, []);
+ }
+ subMap.get(activity.dimension)!.push(activity);
+ }
+
+ this.filteredDimensions = [];
+ const sortedDimensions = Array.from(dimensionMap.keys()).sort();
+ for (const dimName of sortedDimensions) {
+ 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);
+
+ // TEMP DEBUG
+ console.log(
+ `teamTitle="${teamTitle}" | completedTitle="${completedTitle}" | uuid="${activity.uuid}" | team="${teamName}"`
+ );
+ console.log(
+ 'progress keys sample:',
+ Object.keys(this.progressStore.getProgressData()).slice(0, 3)
+ );
+
+ return teamTitle === completedTitle;
+ }
+
+ getTeamProgressName(activity: Activity, teamName: string): string {
+ if (!this.progressStore || !activity.uuid) return '';
+ return this.progressStore.getTeamActivityTitle(activity.uuid, teamName);
+ }
+
+ 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);
+ }
+ }
+ 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,
+ allProgressTitles: this.allProgressTitles,
+ teamGroups: this.loader.datastore?.meta?.teamGroups || {},
+ };
+
+ 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();
+ }
+ });
+ }
+
+ onTeamsChanged(teams: string[]): void {
+ this.reportConfig.selectedTeams = teams;
+ saveReportConfig(this.reportConfig);
+ this.applyFilters();
+ }
+
+ printReport(): void {
+ const menuAlert: Boolean = localStorage.getItem('state.menuIsOpen') === 'true';
+ const darkModeAlert: Boolean = localStorage.getItem('theme') === 'dark';
+
+ if (menuAlert || darkModeAlert) {
+ alert(
+ `${menuAlert ? '- Please close the app Menu before printing.\n' : ''}${
+ darkModeAlert ? '- Please enable Light Mode before printing.\n' : ''
+ }`
+ );
+ } else window.print();
+ }
+
+ formatTags(tags: string[]): string {
+ if (!tags || tags.length === 0) return '—';
+ return tags.join(', ');
+ }
+
+ formatDifficulty(d: DifficultyOfImplementation): string {
+ if (!d) return '—';
+ const parts: string[] = [];
+ if (d.knowledge != null) parts.push(`K:${d.knowledge}`);
+ if (d.time != null) parts.push(`T:${d.time}`);
+ if (d.resources != null) parts.push(`R:${d.resources}`);
+ return parts.length > 0 ? parts.join(' / ') : '—';
+ }
+
+ formatRefList(refs: string[] | undefined): string {
+ if (!refs || refs.length === 0) return '—';
+ return refs.join(', ');
+ }
+
+ formatReferences(refs: any): string {
+ if (!refs) return '—';
+ const attrs = this.reportConfig.activityAttributes;
+ const pairs: string[] = [];
+
+ const renderItems = (key: string, items: any[] | undefined): void => {
+ if (!items || items.length === 0) return;
+
+ const plainItems = items.map(raw => String(raw));
+ const formatted = plainItems.map(item => {
+ if (item.startsWith('http://') || item.startsWith('https://')) {
+ return `↗`;
+ }
+ return item;
+ });
+
+ pairs.push(`${key}: ${formatted.join(', ')}`);
+ };
+
+ if (attrs.showReferencesIso27001_2017) renderItems('iso27001-2017', refs.iso27001_2017);
+ if (attrs.showReferencesIso27001_2022) renderItems('iso27001-2022', refs.iso27001_2022);
+ if (attrs.showReferencesSamm2) renderItems('samm2', refs.samm2);
+ if (attrs.showReferencesOpenCRE) renderItems('openCRE', refs.openCRE);
+
+ return pairs.length > 0 ? `${pairs.join(', ')}` : '—';
+ }
+
+ formatImplementation(impls: Implementation[] | undefined): string {
+ if (!impls || impls.length === 0) return '—';
+ const items = impls.map(impl => {
+ const namePart = impl.name || '?';
+ const linkPart = impl.url
+ ? ` ↗`
+ : '';
+ return `${namePart}${linkPart}`;
+ });
+ return items.join(', ');
+ }
+
+ formatDependsOn(deps: string[] | undefined): string {
+ if (!deps || deps.length === 0) return '—';
+ const items = deps.map(dep => {
+ if (this.activityStore) {
+ const resolved = this.activityStore.getActivityByName(dep);
+ if (resolved && resolved.uuid) {
+ return `${dep} (${resolved.uuid.substring(0, 8)})`;
+ }
+ }
+ return dep;
+ });
+ return items.join(', ');
+ }
+
+ get totalFilteredActivities(): number {
+ let count = 0;
+ for (const dim of this.filteredDimensions) {
+ for (const sub of dim.subDimensions) {
+ count += sub.activities.length;
+ }
+ }
+ return count;
+ }
+}