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 }} .
+
+
+
+
+
+ @for (song of data.songs; track song.title + song.ccliNumber) {
+
+
{{ song.title }}
+
+
{{ song.ccliNumber }}
+
+
+
+ @if (wasOpened(song.ccliNumber)) {
+
+ }
+
+
+ }
+
+
+
+ Abbrechen
+
+ Alle CCLI-Titel wurden gemeldet
+
+
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 '';
+ }
+ }
+}