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 @@ + + + + Teams Evidence +
+ + {{ teamName }}: + {{ evidenceByTeam.get(teamName)?.length || 0 }} entries + +
+
+
+ + + + + {{ teamName }} + + {{ evidenceByTeam.get(teamName)?.length || 0 }} entries + + +
+
+

{{ entry.title }}

+ {{ + entry.evidenceRecorded | date : 'mediumDate' + }} +
+

{{ 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: