From 67884e4638e1fb8a541150fa185f5bf4f1e89bcc Mon Sep 17 00:00:00 2001 From: benjamin Date: Sun, 15 Mar 2026 22:23:11 +0100 Subject: [PATCH] add song reporting --- .../report-dialog.component.html | 44 ++++++++ .../report-dialog.component.less | 74 +++++++++++++ .../report-dialog.component.spec.ts | 40 +++++++ .../report-dialog/report-dialog.component.ts | 41 +++++++ .../list/list-item/list-item.component.html | 5 + .../list/list-item/list-item.component.less | 2 +- .../list-item/list-item.component.spec.ts | 34 ++++-- .../list/list-item/list-item.component.ts | 5 +- .../modules/shows/list/list.component.html | 2 + .../modules/shows/list/list.component.spec.ts | 63 +++++++++-- src/app/modules/shows/list/list.component.ts | 14 ++- src/app/modules/shows/services/show.ts | 3 +- .../modules/shows/show/show.component.html | 17 ++- .../modules/shows/show/show.component.less | 7 ++ .../modules/shows/show/show.component.spec.ts | 104 ++++++++++++++++-- src/app/modules/shows/show/show.component.ts | 84 +++++++++++++- .../components/badge/badge.component.html | 3 + .../components/badge/badge.component.less | 35 ++++++ .../components/badge/badge.component.spec.ts | 28 +++++ .../components/badge/badge.component.ts | 13 +++ .../published-type.pipe.spec.ts | 15 +++ .../published-type.pipe.ts | 10 ++ .../reported-type.pipe.spec.ts | 17 +++ .../reported-type.pipe.ts | 20 ++++ 24 files changed, 644 insertions(+), 36 deletions(-) create mode 100644 src/app/modules/shows/dialog/report-dialog/report-dialog.component.html create mode 100644 src/app/modules/shows/dialog/report-dialog/report-dialog.component.less create mode 100644 src/app/modules/shows/dialog/report-dialog/report-dialog.component.spec.ts create mode 100644 src/app/modules/shows/dialog/report-dialog/report-dialog.component.ts create mode 100644 src/app/widget-modules/components/badge/badge.component.html create mode 100644 src/app/widget-modules/components/badge/badge.component.less create mode 100644 src/app/widget-modules/components/badge/badge.component.spec.ts create mode 100644 src/app/widget-modules/components/badge/badge.component.ts create mode 100644 src/app/widget-modules/pipes/published-type-translator/published-type.pipe.spec.ts create mode 100644 src/app/widget-modules/pipes/published-type-translator/published-type.pipe.ts create mode 100644 src/app/widget-modules/pipes/reported-type-translator/reported-type.pipe.spec.ts create mode 100644 src/app/widget-modules/pipes/reported-type-translator/reported-type.pipe.ts diff --git a/src/app/modules/shows/dialog/report-dialog/report-dialog.component.html b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.html new file mode 100644 index 0000000..03df23a --- /dev/null +++ b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.html @@ -0,0 +1,44 @@ +
+

+ Bitte melde die in dieser Veranstaltung verwendeten CCLI-Titel. Die Meldung ist Teil der CCLI-Lizenz und sorgt dafür, dass Songwriter und Verlage korrekt vergütet werden. +

+

+ Die Meldung erfolgt über + {{ reportingUrl }}. +

