diff --git a/angular.json b/angular.json
index 12dfe142..2b3baa67 100644
--- a/angular.json
+++ b/angular.json
@@ -116,6 +116,7 @@
},
"defaultProject": "DSOMM",
"cli": {
- "defaultCollection": "@angular-eslint/schematics"
+ "defaultCollection": "@angular-eslint/schematics",
+ "analytics": false
}
}
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index f7fb235a..a86dca2e 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -37,6 +37,9 @@ 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';
+import { AddEvidenceModalComponent } from './component/add-evidence-modal/add-evidence-modal.component';
+import { EvidencePanelComponent } from './component/evidence-panel/evidence-panel.component';
+import { ViewEvidenceModalComponent } from './component/view-evidence-modal/view-evidence-modal.component';
@NgModule({
declarations: [
@@ -65,6 +68,9 @@ import { ColResizeDirective } from './directive/col-resize.directive';
ReportConfigModalComponent,
TeamSelectorComponent,
ColResizeDirective,
+ AddEvidenceModalComponent,
+ EvidencePanelComponent,
+ ViewEvidenceModalComponent,
],
imports: [
BrowserModule,
diff --git a/src/app/component/activity-description/activity-description.component.html b/src/app/component/activity-description/activity-description.component.html
index 28fcc422..96d370e7 100644
--- a/src/app/component/activity-description/activity-description.component.html
+++ b/src/app/component/activity-description/activity-description.component.html
@@ -285,26 +285,6 @@
Usefulness
>
-
-
No teams have started implementing this activity yet.
+
+
+
Add Evidence
+
+
+
+
+ At least one team must be selected.
+
+
+
+
+
+
Evidence Details
+
+ Title
+
+
+ Title is required.
+
+
+ Description
+
+
+
+ Reviewer
+
+
+
+
+
+
+
+
Attachments
+
Add links to supporting documents, images, or URLs.
+
+
+ Type
+
+
+ {{ aType }}
+
+
+
+
+ URL
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/component/add-evidence-modal/add-evidence-modal.component.ts b/src/app/component/add-evidence-modal/add-evidence-modal.component.ts
new file mode 100644
index 00000000..6f69b50a
--- /dev/null
+++ b/src/app/component/add-evidence-modal/add-evidence-modal.component.ts
@@ -0,0 +1,86 @@
+import { Component, Inject } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { EvidenceEntry, EvidenceStore } from '../../model/evidence-store';
+import { TeamGroups } from '../../model/types';
+
+export interface AddEvidenceModalData {
+ activityUuid: string;
+ allTeams: string[];
+ teamGroups: TeamGroups;
+}
+
+@Component({
+ selector: 'app-add-evidence-modal',
+ templateUrl: './add-evidence-modal.component.html',
+ styleUrls: ['./add-evidence-modal.component.css'],
+})
+export class AddEvidenceModalComponent {
+ activityUuid: string;
+ allTeams: string[];
+ teamGroups: TeamGroups;
+
+ // Form fields
+ selectedTeams: string[] = [];
+ title: string = '';
+ description: string = '';
+ progress: string = '';
+ evidenceRecorded: string = EvidenceStore.todayDateString();
+ reviewer: string = '';
+ attachments: { type: string; externalLink: string }[] = [];
+
+ // Validation
+ teamsError: boolean = false;
+ titleError: boolean = false;
+
+ attachmentTypes: string[] = ['document', 'image', 'link'];
+
+ constructor(
+ public dialogRef: MatDialogRef
,
+ @Inject(MAT_DIALOG_DATA) public data: AddEvidenceModalData
+ ) {
+ this.activityUuid = data.activityUuid;
+ this.allTeams = data.allTeams;
+ this.teamGroups = data.teamGroups || {};
+ }
+
+ onSelectedTeamsChange(teams: string[]): void {
+ this.selectedTeams = teams;
+ this.teamsError = this.selectedTeams.length === 0;
+ }
+
+ addAttachment(): void {
+ this.attachments.push({ type: 'link', externalLink: '' });
+ }
+
+ removeAttachment(index: number): void {
+ this.attachments.splice(index, 1);
+ }
+
+ onSave(): void {
+ this.teamsError = this.selectedTeams.length === 0;
+ this.titleError = !this.title.trim();
+
+ if (this.teamsError || this.titleError) {
+ return;
+ }
+
+ // Filter out empty attachments
+ const validAttachments = this.attachments.filter(a => a.externalLink.trim());
+
+ const entry: EvidenceEntry = {
+ id: EvidenceStore.generateId(),
+ teams: [...this.selectedTeams],
+ title: this.title.trim(),
+ description: this.description.trim(),
+ evidenceRecorded: this.evidenceRecorded,
+ reviewer: this.reviewer.trim() || undefined,
+ attachment: validAttachments.length > 0 ? validAttachments : undefined,
+ };
+
+ this.dialogRef.close({ activityUuid: this.activityUuid, entry });
+ }
+
+ onCancel(): void {
+ this.dialogRef.close(null);
+ }
+}
diff --git a/src/app/component/evidence-panel/evidence-panel.component.css b/src/app/component/evidence-panel/evidence-panel.component.css
new file mode 100644
index 00000000..226a3847
--- /dev/null
+++ b/src/app/component/evidence-panel/evidence-panel.component.css
@@ -0,0 +1,113 @@
+mat-panel-title b {
+ white-space: nowrap;
+}
+
+.tool-name {
+ margin: 0;
+ font-size: 1.1em;
+ color: var(--text-primary);
+ font-weight: 500;
+}
+
+.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;
+}
+
+.hidden {
+ opacity: 0;
+ height: 0;
+ overflow: hidden;
+ margin: 0;
+ gap: 0;
+}
+
+.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;
+}
+
+/* Evidence entry styling */
+.evidence-entry {
+ padding: 12px 0;
+ border-bottom: 1px solid var(--text-secondary);
+}
+
+.evidence-entry:last-child {
+ border-bottom: none;
+}
+
+.evidence-entry-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.evidence-date {
+ font-size: 0.8em;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.evidence-description {
+ color: var(--text-secondary);
+ line-height: 1.5;
+ margin: 0;
+ font-size: 0.95em;
+}
+
+.evidence-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.reference-link {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+ padding: 6px 10px;
+ border: 1px solid var(--text-secondary);
+ border-radius: 6px;
+ transition: opacity 0.2s;
+ font-size: 0.9em;
+ background-color: var(--background-secondary);
+ opacity: 0.8;
+}
+
+.reference-link:hover {
+ opacity: 1;
+}
+
+.no-evidence-message {
+ color: var(--text-secondary); font-size: 0.9em; text-align: center; margin-top: 16px;
+}
\ No newline at end of file
diff --git a/src/app/component/evidence-panel/evidence-panel.component.html b/src/app/component/evidence-panel/evidence-panel.component.html
new file mode 100644
index 00000000..a73dc19b
--- /dev/null
+++ b/src/app/component/evidence-panel/evidence-panel.component.html
@@ -0,0 +1,51 @@
+ 0" [expanded]="expanded" #evidencePanel>
+
+
+ Teams Evidence
+
+
+ {{ teamName }}:
+ {{ evidenceByTeam.get(teamName)?.length || 0 }} entries
+
+
+
+
+
+
+
+
+ {{ teamName }}
+
+ {{ evidenceByTeam.get(teamName)?.length || 0 }} entries
+
+
+
+
+
{{ entry.description }}
+
+ Reviewer: {{ entry.reviewer }}
+
+
+
+
+
+
+
+
+
No Evidence Recorded Yet for this Activity.
+
diff --git a/src/app/component/evidence-panel/evidence-panel.component.ts b/src/app/component/evidence-panel/evidence-panel.component.ts
new file mode 100644
index 00000000..1fcf5392
--- /dev/null
+++ b/src/app/component/evidence-panel/evidence-panel.component.ts
@@ -0,0 +1,56 @@
+import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { EvidenceEntry } from '../../model/evidence-store';
+import { LoaderService } from '../../service/loader/data-loader.service';
+
+@Component({
+ selector: 'app-evidence-panel',
+ templateUrl: './evidence-panel.component.html',
+ styleUrls: ['./evidence-panel.component.css'],
+})
+export class EvidencePanelComponent implements OnChanges {
+ @Input() activityUuid: string = '';
+ @Input() expanded: boolean = false;
+
+ evidenceEntries: EvidenceEntry[] = [];
+ evidenceByTeam: Map = new Map();
+ teamsWithEvidence: string[] = [];
+
+ constructor(private loader: LoaderService) {}
+
+ ngOnChanges(changes: SimpleChanges) {
+ if (changes['activityUuid'] && this.activityUuid) {
+ this.updateTeamsEvidence();
+ }
+ }
+
+ updateTeamsEvidence(): void {
+ this.evidenceEntries = [];
+ this.evidenceByTeam.clear();
+ this.teamsWithEvidence = [];
+
+ const dataStore = this.loader.datastore;
+ if (!dataStore || !dataStore.evidenceStore || !this.activityUuid) {
+ return;
+ }
+
+ const evidenceStore = dataStore.evidenceStore;
+
+ if (!evidenceStore.hasEvidence(this.activityUuid)) {
+ return;
+ }
+
+ this.evidenceEntries = evidenceStore.getEvidence(this.activityUuid);
+
+ // Group evidence entries by team name
+ for (const entry of this.evidenceEntries) {
+ for (const teamName of entry.teams) {
+ if (!this.evidenceByTeam.has(teamName)) {
+ this.evidenceByTeam.set(teamName, []);
+ }
+ this.evidenceByTeam.get(teamName)!.push(entry);
+ }
+ }
+
+ this.teamsWithEvidence = Array.from(this.evidenceByTeam.keys());
+ }
+}
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 e2fc14c3..257044e0 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
@@ -102,12 +102,47 @@ Activity Attributes
-
-
-
+
+
+
+ Title
+
+
+ Description
+
+
+ Date Recorded
+
+
+ Reviewer
+
+
+ Attachments
+
+
+
+
+
diff --git a/src/app/component/team-selector/team-selector.component.html b/src/app/component/team-selector/team-selector.component.html
index be96913b..df61e56b 100644
--- a/src/app/component/team-selector/team-selector.component.html
+++ b/src/app/component/team-selector/team-selector.component.html
@@ -1,6 +1,8 @@
Teams
-
Select which teams to include in the report.
+
+ Select which teams to include in the {{ type === 'report-config' ? 'report' : 'evidence' }}.
+
diff --git a/src/app/component/team-selector/team-selector.component.ts b/src/app/component/team-selector/team-selector.component.ts
index 52eef4a3..55f5f0fc 100644
--- a/src/app/component/team-selector/team-selector.component.ts
+++ b/src/app/component/team-selector/team-selector.component.ts
@@ -10,6 +10,7 @@ export class TeamSelectorComponent {
@Input() allTeams: string[] = [];
@Input() selectedTeams: string[] = [];
@Input() teamGroups: TeamGroups = {};
+ @Input() type: 'report-config' | 'add-evidence-config' = 'report-config';
@Output() selectedTeamsChange = new EventEmitter
();
diff --git a/src/app/component/view-evidence-modal/view-evidence-modal.component.css b/src/app/component/view-evidence-modal/view-evidence-modal.component.css
new file mode 100644
index 00000000..87c9894d
--- /dev/null
+++ b/src/app/component/view-evidence-modal/view-evidence-modal.component.css
@@ -0,0 +1,6 @@
+.evidence-modal-content {
+ max-height: 70vh;
+ overflow-y: auto;
+ padding: 0 24px;
+ min-width: 400px;
+}
diff --git a/src/app/component/view-evidence-modal/view-evidence-modal.component.html b/src/app/component/view-evidence-modal/view-evidence-modal.component.html
new file mode 100644
index 00000000..a34d83ed
--- /dev/null
+++ b/src/app/component/view-evidence-modal/view-evidence-modal.component.html
@@ -0,0 +1,9 @@
+Evidence: {{ activityName }}
+
+
+
+
+
+
+
+
diff --git a/src/app/component/view-evidence-modal/view-evidence-modal.component.ts b/src/app/component/view-evidence-modal/view-evidence-modal.component.ts
new file mode 100644
index 00000000..13b324e7
--- /dev/null
+++ b/src/app/component/view-evidence-modal/view-evidence-modal.component.ts
@@ -0,0 +1,29 @@
+import { Component, Inject } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+
+export interface ViewEvidenceModalData {
+ activityUuid: string;
+ activityName: string;
+}
+
+@Component({
+ selector: 'app-view-evidence-modal',
+ templateUrl: './view-evidence-modal.component.html',
+ styleUrls: ['./view-evidence-modal.component.css'],
+})
+export class ViewEvidenceModalComponent {
+ activityUuid: string;
+ activityName: string;
+
+ constructor(
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: ViewEvidenceModalData
+ ) {
+ this.activityUuid = data.activityUuid;
+ this.activityName = data.activityName || 'Activity';
+ }
+
+ onClose(): void {
+ this.dialogRef.close();
+ }
+}
diff --git a/src/app/model/data-store.ts b/src/app/model/data-store.ts
index 78834609..9d71c842 100644
--- a/src/app/model/data-store.ts
+++ b/src/app/model/data-store.ts
@@ -2,16 +2,19 @@ import { ActivityStore } from './activity-store';
import { Progress } from './types';
import { MetaStore, MetaStrings } from './meta-store';
import { ProgressStore } from './progress-store';
+import { EvidenceData, EvidenceStore } from './evidence-store';
export class DataStore {
public meta: MetaStore | null = null;
public activityStore: ActivityStore | null = null;
public progressStore: ProgressStore | null = null;
+ public evidenceStore: EvidenceStore | null = null;
constructor() {
this.meta = new MetaStore();
this.activityStore = new ActivityStore();
this.progressStore = new ProgressStore();
+ this.evidenceStore = new EvidenceStore();
}
public addActivities(activities: ActivityStore): void {
@@ -20,6 +23,9 @@ export class DataStore {
public addProgressData(progress: Progress): void {
this.progressStore?.addProgressData(progress);
}
+ public addEvidenceData(evidence: EvidenceData): void {
+ this.evidenceStore?.addEvidenceData(evidence);
+ }
public getMetaStrings(): MetaStrings {
if (this.meta == null) {
diff --git a/src/app/model/evidence-store.ts b/src/app/model/evidence-store.ts
new file mode 100644
index 00000000..b24eaac0
--- /dev/null
+++ b/src/app/model/evidence-store.ts
@@ -0,0 +1,197 @@
+import { YamlService } from '../service/yaml-loader/yaml-loader.service';
+import { ActivityId, EvidenceId } from './types';
+
+export interface EvidenceAttachment {
+ type: string; // e.g. 'document', 'image', 'link'
+ externalLink: string; // URL
+}
+
+export interface EvidenceEntry {
+ id: EvidenceId; // stable UUID for this entry
+ teams: string[];
+ title: string;
+ evidenceRecorded: string; // ISO date string
+ reviewer?: string;
+ description: string;
+ attachment?: EvidenceAttachment[];
+}
+
+export type EvidenceData = Record;
+
+const LOCALSTORAGE_KEY: string = 'evidence';
+
+export class EvidenceStore {
+ private yamlService: YamlService = new YamlService();
+ private _evidence: EvidenceData = {};
+
+ // ─── Lifecycle ────────────────────────────────────────────
+
+ public initFromLocalStorage(): void {
+ const stored = this.retrieveStoredEvidence();
+ if (stored) {
+ this.addEvidenceData(stored);
+ }
+ }
+
+ // ─── Accessors ────────────────────────────────────────────
+
+ public getEvidenceData(): EvidenceData {
+ return this._evidence;
+ }
+
+ public getEvidence(activityUuid: ActivityId): EvidenceEntry[] {
+ return this._evidence[activityUuid] || [];
+ }
+
+ public hasEvidence(activityUuid: ActivityId): boolean {
+ return (this._evidence[activityUuid]?.length || 0) > 0;
+ }
+
+ public getEvidenceCount(activityUuid: ActivityId): number {
+ return this._evidence[activityUuid]?.length ?? 0;
+ }
+
+ public getTotalEvidenceCount(): number {
+ let count = 0;
+ for (const uuid in this._evidence) {
+ count += this._evidence[uuid].length;
+ }
+ return count;
+ }
+
+ public getActivityUuidsWithEvidence(): ActivityId[] {
+ return Object.keys(this._evidence).filter(uuid => this._evidence[uuid].length > 0);
+ }
+
+ // ─── Mutators ────────────────────────────────────────────
+
+ public addEvidenceData(newEvidence: EvidenceData): void {
+ if (!newEvidence) return;
+
+ for (const activityUuid in newEvidence) {
+ if (!this._evidence[activityUuid]) {
+ this._evidence[activityUuid] = [];
+ }
+
+ const newEntries = newEvidence[activityUuid];
+ if (Array.isArray(newEntries)) {
+ for (const entry of newEntries) {
+ const existingIndex = this._evidence[activityUuid].findIndex(e => e.id === entry.id);
+ if (existingIndex !== -1) {
+ this._evidence[activityUuid][existingIndex] = entry;
+ } else {
+ this._evidence[activityUuid].push(entry);
+ }
+ }
+ }
+ }
+ }
+
+ public replaceEvidenceData(data: EvidenceData): void {
+ this._evidence = data;
+ this.saveToLocalStorage();
+ }
+
+ public addEvidence(activityUuid: ActivityId, entry: EvidenceEntry): void {
+ if (!this._evidence[activityUuid]) {
+ this._evidence[activityUuid] = [];
+ }
+ this._evidence[activityUuid].push(entry);
+ this.saveToLocalStorage();
+ }
+
+ public updateEvidence(
+ activityUuid: ActivityId,
+ entryId: EvidenceId,
+ updatedEntry: Partial
+ ): void {
+ const entries = this._evidence[activityUuid];
+ if (!entries) {
+ console.warn(`No evidence found for activity ${activityUuid}`);
+ return;
+ }
+ const index = entries.findIndex(e => e.id === entryId);
+ if (index === -1) {
+ console.warn(`Cannot find evidence with id ${entryId} for activity ${activityUuid}`);
+ return;
+ }
+ // Immutable update for Angular change detection
+ entries[index] = { ...entries[index], ...updatedEntry };
+ this.saveToLocalStorage();
+ }
+
+ public deleteEvidence(activityUuid: ActivityId, entryId: EvidenceId): void {
+ const entries = this._evidence[activityUuid];
+ if (!entries) {
+ console.warn(`No evidence found for activity ${activityUuid}`);
+ return;
+ }
+ const index = entries.findIndex(e => e.id === entryId);
+ if (index === -1) {
+ console.warn(`Cannot find evidence with id ${entryId} for activity ${activityUuid}`);
+ return;
+ }
+ entries.splice(index, 1);
+
+ if (entries.length === 0) {
+ delete this._evidence[activityUuid];
+ }
+ this.saveToLocalStorage();
+ }
+
+ public renameTeam(oldName: string, newName: string): void {
+ console.log(`Renaming team '${oldName}' to '${newName}' in evidence store`);
+ for (const uuid in this._evidence) {
+ this._evidence[uuid].forEach(entry => {
+ entry.teams = entry.teams.map(t => (t === oldName ? newName : t));
+ });
+ }
+ this.saveToLocalStorage();
+ }
+
+ // ─── Serialization ──────────────────────────────────────
+
+ public asYamlString(): string {
+ return this.yamlService.stringify({ evidence: this._evidence });
+ }
+
+ public saveToLocalStorage(): void {
+ const yamlStr = this.asYamlString();
+ localStorage.setItem(LOCALSTORAGE_KEY, yamlStr);
+ }
+
+ public deleteBrowserStoredEvidence(): void {
+ console.log('Deleting evidence from browser storage');
+ localStorage.removeItem(LOCALSTORAGE_KEY);
+ }
+
+ public retrieveStoredEvidenceYaml(): string | null {
+ return localStorage.getItem(LOCALSTORAGE_KEY);
+ }
+
+ public retrieveStoredEvidence(): EvidenceData | null {
+ const yamlStr = this.retrieveStoredEvidenceYaml();
+ if (!yamlStr) return null;
+
+ const parsed = this.yamlService.parse(yamlStr);
+ return parsed?.evidence ?? null;
+ }
+
+ // ─── Helpers ─────────────────────────────────────────────
+
+ private isDuplicateEntry(activityUuid: ActivityId, entry: EvidenceEntry): boolean {
+ const existing = this._evidence[activityUuid];
+ if (!existing) return false;
+ return existing.some(e => e.id === entry.id);
+ }
+
+ public static todayDateString(): string {
+ const now = new Date();
+ return now.toISOString().substring(0, 10);
+ }
+
+ // to be used when creating new evidence entries to ensure they have a stable UUID
+ public static generateId(): string {
+ return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+ }
+}
diff --git a/src/app/model/meta-store.ts b/src/app/model/meta-store.ts
index d36ab238..3a0a26b6 100644
--- a/src/app/model/meta-store.ts
+++ b/src/app/model/meta-store.ts
@@ -35,6 +35,7 @@ export class MetaStore {
teams: TeamNames = [];
activityFiles: string[] = [];
teamProgressFile: string = '';
+ teamEvidenceFile: string = '';
allowChangeTeamNameInBrowser: boolean = true;
dimensionIcons: Record = {
@@ -67,6 +68,7 @@ export class MetaStore {
this.teams = metaData.teams || this.teams || [];
this.activityFiles = metaData.activityFiles || this.activityFiles || [];
this.teamProgressFile = metaData.teamProgressFile || this.teamProgressFile || '';
+ this.teamEvidenceFile = metaData.teamEvidenceFile || this.teamEvidenceFile || '';
if (metaData.allowChangeTeamNameInBrowser !== undefined)
this.allowChangeTeamNameInBrowser = metaData.allowChangeTeamNameInBrowser;
}
diff --git a/src/app/model/report-config.ts b/src/app/model/report-config.ts
index 64fc19b9..79b4d801 100644
--- a/src/app/model/report-config.ts
+++ b/src/app/model/report-config.ts
@@ -15,6 +15,11 @@ export interface ActivityAttributes {
showReferencesSamm2: boolean;
showReferencesOpenCRE: boolean;
showEvidence: boolean;
+ showEvidenceTitle: boolean;
+ showEvidenceDescription: boolean;
+ showEvidenceDate: boolean;
+ showEvidenceReviewer: boolean;
+ showEvidenceAttachments: boolean;
showTags: boolean;
}
@@ -48,6 +53,11 @@ export function getDefaultActivityAttributes(): ActivityAttributes {
showReferencesSamm2: true,
showReferencesOpenCRE: true,
showEvidence: false,
+ showEvidenceTitle: true,
+ showEvidenceDescription: false,
+ showEvidenceDate: false,
+ showEvidenceReviewer: false,
+ showEvidenceAttachments: true,
showTags: false,
};
}
@@ -98,6 +108,13 @@ export function getReportConfig(): ReportConfig {
showReferencesOpenCRE:
parsedAttrs.showReferencesOpenCRE ?? defaultAttrs.showReferencesOpenCRE,
showEvidence: parsedAttrs.showEvidence ?? defaultAttrs.showEvidence,
+ showEvidenceTitle: parsedAttrs.showEvidenceTitle ?? defaultAttrs.showEvidenceTitle,
+ showEvidenceDescription:
+ parsedAttrs.showEvidenceDescription ?? defaultAttrs.showEvidenceDescription,
+ showEvidenceDate: parsedAttrs.showEvidenceDate ?? defaultAttrs.showEvidenceDate,
+ showEvidenceReviewer: parsedAttrs.showEvidenceReviewer ?? defaultAttrs.showEvidenceReviewer,
+ showEvidenceAttachments:
+ parsedAttrs.showEvidenceAttachments ?? defaultAttrs.showEvidenceAttachments,
showTags: parsedAttrs.showTags ?? defaultAttrs.showTags,
};
diff --git a/src/app/model/types.ts b/src/app/model/types.ts
index 75f1f1a5..0cbbf54d 100644
--- a/src/app/model/types.ts
+++ b/src/app/model/types.ts
@@ -15,6 +15,8 @@ export type Progress = Record;
export type ActivityProgress = Record;
export type TeamProgress = Record;
export type Uuid = string;
+export type ActivityId = Uuid;
+export type EvidenceId = Uuid;
export type TeamName = string;
export type GroupName = string;
export type ProgressTitle = string;
diff --git a/src/app/pages/circular-heatmap/circular-heatmap.component.css b/src/app/pages/circular-heatmap/circular-heatmap.component.css
index 6fa548cd..e77cfdd8 100644
--- a/src/app/pages/circular-heatmap/circular-heatmap.component.css
+++ b/src/app/pages/circular-heatmap/circular-heatmap.component.css
@@ -58,9 +58,6 @@
max-width: min(100vh - 60px, 100vw - 60px);
}
-.downloadButtonClass {
- margin: 10px 0;
-}
.overlay-details {
z-index: 2;
background-color: rgba(0, 0, 0, 0.555);
@@ -157,6 +154,19 @@ app-progress-slider {
font-style: italic;
}
+.evidence-container {
+ margin-top: 12px;
+ margin-bottom: 8px;
+ gap: 8px;
+ display: flex;
+ justify-content: center;
+}
+
+.add-evidence-button mat-icon {
+ font-size: 18px;
+ margin-right: 4px;
+}
+
mat-chip {
cursor: pointer;
}
@@ -178,9 +188,9 @@ button.filter-toggle {
place-self: flex-end;
margin: 0 1rem;
padding-top: 1rem;
- display: flex;
- align-items: flex-end;
- flex-direction: column;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
}
:host ::ng-deep .activity-card .mat-expansion-panel-body {
diff --git a/src/app/pages/circular-heatmap/circular-heatmap.component.html b/src/app/pages/circular-heatmap/circular-heatmap.component.html
index 6e970187..39f7a75b 100644
--- a/src/app/pages/circular-heatmap/circular-heatmap.component.html
+++ b/src/app/pages/circular-heatmap/circular-heatmap.component.html
@@ -106,6 +106,23 @@ Nothing to show
* Has been modified
** Has been modified to less complete stage than before
+
+
+
+
@@ -118,6 +135,13 @@
Nothing to show
(click)="exportTeamProgress()">
Download team progress
+
+
diff --git a/src/app/pages/circular-heatmap/circular-heatmap.component.ts b/src/app/pages/circular-heatmap/circular-heatmap.component.ts
index f871f449..c2e83c62 100644
--- a/src/app/pages/circular-heatmap/circular-heatmap.component.ts
+++ b/src/app/pages/circular-heatmap/circular-heatmap.component.ts
@@ -28,6 +28,15 @@ import { downloadYamlFile } from 'src/app/util/download';
import { ThemeService } from '../../service/theme.service';
import { TitleService } from '../../service/title.service';
import { SettingsService } from 'src/app/service/settings/settings.service';
+import { MatDialog } from '@angular/material/dialog';
+import {
+ AddEvidenceModalComponent,
+ AddEvidenceModalData,
+} from '../../component/add-evidence-modal/add-evidence-modal.component';
+import {
+ ViewEvidenceModalComponent,
+ ViewEvidenceModalData,
+} from '../../component/view-evidence-modal/view-evidence-modal.component';
@Component({
selector: 'app-circular-heatmap',
@@ -70,6 +79,7 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy {
private router: Router,
private route: ActivatedRoute,
private location: Location,
+ private dialog: MatDialog,
public modal: ModalMessageComponent
) {
this.theme = this.themeService.getTheme();
@@ -707,8 +717,20 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy {
downloadYamlFile(yamlStr, this.dataStore?.meta?.teamProgressFile || 'team-progress.yaml');
}
+ exportTeamEvidences() {
+ console.log(`${perfNow()}: Exporting team evidence`);
+
+ let yamlStr: string | null = this.dataStore?.evidenceStore?.asYamlString() || null;
+ if (!yamlStr) {
+ this.displayMessage(new DialogInfo('No team evidence data available', 'Export Error'));
+ return;
+ }
+
+ downloadYamlFile(yamlStr, this.dataStore?.meta?.teamEvidenceFile || 'team-evidence.yaml');
+ }
+
async deleteLocalTeamsProgress() {
- let buttonClicked: string = await this.displayDeleteLocalProgressDialog();
+ let buttonClicked: string = await this.displayDeleteLocalFilesDialog('progress');
if (buttonClicked === 'Delete') {
this.dataStore?.progressStore?.deleteBrowserStoredTeamProgress();
@@ -716,12 +738,25 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy {
}
}
- displayDeleteLocalProgressDialog(): Promise {
+ async deleteLocalTeamsEvidence() {
+ let buttonClicked: string = await this.displayDeleteLocalFilesDialog('evidence');
+
+ if (buttonClicked === 'Delete') {
+ this.dataStore?.evidenceStore?.deleteBrowserStoredEvidence();
+ location.reload(); // Make sure all load routines are initialized
+ }
+ }
+
+ displayDeleteLocalFilesDialog(type: 'progress' | 'evidence'): Promise {
return new Promise((resolve, reject) => {
let title: string = 'Delete local browser data';
let message: string =
- 'Do you want to delete all progress for each team?' +
- '\n\nThis deletes all progress stored in your local browser, but does ' +
+ 'Do you want to delete all ' +
+ type +
+ ' for each team?' +
+ '\n\nThis deletes all ' +
+ type +
+ ' stored in your local browser, but does ' +
'not change any progress stored in the yaml file on the server.';
let buttons: string[] = ['Cancel', 'Delete'];
this.modal
@@ -751,4 +786,40 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy {
unsorted() {
return 0;
}
+
+ openAddEvidenceModal(activityUuid: string): void {
+ const teams = this.dataStore?.meta?.teams || [];
+
+ const dialogData: AddEvidenceModalData = {
+ activityUuid,
+ allTeams: teams,
+ teamGroups: this.teamGroups,
+ };
+
+ const dialogRef = this.dialog.open(AddEvidenceModalComponent, {
+ width: '700px',
+ maxHeight: '90vh',
+ data: dialogData,
+ });
+
+ dialogRef.afterClosed().subscribe(result => {
+ if (result && result.entry && this.dataStore?.evidenceStore) {
+ this.dataStore.evidenceStore.addEvidence(result.activityUuid, result.entry);
+ console.log(`${perfNow()}: Evidence added for activity ${result.activityUuid}`);
+ }
+ });
+ }
+
+ openViewEvidenceModal(activityUuid: string, activityName: string): void {
+ const dialogData: ViewEvidenceModalData = {
+ activityUuid,
+ activityName,
+ };
+
+ this.dialog.open(ViewEvidenceModalComponent, {
+ width: '700px',
+ maxHeight: '90vh',
+ data: dialogData,
+ });
+ }
}
diff --git a/src/app/pages/report/report.component.css b/src/app/pages/report/report.component.css
index 6f8b0870..eed9afb2 100644
--- a/src/app/pages/report/report.component.css
+++ b/src/app/pages/report/report.component.css
@@ -142,6 +142,10 @@
width: 200px;
}
+.col-evidence {
+ width: 400px;
+}
+
.cell-tags {
font-size: 0.85em;
color: var(--text-secondary);
@@ -257,10 +261,57 @@
border-radius: 4px;
}
+/* Evidence */
+::ng-deep .evidence-cell .evidence-item {
+ padding: 4px 0;
+ border-bottom: 1px solid var(--text-tertiary);
+ font-size: 0.9em;
+ line-height: 1.5;
+}
+
+::ng-deep .evidence-cell .evidence-item:last-child {
+ border-bottom: none;
+}
+
+::ng-deep .evidence-teams {
+ font-weight: 600;
+ font-size: 0.85em;
+ margin-right: 4px;
+}
+
+::ng-deep .evidence-desc {
+ color: var(--text-secondary);
+}
+
+::ng-deep .evidence-meta {
+ font-size: 0.85em;
+ color: var(--text-secondary);
+}
+
+.evidence-view-btn {
+ width: 24px;
+ height: 24px; /* was 4px — this was clipping the icon */
+ line-height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ float: right;
+ opacity: 0.5;
+}
+
+.evidence-view-btn mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ line-height: 18px; /* add this — mat-icon needs matching line-height */
+}
+
+
/* ============ PRINT STYLES ============ */
@media print {
.no-print,
+ .evidence-view-btn,
app-top-header,
.report-toolbar {
display: none !important;
diff --git a/src/app/pages/report/report.component.html b/src/app/pages/report/report.component.html
index d5eb4529..e467565a 100644
--- a/src/app/pages/report/report.component.html
+++ b/src/app/pages/report/report.component.html
@@ -153,7 +153,7 @@ {{ subDimension.name }}
+ class="col-evidence">
Evidence
|
@@ -243,11 +243,17 @@ {{ subDimension.name }}
-
+
+
+
+
|
diff --git a/src/app/pages/report/report.component.ts b/src/app/pages/report/report.component.ts
index 51d9c147..c7341d59 100644
--- a/src/app/pages/report/report.component.ts
+++ b/src/app/pages/report/report.component.ts
@@ -17,6 +17,12 @@ import {
ReportConfigModalData,
} from '../../component/report-config-modal/report-config-modal.component';
import { ProgressTitle, TeamGroups } from '../../model/types';
+import { EvidenceEntry } from '../../model/evidence-store';
+import { DatePipe } from '@angular/common';
+import {
+ ViewEvidenceModalComponent,
+ ViewEvidenceModalData,
+} from '../../component/view-evidence-modal/view-evidence-modal.component';
export interface ReportSubDimension {
name: string;
@@ -39,6 +45,7 @@ export interface LevelOverview {
selector: 'app-report',
templateUrl: './report.component.html',
styleUrls: ['./report.component.css'],
+ providers: [DatePipe],
})
export class ReportComponent implements OnInit {
reportConfig: ReportConfig;
@@ -63,7 +70,8 @@ export class ReportComponent implements OnInit {
constructor(
private loader: LoaderService,
private settings: SettingsService,
- private dialog: MatDialog
+ private dialog: MatDialog,
+ private datePipe: DatePipe
) {
this.reportConfig = getReportConfig();
}
@@ -212,13 +220,13 @@ export class ReportComponent implements OnInit {
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)
- );
+ // 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;
}
@@ -417,4 +425,74 @@ export class ReportComponent implements OnInit {
}
return count;
}
+
+ formatEvidence(activity: Activity): string {
+ const evidenceStore = this.loader.datastore?.evidenceStore;
+ if (!evidenceStore || !activity.uuid || !evidenceStore.hasEvidence(activity.uuid)) {
+ return '—';
+ }
+
+ const allEntries: EvidenceEntry[] = evidenceStore.getEvidence(activity.uuid);
+ const attrs = this.reportConfig.activityAttributes;
+ const selectedTeams = this.reportConfig.selectedTeams;
+
+ const entries = allEntries.filter(entry => entry.teams.some(t => selectedTeams.includes(t)));
+
+ if (entries.length === 0) return '—';
+
+ const parts = entries.map(entry => {
+ const items: string[] = [];
+
+ if (attrs.showEvidenceTitle && entry.title) {
+ items.push(`${entry.title}`);
+ }
+ if (attrs.showEvidenceDescription && entry.description) {
+ items.push(`${entry.description}`);
+ }
+ if (attrs.showEvidenceDate && entry.evidenceRecorded) {
+ items.push(
+ `Date: ${this.datePipe.transform(
+ entry.evidenceRecorded,
+ 'mediumDate'
+ )}`
+ );
+ }
+ if (attrs.showEvidenceReviewer && entry.reviewer) {
+ items.push(
+ `Reviewer: ${entry.reviewer}`
+ );
+ }
+ if (attrs.showEvidenceAttachments && entry.attachment?.length) {
+ const links = entry.attachment.map(
+ a =>
+ `${a.type} ↗`
+ );
+ items.push(links.join(' '));
+ }
+
+ // Show only the selected teams for this entry
+ const matchingTeams = entry.teams.filter(t => selectedTeams.includes(t));
+ const teamsLabel =
+ matchingTeams.length > 0
+ ? `[${matchingTeams.join(', ')}] `
+ : '';
+
+ return `${teamsLabel}${items.join(' · ')}
`;
+ });
+
+ return parts.join('');
+ }
+
+ openViewEvidenceModal(activityUuid: string, activityName: string): void {
+ const dialogData: ViewEvidenceModalData = {
+ activityUuid,
+ activityName,
+ };
+
+ this.dialog.open(ViewEvidenceModalComponent, {
+ width: '700px',
+ maxHeight: '90vh',
+ data: dialogData,
+ });
+ }
}
diff --git a/src/app/service/loader/data-loader.service.ts b/src/app/service/loader/data-loader.service.ts
index 79c49ddc..e2c02da5 100644
--- a/src/app/service/loader/data-loader.service.ts
+++ b/src/app/service/loader/data-loader.service.ts
@@ -84,6 +84,14 @@ export class LoaderService {
this.dataStore.addProgressData(browserProgress?.progress);
}
+ // Load evidence data
+ const evidenceData = await this.loadEvidence(this.dataStore.meta);
+ this.dataStore.addEvidenceData(evidenceData.evidence);
+ this.dataStore.evidenceStore?.initFromLocalStorage();
+
+ // DEBUG ONLY
+ console.log('Merged EvidenceStore:', this.dataStore.evidenceStore?.getEvidenceData());
+
console.log(`${perfNow()}: YAML: All YAML files loaded`);
return this.dataStore;
@@ -134,6 +142,10 @@ export class LoaderService {
meta.activityFiles = meta.activityFiles.map(file =>
this.yamlService.makeFullPath(file, this.META_FILE)
);
+ if (!meta.teamEvidenceFile) {
+ throw Error("The meta.yaml has no 'teamEvidenceFile' to be loaded");
+ }
+ meta.teamEvidenceFile = this.yamlService.makeFullPath(meta.teamEvidenceFile, this.META_FILE);
if (this.debug) console.log(`${perfNow()} s: meta loaded`);
console.log(`${perfNow()} s: Loaded teams: ${meta.teams.join(', ')}`);
@@ -145,6 +157,11 @@ export class LoaderService {
return this.yamlService.loadYaml(meta.teamProgressFile);
}
+ private async loadEvidence(meta: MetaStore): Promise<{ evidence: any }> {
+ if (this.debug) console.log(`${perfNow()}s: Loading Team Evidence: ${meta.teamEvidenceFile}`);
+ return this.yamlService.loadYaml(meta.teamEvidenceFile);
+ }
+
private async loadActivities(meta: MetaStore): Promise {
const activityStore = new ActivityStore();
const errors: string[] = [];
diff --git a/src/assets/YAML/meta.yaml b/src/assets/YAML/meta.yaml
index f655a1d9..81c1c5fb 100644
--- a/src/assets/YAML/meta.yaml
+++ b/src/assets/YAML/meta.yaml
@@ -5,6 +5,7 @@ browserSettings:
teamProgressFile: 'team-progress.yaml'
+teamEvidenceFile: 'team-evidence.yaml'
progressDefinition:
Not implemented:
score: 0%
diff --git a/src/assets/YAML/team-evidence.yaml b/src/assets/YAML/team-evidence.yaml
new file mode 100644
index 00000000..c6a846fd
--- /dev/null
+++ b/src/assets/YAML/team-evidence.yaml
@@ -0,0 +1,2 @@
+ # Export team evidence from the browser, and replace this file
+evidence: