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.

+
+ + + + 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 + + · {{ reportConfig.selectedTeams.length }} teams + + +
+ +
+ + +
+ +
+ +
+ +
+ filter_list_off +

No activities match the current report configuration.

+ +
+ +
+ +
+

Overview

+ + + + + + + + + + + + + + + + + +
LevelTotal ActivitiesCompletedCompletion
{{ row.level }}{{ row.totalActivities }}{{ row.completedCount }} + + + + {{ row.completionPercent }}% +
+
+ +
+

{{ dimension.name }}

+ +
+

{{ subDimension.name }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelActivity + 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; + } +}