+ +
+
+
Titel
+
CCLI-Nummer
+
+ + @for (song of data.songs; track song.title + song.ccliNumber) { +
+
{{ song.title }}
+
+ {{ song.ccliNumber }} + + + + @if (wasOpened(song.ccliNumber)) { + + } +
+
+ } +
+
+
+ + +
diff --git a/src/app/modules/shows/dialog/report-dialog/report-dialog.component.less b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.less new file mode 100644 index 0000000..460b28c --- /dev/null +++ b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.less @@ -0,0 +1,74 @@ +.song-list { + width: 100%; +} + +.list-head, +.list-item { + display: grid; + grid-template-columns: auto 180px; + gap: 0; + align-items: center; + + border-bottom: 1px solid var(--overlay) +} + +.list-head { + padding: 3px 10px; + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 600; +} + +.list-item { + padding: 3px 10px; + transition: var(--transition); + + &:not(:last-child) { + border-bottom: 1px solid var(--divider) + } +} + +.list-head > div, +.list-item > div { + display: flex; + align-items: center; +} + +.number-cell { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.report-link { + display: inline-flex; + align-items: center; + color: inherit; + text-decoration: none; +} + +.opened-check { + color: var(--success); + font-size: 0.85rem; +} + +@media screen and (max-width: 640px) { + .list-head, + .list-item { + grid-template-columns: 1fr; + gap: 4px; + } + + .list-head { + display: none; + } + + .list-item { + padding: 10px 16px; + } + + .number-cell { + justify-content: flex-start; + } +} diff --git a/src/app/modules/shows/dialog/report-dialog/report-dialog.component.spec.ts b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.spec.ts new file mode 100644 index 0000000..39b48ac --- /dev/null +++ b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.spec.ts @@ -0,0 +1,40 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {ReportDialogComponent} from './report-dialog.component'; + +describe('ReportDialogComponent', () => { + let component: ReportDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ReportDialogComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: {songs: [{title: 'Amazing Grace', ccliNumber: '12345'}]}, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ReportDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('create an instance', () => { + void expect(component).toBeTruthy(); + }); + + it('should build direct reporting urls', () => { + expect(component.getSongReportingUrl('5770492')).toBe('https://reporting.ccli.com/search?s=5770492'); + }); + + it('should mark numbers as opened locally', () => { + expect(component.wasOpened('12345')).toBeFalse(); + + component.markOpened('12345'); + + expect(component.wasOpened('12345')).toBeTrue(); + }); +}); diff --git a/src/app/modules/shows/dialog/report-dialog/report-dialog.component.ts b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.ts new file mode 100644 index 0000000..4b69174 --- /dev/null +++ b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.ts @@ -0,0 +1,41 @@ +import {Component, inject} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent} from '@angular/material/dialog'; +import {MatButton} from '@angular/material/button'; +import {FaIconComponent} from '@fortawesome/angular-fontawesome'; +import {faArrowUpRightFromSquare, faCheck} from '@fortawesome/free-solid-svg-icons'; + +export interface ReportDialogSong { + title: string; + ccliNumber: string; +} + +export interface ReportDialogData { + songs: ReportDialogSong[]; +} + +@Component({ + selector: 'app-report-dialog', + imports: [MatButton, MatDialogActions, MatDialogContent, MatDialogClose, FaIconComponent], + templateUrl: './report-dialog.component.html', + styleUrl: './report-dialog.component.less', + standalone: true, +}) +export class ReportDialogComponent { + public readonly reportingUrl = 'https://reporting.ccli.com/search'; + public readonly faOpen = faArrowUpRightFromSquare; + public readonly faCheck = faCheck; + public data = inject(MAT_DIALOG_DATA); + private readonly openedNumbers = new Set(); + + public getSongReportingUrl(ccliNumber: string): string { + return `${this.reportingUrl}?s=${encodeURIComponent(ccliNumber)}`; + } + + public markOpened(ccliNumber: string): void { + this.openedNumbers.add(ccliNumber); + } + + public wasOpened(ccliNumber: string): boolean { + return this.openedNumbers.has(ccliNumber); + } +} diff --git a/src/app/modules/shows/list/list-item/list-item.component.html b/src/app/modules/shows/list/list-item/list-item.component.html index 369a00a..10e3506 100644 --- a/src/app/modules/shows/list/list-item/list-item.component.html +++ b/src/app/modules/shows/list/list-item/list-item.component.html @@ -5,5 +5,10 @@
{{ show.showType | showType }}
+
+ @if (showStatusBadge) { + {{ showStatusBadge }} + } +
} diff --git a/src/app/modules/shows/list/list-item/list-item.component.less b/src/app/modules/shows/list/list-item/list-item.component.less index af771b7..8c5b132 100644 --- a/src/app/modules/shows/list/list-item/list-item.component.less +++ b/src/app/modules/shows/list/list-item/list-item.component.less @@ -1,7 +1,7 @@ .list-item { padding: 5px 20px; display: grid; - grid-template-columns: 100px 150px auto; + grid-template-columns: 100px 150px auto 160px; min-height: 21px; & > div { diff --git a/src/app/modules/shows/list/list-item/list-item.component.spec.ts b/src/app/modules/shows/list/list-item/list-item.component.spec.ts index 39f3c0b..21a8f00 100644 --- a/src/app/modules/shows/list/list-item/list-item.component.spec.ts +++ b/src/app/modules/shows/list/list-item/list-item.component.spec.ts @@ -1,24 +1,44 @@ -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; - +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BehaviorSubject, of} from 'rxjs'; import {ListItemComponent} from './list-item.component'; +import {UserService} from '../../../../services/user/user.service'; describe('ListItemComponent', () => { let component: ListItemComponent; let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - void TestBed.configureTestingModule({ + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [ListItemComponent], + providers: [ + { + provide: UserService, + useValue: { + user$: new BehaviorSubject({id: 'user-1'}).asObservable(), + userId$: new BehaviorSubject('user-1').asObservable(), + loggedIn$: () => of(true), + }, + }, + ], }).compileComponents(); - })); - beforeEach(() => { fixture = TestBed.createComponent(ListItemComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { void expect(component).toBeTruthy(); }); + + it('should render a status badge when provided', () => { + component.show = {date: {toDate: () => new Date('2026-03-15')} as never, owner: 'user-1', showType: 'misc-private'} as never; + component.showStatusBadge = 'nicht gemeldet'; + component.showStatusBadgeType = 'error'; + + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('app-badge .badge'); + expect(badge?.textContent?.trim()).toBe('nicht gemeldet'); + expect(badge?.className).toContain('error'); + }); }); diff --git a/src/app/modules/shows/list/list-item/list-item.component.ts b/src/app/modules/shows/list/list-item/list-item.component.ts index 9e840aa..3529381 100644 --- a/src/app/modules/shows/list/list-item/list-item.component.ts +++ b/src/app/modules/shows/list/list-item/list-item.component.ts @@ -3,13 +3,16 @@ import {Show} from '../../services/show'; import {DatePipe} from '@angular/common'; import {UserNameComponent} from '../../../../services/user/user-name/user-name.component'; import {ShowTypePipe} from '../../../../widget-modules/pipes/show-type-translater/show-type.pipe'; +import {BadgeComponent, BadgeType} from '../../../../widget-modules/components/badge/badge.component'; @Component({ selector: 'app-list-item', templateUrl: './list-item.component.html', styleUrls: ['./list-item.component.less'], - imports: [UserNameComponent, DatePipe, ShowTypePipe], + imports: [UserNameComponent, DatePipe, ShowTypePipe, BadgeComponent], }) export class ListItemComponent { @Input() public show: Show | null = null; + @Input() public showStatusBadge: string | null = null; + @Input() public showStatusBadgeType: BadgeType = 'none'; } diff --git a/src/app/modules/shows/list/list.component.html b/src/app/modules/shows/list/list.component.html index 3a934a4..cd5b263 100644 --- a/src/app/modules/shows/list/list.component.html +++ b/src/app/modules/shows/list/list.component.html @@ -16,6 +16,8 @@ @for (show of shows | sortBy: 'desc':'date'; track trackBy($index, show)) { } diff --git a/src/app/modules/shows/list/list.component.spec.ts b/src/app/modules/shows/list/list.component.spec.ts index bbde914..70e02b6 100644 --- a/src/app/modules/shows/list/list.component.spec.ts +++ b/src/app/modules/shows/list/list.component.spec.ts @@ -1,24 +1,73 @@ -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; - +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BehaviorSubject, of} from 'rxjs'; import {ListComponent} from './list.component'; +import {ShowService} from '../services/show.service'; +import {UserService} from '../../../services/user/user.service'; +import {FilterStoreService} from '../../../services/filter-store.service'; describe('ListComponent', () => { let component: ListComponent; let fixture: ComponentFixture; + let shows$: BehaviorSubject; + let user$: BehaviorSubject; - beforeEach(waitForAsync(() => { - void TestBed.configureTestingModule({ + beforeEach(async () => { + shows$ = new BehaviorSubject([]); + user$ = new BehaviorSubject({id: 'user-1'}); + + await TestBed.configureTestingModule({ imports: [ListComponent], + providers: [ + { + provide: ShowService, + useValue: { + list$: () => shows$.asObservable(), + listPublicSince$: () => of([]), + }, + }, + { + provide: UserService, + useValue: { + user$: user$.asObservable(), + }, + }, + FilterStoreService, + ], }).compileComponents(); - })); - beforeEach(() => { fixture = TestBed.createComponent(ListComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { void expect(component).toBeTruthy(); }); + + it('should list own drafts and pending published shows in my shows', done => { + shows$.next([ + {id: 'draft-own', owner: 'user-1', published: false, reportedType: null}, + {id: 'pending-own', owner: 'user-1', published: true, reportedType: 'pending'}, + {id: 'reported-own', owner: 'user-1', published: true, reportedType: 'reported'}, + {id: 'draft-other', owner: 'user-2', published: false, reportedType: null}, + ] as never); + + component.privateShows$.subscribe(shows => { + expect(shows.map(show => show.id)).toEqual(['draft-own', 'pending-own']); + done(); + }); + }); + + it('should ignore show filters for my shows', done => { + const filterStore = TestBed.inject(FilterStoreService); + filterStore.updateShowFilter({time: 0, showType: 'service-worship'}); + shows$.next([ + {id: 'older-draft', owner: 'user-1', published: false, reportedType: null, showType: 'misc-private'}, + {id: 'pending-own', owner: 'user-1', published: true, reportedType: 'pending', showType: 'home-group'}, + ] as never); + + component.privateShows$.subscribe(shows => { + expect(shows.map(show => show.id)).toEqual(['older-draft', 'pending-own']); + done(); + }); + }); }); diff --git a/src/app/modules/shows/list/list.component.ts b/src/app/modules/shows/list/list.component.ts index 67d2ac5..9f1fccb 100644 --- a/src/app/modules/shows/list/list.component.ts +++ b/src/app/modules/shows/list/list.component.ts @@ -14,6 +14,7 @@ import {FilterComponent} from './filter/filter.component'; import {CardComponent} from '../../../widget-modules/components/card/card.component'; import {ListItemComponent} from './list-item/list-item.component'; import {SortByPipe} from '../../../widget-modules/pipes/sort-by/sort-by.pipe'; +import {UserService} from '../../../services/user/user.service'; @Component({ selector: 'app-list', @@ -25,14 +26,19 @@ import {SortByPipe} from '../../../widget-modules/pipes/sort-by/sort-by.pipe'; export class ListComponent { private showService = inject(ShowService); private filterStore = inject(FilterStoreService); + private userService = inject(UserService); public filter$ = this.filterStore.showFilter$; public lastMonths$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.time || 1)); public owner$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.owner)); public showType$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.showType)); public shows$ = this.showService.list$(); - public privateShows$ = combineLatest([this.shows$, this.filter$]).pipe( - map(([shows, filter]) => shows.filter(show => !show.published).filter(show => this.matchesPrivateFilter(show, filter))) + public privateShows$ = combineLatest([this.shows$, this.userService.user$]).pipe( + map(([shows, user]) => + shows + .filter(show => show.owner === user?.id) + .filter(show => !show.published || show.reportedType === 'pending') + ) ); public queriedPublicShows$ = this.lastMonths$.pipe(switchMap(lastMonths => this.showService.listPublicSince$(lastMonths))); public fallbackPublicShows$ = combineLatest([this.shows$, this.lastMonths$]).pipe( @@ -50,10 +56,6 @@ export class ListComponent { public trackBy = (index: number, show: unknown) => (show as Show).id; - private matchesPrivateFilter(show: Show, filter: FilterValues): boolean { - return this.matchesTimeFilter(show, filter.time || 1) && (!filter.showType || show.showType === filter.showType); - } - private matchesTimeFilter(show: Show, lastMonths: number): boolean { const startDate = new Date(); startDate.setHours(0, 0, 0, 0); diff --git a/src/app/modules/shows/services/show.ts b/src/app/modules/shows/services/show.ts index e5e1ec7..33fef32 100644 --- a/src/app/modules/shows/services/show.ts +++ b/src/app/modules/shows/services/show.ts @@ -1,6 +1,7 @@ import {Timestamp} from '@angular/fire/firestore'; export type PresentationBackground = 'none' | 'blue' | 'green' | 'leder' | 'praise' | 'bible'; +export type ReportedType = null | 'pending' | 'reported' | 'not-required'; export interface Show { id: string; @@ -9,7 +10,7 @@ export interface Show { owner: string; songIds?: string[]; public: boolean; - reported: boolean; + reportedType: ReportedType; published: boolean; archived: boolean; order: string[]; diff --git a/src/app/modules/shows/show/show.component.html b/src/app/modules/shows/show/show.component.html index 5366890..a22d158 100644 --- a/src/app/modules/shows/show/show.component.html +++ b/src/app/modules/shows/show/show.component.html @@ -8,8 +8,14 @@ }} - {{ getStatus(show) }}" > @if (!useSwiper) { -

{{ show.public ? 'öffentliche' : 'geschlossene' }} Veranstaltung von +

{{ show.public ? 'öffentliche' : 'geschlossene' }} Veranstaltung von + + {{ show.published | publishedType }} + @if (show.reportedType) { + {{ show.reportedType | reportedType }} + } +

}
@@ -98,12 +104,12 @@ } @if (!show.published) { - + Veröffentlichen } @if (show.published) { - + Veröffentlichung zurückziehen } @@ -112,6 +118,11 @@ Teilen } + @if (show.published && show.reportedType === 'pending') { + + Melden + + } @if (!show.published) { Ändern diff --git a/src/app/modules/shows/show/show.component.less b/src/app/modules/shows/show/show.component.less index f1f53e2..5b96c47 100644 --- a/src/app/modules/shows/show/show.component.less +++ b/src/app/modules/shows/show/show.component.less @@ -13,6 +13,13 @@ min-height: calc(100vh - 60px); } +.show-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .head { display: flex; diff --git a/src/app/modules/shows/show/show.component.spec.ts b/src/app/modules/shows/show/show.component.spec.ts index 6a36df9..50a9dda 100644 --- a/src/app/modules/shows/show/show.component.spec.ts +++ b/src/app/modules/shows/show/show.component.spec.ts @@ -1,24 +1,114 @@ -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; - +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BehaviorSubject, of} from 'rxjs'; import {ShowComponent} from './show.component'; +import {ActivatedRoute, Router} from '@angular/router'; +import {ShowService} from '../services/show.service'; +import {SongService} from '../../songs/services/song.service'; +import {ShowSongService} from '../services/show-song.service'; +import {DocxService} from '../services/docx.service'; +import {UserService} from '../../../services/user/user.service'; +import {MatDialog} from '@angular/material/dialog'; +import {GuestShowService} from '../../guest/guest-show.service'; describe('ShowComponent', () => { let component: ShowComponent; let fixture: ComponentFixture; + let showServiceSpy: jasmine.SpyObj; + let showSongServiceSpy: jasmine.SpyObj; + let dialogSpy: jasmine.SpyObj; + let user$: BehaviorSubject; + let userId$: BehaviorSubject; - beforeEach(waitForAsync(() => { - void TestBed.configureTestingModule({ + beforeEach(async () => { + showServiceSpy = jasmine.createSpyObj('ShowService', ['update$', 'read$']); + showSongServiceSpy = jasmine.createSpyObj('ShowSongService', ['list$', 'list']); + dialogSpy = jasmine.createSpyObj('MatDialog', ['open']); + user$ = new BehaviorSubject({id: 'user-1', role: ['leader']}); + userId$ = new BehaviorSubject('user-1'); + + showServiceSpy.read$.and.returnValue(of(null)); + showServiceSpy.update$.and.resolveTo(); + showSongServiceSpy.list$.and.returnValue(of([])); + showSongServiceSpy.list.and.resolveTo([]); + + await TestBed.configureTestingModule({ imports: [ShowComponent], + providers: [ + {provide: ActivatedRoute, useValue: {params: of({showId: 'show-1'})}}, + {provide: ShowService, useValue: showServiceSpy}, + {provide: SongService, useValue: {list$: () => of([])}}, + {provide: ShowSongService, useValue: showSongServiceSpy}, + {provide: DocxService, useValue: {create: jasmine.createSpy('create').and.resolveTo()}}, + {provide: Router, useValue: {navigateByUrl: jasmine.createSpy('navigateByUrl')}}, + { + provide: UserService, + useValue: { + user$: user$.asObservable(), + userId$: userId$.asObservable(), + loggedIn$: () => of(true), + }, + }, + {provide: MatDialog, useValue: dialogSpy}, + {provide: GuestShowService, useValue: {share: jasmine.createSpy('share').and.resolveTo('https://example.invalid')}}, + ], }).compileComponents(); - })); - beforeEach(() => { fixture = TestBed.createComponent(ShowComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { void expect(component).toBeTruthy(); }); + + it('should reset reportedType when unpublishing', async () => { + await component.onPublish({id: 'show-1', public: true} as never, false); + + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {published: false, reportedType: null}); + }); + + it('should set not-required for private shows when publishing', async () => { + await component.onPublish({id: 'show-1', public: false} as never, true); + + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {published: true, reportedType: 'not-required'}); + }); + + it('should set pending for public shows with reportable CCLI songs', async () => { + showSongServiceSpy.list.and.resolveTo([{legalOwner: 'CCLI', legalOwnerId: '123'}] as never); + + await component.onPublish({id: 'show-1', public: true} as never, true); + + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {published: true, reportedType: 'pending'}); + }); + + it('should set not-required for public shows without reportable CCLI songs', async () => { + showSongServiceSpy.list.and.resolveTo([{legalOwner: 'CCLI', legalOwnerId: ''}] as never); + + await component.onPublish({id: 'show-1', public: true} as never, true); + + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {published: true, reportedType: 'not-required'}); + }); + + it('should open report dialog with deduplicated reportable songs and mark show as reported', () => { + component.showSongs = [ + {id: 'show-song-1', songId: 'song-1', title: 'Alpha', legalOwner: 'CCLI', legalOwnerId: '123'}, + {id: 'show-song-2', songId: 'song-1', title: 'Alpha', legalOwner: 'CCLI', legalOwnerId: '123'}, + {id: 'show-song-3', songId: 'song-2', title: 'Beta', legalOwner: 'other', legalOwnerId: '456'}, + {id: 'show-song-4', songId: 'song-3', title: 'Gamma', legalOwner: 'CCLI', legalOwnerId: '789'}, + ] as never; + dialogSpy.open.and.returnValue({afterClosed: () => of(true)} as never); + + component.onReport({id: 'show-1', order: ['show-song-1', 'show-song-2', 'show-song-3', 'show-song-4']} as never); + + expect(dialogSpy.open).toHaveBeenCalledWith(jasmine.any(Function), { + width: '640px', + data: { + songs: [ + {title: 'Alpha', ccliNumber: '123'}, + {title: 'Gamma', ccliNumber: '789'}, + ], + }, + }); + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {reportedType: 'reported'}); + }); }); diff --git a/src/app/modules/shows/show/show.component.ts b/src/app/modules/shows/show/show.component.ts index f8898b5..197b4c8 100644 --- a/src/app/modules/shows/show/show.component.ts +++ b/src/app/modules/shows/show/show.component.ts @@ -14,6 +14,7 @@ import { faArrowUpRightFromSquare, faBox, faBoxOpen, + faCheck, faChevronRight, faFileDownload, faLock, @@ -50,6 +51,10 @@ import {ButtonComponent} from '../../../widget-modules/components/button/button. import {MatMenu, MatMenuTrigger} from '@angular/material/menu'; import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe'; import {UserService} from '../../../services/user/user.service'; +import {ReportedTypePipe} from '../../../widget-modules/pipes/reported-type-translator/reported-type.pipe'; +import {BadgeComponent, BadgeType} from '../../../widget-modules/components/badge/badge.component'; +import {ReportDialogComponent, ReportDialogSong} from '../dialog/report-dialog/report-dialog.component'; +import {PublishedTypePipe} from '../../../widget-modules/pipes/published-type-translator/published-type.pipe'; @Component({ selector: 'app-show', @@ -79,6 +84,9 @@ import {UserService} from '../../../services/user/user.service'; AsyncPipe, DatePipe, ShowTypePipe, + ReportedTypePipe, + PublishedTypePipe, + BadgeComponent, ], }) export class ShowComponent implements OnInit, OnDestroy { @@ -101,6 +109,7 @@ export class ShowComponent implements OnInit, OnDestroy { public faBox = faBox; public faBoxOpen = faBoxOpen; + public faReport = faCheck; public faPublish = faUnlock; public faUnpublish = faLock; public faShare = faArrowUpRightFromSquare; @@ -185,8 +194,24 @@ export class ShowComponent implements OnInit, OnDestroy { } } - public async onPublish(published: boolean): Promise { - if (this.showId != null) await this.showService.update$(this.showId, {published}); + public async onPublish(show: Show, published: boolean): Promise { + if (!show.id) { + return; + } + + if (!published) { + await this.showService.update$(show.id, {published: false, reportedType: null}); + return; + } + + if (!show.public) { + await this.showService.update$(show.id, {published: true, reportedType: 'not-required'}); + return; + } + + const showSongs = this.showSongs ?? (await this.showSongService.list(show.id)); + const reportedType = showSongs.some(song => song.legalOwner === 'CCLI' && !!song.legalOwnerId) ? 'pending' : 'not-required'; + await this.showService.update$(show.id, {published: true, reportedType}); } public onShare = async (show: Show): Promise => { @@ -194,16 +219,69 @@ export class ShowComponent implements OnInit, OnDestroy { this.dialog.open(ShareDialogComponent, {data: {url, show}}); }; + public onReport(show: Show): void { + const songs = this.getReportableSongs(show); + if (songs.length === 0) { + return; + } + + const dialogRef = this.dialog.open(ReportDialogComponent, { + width: '640px', + data: {songs}, + }); + + dialogRef.afterClosed().pipe(take(1)).subscribe((reported: boolean) => { + if (reported) { + void this.showService.update$(show.id, {reportedType: 'reported'}); + } + }); + } + public getStatus(show: Show): string { if (show.published) { return 'veröffentlicht'; } - if (show.reported) { + if (show.reportedType === 'reported') { return 'gemeldet'; } return 'entwurf'; } + public getReportedTypeBadgeType(show: Show): BadgeType { + switch (show.reportedType) { + case 'pending': + return 'error'; + case 'reported': + return 'ok'; + case 'not-required': + return 'none'; + default: + return 'none'; + } + } + + public getPublishedBadgeType(show: Show): BadgeType { + return show.published ? 'ok' : 'none'; + } + + private getReportableSongs(show: Show): ReportDialogSong[] { + const uniqueSongs = new Map(); + + this.orderedShowSongs(show) + .filter(song => song.legalOwner === 'CCLI' && !!song.legalOwnerId) + .forEach(song => { + const key = song.songId || `${song.title}:${song.legalOwnerId}`; + if (!uniqueSongs.has(key)) { + uniqueSongs.set(key, { + title: song.title, + ccliNumber: song.legalOwnerId, + }); + } + }); + + return Array.from(uniqueSongs.values()); + } + public async onDownload(): Promise { if (this.showId != null) await this.docxService.create(this.showId); } diff --git a/src/app/widget-modules/components/badge/badge.component.html b/src/app/widget-modules/components/badge/badge.component.html new file mode 100644 index 0000000..f376aaa --- /dev/null +++ b/src/app/widget-modules/components/badge/badge.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/widget-modules/components/badge/badge.component.less b/src/app/widget-modules/components/badge/badge.component.less new file mode 100644 index 0000000..c7811f3 --- /dev/null +++ b/src/app/widget-modules/components/badge/badge.component.less @@ -0,0 +1,35 @@ +.badge { + display: inline-flex; + align-items: center; + min-height: 1.75rem; + padding: 0 10px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; + line-height: 1; + border: 1px solid transparent; + + &.ok { + background: #e6f6ea; + border-color: #9ad1a7; + color: #1f6b34; + } + + &.warn { + background: #fff4db; + border-color: #f2cf7a; + color: #8a5a00; + } + + &.error { + background: #fdeaea; + border-color: #efb2b2; + color: #9b2c2c; + } + + &.none { + background: #eef1f4; + border-color: #c7d0d9; + color: #4f5f6f; + } +} diff --git a/src/app/widget-modules/components/badge/badge.component.spec.ts b/src/app/widget-modules/components/badge/badge.component.spec.ts new file mode 100644 index 0000000..be84272 --- /dev/null +++ b/src/app/widget-modules/components/badge/badge.component.spec.ts @@ -0,0 +1,28 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BadgeComponent} from './badge.component'; + +describe('BadgeComponent', () => { + let component: BadgeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BadgeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BadgeComponent); + component = fixture.componentInstance; + }); + + it('create an instance', () => { + void expect(component).toBeTruthy(); + }); + + it('should apply the configured type class', () => { + component.type = 'error'; + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.badge')?.className).toContain('error'); + }); +}); diff --git a/src/app/widget-modules/components/badge/badge.component.ts b/src/app/widget-modules/components/badge/badge.component.ts new file mode 100644 index 0000000..f79c97c --- /dev/null +++ b/src/app/widget-modules/components/badge/badge.component.ts @@ -0,0 +1,13 @@ +import {Component, Input} from '@angular/core'; + +export type BadgeType = 'ok' | 'warn' | 'error' | 'none'; + +@Component({ + selector: 'app-badge', + templateUrl: './badge.component.html', + styleUrls: ['./badge.component.less'], + standalone: true, +}) +export class BadgeComponent { + @Input() public type: BadgeType = 'none'; +} diff --git a/src/app/widget-modules/pipes/published-type-translator/published-type.pipe.spec.ts b/src/app/widget-modules/pipes/published-type-translator/published-type.pipe.spec.ts new file mode 100644 index 0000000..aef3e5f --- /dev/null +++ b/src/app/widget-modules/pipes/published-type-translator/published-type.pipe.spec.ts @@ -0,0 +1,15 @@ +import {PublishedTypePipe} from './published-type.pipe'; + +describe('PublishedTypePipe', () => { + it('create an instance', () => { + const pipe = new PublishedTypePipe(); + void expect(pipe).toBeTruthy(); + }); + + it('should translate publication state', () => { + const pipe = new PublishedTypePipe(); + + expect(pipe.transform(true)).toBe('veröffentlicht'); + expect(pipe.transform(false)).toBe('unveröffentlicht'); + }); +}); diff --git a/src/app/widget-modules/pipes/published-type-translator/published-type.pipe.ts b/src/app/widget-modules/pipes/published-type-translator/published-type.pipe.ts new file mode 100644 index 0000000..964ce2a --- /dev/null +++ b/src/app/widget-modules/pipes/published-type-translator/published-type.pipe.ts @@ -0,0 +1,10 @@ +import {Pipe, PipeTransform} from '@angular/core'; + +@Pipe({ + name: 'publishedType', +}) +export class PublishedTypePipe implements PipeTransform { + public transform(published: boolean): string { + return published ? 'veröffentlicht' : 'unveröffentlicht'; + } +} diff --git a/src/app/widget-modules/pipes/reported-type-translator/reported-type.pipe.spec.ts b/src/app/widget-modules/pipes/reported-type-translator/reported-type.pipe.spec.ts new file mode 100644 index 0000000..a14f882 --- /dev/null +++ b/src/app/widget-modules/pipes/reported-type-translator/reported-type.pipe.spec.ts @@ -0,0 +1,17 @@ +import {ReportedTypePipe} from './reported-type.pipe'; + +describe('ReportedTypePipe', () => { + it('create an instance', () => { + const pipe = new ReportedTypePipe(); + void expect(pipe).toBeTruthy(); + }); + + it('should translate report states', () => { + const pipe = new ReportedTypePipe(); + + expect(pipe.transform('pending')).toBe('nicht gemeldet'); + expect(pipe.transform('reported')).toBe('gemeldet'); + expect(pipe.transform('not-required')).toBe('Meldung nicht notwendig'); + expect(pipe.transform(null)).toBe(''); + }); +}); diff --git a/src/app/widget-modules/pipes/reported-type-translator/reported-type.pipe.ts b/src/app/widget-modules/pipes/reported-type-translator/reported-type.pipe.ts new file mode 100644 index 0000000..2daef87 --- /dev/null +++ b/src/app/widget-modules/pipes/reported-type-translator/reported-type.pipe.ts @@ -0,0 +1,20 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {ReportedType} from '../../../modules/shows/services/show'; + +@Pipe({ + name: 'reportedType', +}) +export class ReportedTypePipe implements PipeTransform { + public transform(reportedType: ReportedType): string { + switch (reportedType) { + case 'pending': + return 'nicht gemeldet'; + case 'reported': + return 'gemeldet'; + case 'not-required': + return 'Meldung nicht notwendig'; + default: + return ''; + } + } +}