Enhance Assignment Voting
- repaired the PDF Service for ballots - fixed some permission errors - analog voting has no "started" option anymore - more-link as button - named voting has a progress bar - Shows the poll type for eVoting - Moves and declutters meta info - Enhance the grid and the layout in detail view - declutter and enhance the dot-menus - some other layout changes - remove breadcrumbs in assignment detail - other cleanups refinements - Voting in Assignment over instead of forms (requires more server changes)
This commit is contained in:
parent
524a97cdcc
commit
6044c63c28
@ -116,6 +116,7 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
|
||||
return this.translate.instant(plural ? 'Polls' : 'Poll');
|
||||
};
|
||||
|
||||
// TODO: data must not be any
|
||||
public vote(data: any, poll_id: number): Promise<void> {
|
||||
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, data);
|
||||
}
|
||||
|
@ -51,19 +51,19 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
|
||||
public onehundred_percent_base: PercentBase;
|
||||
public user_has_voted: boolean;
|
||||
|
||||
public get isStateCreated(): boolean {
|
||||
public get isCreated(): boolean {
|
||||
return this.state === PollState.Created;
|
||||
}
|
||||
|
||||
public get isStateStarted(): boolean {
|
||||
public get isStarted(): boolean {
|
||||
return this.state === PollState.Started;
|
||||
}
|
||||
|
||||
public get isStateFinished(): boolean {
|
||||
public get isFinished(): boolean {
|
||||
return this.state === PollState.Finished;
|
||||
}
|
||||
|
||||
public get isStatePublished(): boolean {
|
||||
public get isPublished(): boolean {
|
||||
return this.state === PollState.Published;
|
||||
}
|
||||
|
||||
@ -71,20 +71,6 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
|
||||
return this.onehundred_percent_base === PercentBase.Valid || this.onehundred_percent_base === PercentBase.Cast;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the state is finished.
|
||||
*/
|
||||
public get isFinished(): boolean {
|
||||
return this.state === PollState.Finished;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the state is published.
|
||||
*/
|
||||
public get isPublished(): boolean {
|
||||
return this.state === PollState.Published;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the state is finished or published
|
||||
*/
|
||||
|
@ -6,7 +6,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
// MaterialUI modules
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonModule, MatAnchor } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
|
@ -1,16 +1,9 @@
|
||||
<os-head-bar
|
||||
[goBack]="true"
|
||||
[nav]="false"
|
||||
[hasMainButton]="poll ? poll.type === 'analog' && (poll.state === 2 || poll.state === 3) : false"
|
||||
[mainButtonIcon]="'edit'"
|
||||
[mainActionTooltip]="'Edit' | translate"
|
||||
(mainEvent)="openDialog()"
|
||||
>
|
||||
<os-head-bar [goBack]="true" [nav]="false">
|
||||
<div class="title-slot">
|
||||
<h2 *ngIf="!!poll">{{ poll.title }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="menu-slot" *osPerms="'agenda.can_manage'; or: 'agenda.can_see_list_of_speakers'">
|
||||
<div class="menu-slot" *osPerms="'assignments.can_manage_polls'">
|
||||
<button type="button" mat-icon-button [matMenuTriggerFor]="pollDetailMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
@ -25,30 +18,14 @@
|
||||
<ng-template #viewTemplate>
|
||||
<ng-container *ngIf="isReady">
|
||||
<h1>{{ poll.title }}</h1>
|
||||
<mat-divider></mat-divider>
|
||||
<os-breadcrumb [breadcrumbs]="breadcrumbs"></os-breadcrumb>
|
||||
<div class="poll-content">
|
||||
<div>{{ 'Current state' | translate }}: {{ poll.stateVerbose | translate }}</div>
|
||||
<div *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
|
||||
{{ 'Groups' | translate }}:
|
||||
<span *ngFor="let group of poll.groups">{{ group.getTitle() | translate }}</span>
|
||||
</div>
|
||||
<div>{{ 'Voting type' | translate }}: {{ poll.typeVerbose | translate }}</div>
|
||||
<div>{{ 'Election method' | translate }}: {{ poll.pollmethodVerbose | translate }}</div>
|
||||
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
|
||||
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
|
||||
</div>
|
||||
|
||||
<!-- TODO Enum -->
|
||||
<div *ngIf="poll.state === 2">
|
||||
<os-poll-progress [poll]="poll"></os-poll-progress>
|
||||
</div>
|
||||
<span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
|
||||
|
||||
<div *ngIf="poll.stateHasVotes">
|
||||
<h2 translate>Result</h2>
|
||||
|
||||
<div class="chart-wrapper" [ngClass]="{ flex: isVotedPoll }">
|
||||
<mat-table [dataSource]="poll.tableData">
|
||||
<div class="result-wrapper">
|
||||
<!-- Result Table -->
|
||||
<mat-table class="result-table" [dataSource]="poll.tableData">
|
||||
<ng-container matColumnDef="user" sticky>
|
||||
<mat-header-cell *matHeaderCellDef>{{ 'Candidates' | translate }}</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">{{ row.user }}</mat-cell>
|
||||
@ -81,43 +58,64 @@
|
||||
<mat-row *matRowDef="let row; columns: columnDefinitionOverview"></mat-row>
|
||||
</mat-table>
|
||||
|
||||
<div class="chart-inner-wrapper">
|
||||
<os-charts
|
||||
*ngIf="chartDataSubject.value"
|
||||
[type]="chartType"
|
||||
[labels]="candidatesLabels"
|
||||
[size]="isVotedPoll ? 70 : 100"
|
||||
[legendPosition]="isVotedPoll ? 'right' : 'top'"
|
||||
[showLegend]="true"
|
||||
[data]="chartDataSubject"
|
||||
></os-charts>
|
||||
<!-- Result Chart -->
|
||||
<os-charts
|
||||
class="result-chart"
|
||||
*ngIf="chartDataSubject.value"
|
||||
[type]="chartType"
|
||||
[labels]="candidatesLabels"
|
||||
[data]="chartDataSubject"
|
||||
[hasPadding]="false"
|
||||
[legendPosition]="isVotedPoll ? 'right' : 'top'"
|
||||
></os-charts>
|
||||
|
||||
<!-- Named Result -->
|
||||
<div class="named-result-table" *ngIf="poll.type === 'named' && votesDataSource.data">
|
||||
<h3>{{ 'Single votes' | translate }}</h3>
|
||||
<mat-form-field>
|
||||
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter" />
|
||||
</mat-form-field>
|
||||
<mat-table [dataSource]="votesDataSource">
|
||||
<ng-container matColumnDef="users" sticky>
|
||||
<mat-header-cell *matHeaderCellDef>{{ 'User' | translate }}</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<div *ngIf="row.user">{{ row.user.getFullName() }}</div>
|
||||
<div *ngIf="!row.user">{{ 'Unknown user' | translate }}</div>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container
|
||||
[matColumnDef]="'votes-' + option.user_id"
|
||||
*ngFor="let option of poll.options"
|
||||
sticky
|
||||
>
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<div *ngIf="option.user">{{ option.user.getFullName() }}</div>
|
||||
<div *ngIf="!option.user">{{ 'Unknown user' | translate }}</div>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
{{ row.votes[option.user_id] }}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<mat-header-row *matHeaderRowDef="columnDefinitionPerName"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: columnDefinitionPerName"></mat-row>
|
||||
</mat-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="poll.type === 'named' && votesDataSource.data">
|
||||
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter" />
|
||||
<mat-table [dataSource]="votesDataSource">
|
||||
<ng-container matColumnDef="users" sticky>
|
||||
<mat-header-cell *matHeaderCellDef>{{ 'User' | translate }}</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<div *ngIf="row.user">{{ row.user.getFullName() }}</div>
|
||||
<div *ngIf="!row.user">{{ 'Unknown user' | translate }}</div>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container [matColumnDef]="'votes-' + option.user_id" *ngFor="let option of poll.options" sticky>
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<div *ngIf="option.user">{{ option.user.getFullName() }}</div>
|
||||
<div *ngIf="!option.user">{{ 'Unknown user' | translate }}</div>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
{{ row.votes[option.user_id] }}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- Meta Infos -->
|
||||
<div class="poll-content small">
|
||||
<div *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
|
||||
{{ 'Groups' | translate }}:
|
||||
|
||||
<mat-header-row *matHeaderRowDef="columnDefinitionPerName"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: columnDefinitionPerName"></mat-row>
|
||||
</mat-table>
|
||||
</ng-container>
|
||||
<span *ngFor="let group of poll.groups; let i = index">
|
||||
{{ group.getTitle() | translate }}<span *ngIf="i < poll.groups.length - 1">, </span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
|
||||
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
@ -125,13 +123,21 @@
|
||||
<!-- More Menu -->
|
||||
<mat-menu #pollDetailMenu="matMenu">
|
||||
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
|
||||
<button mat-menu-item *ngIf="poll && poll.type === 'named'" (click)="pseudoanonymizePoll()">
|
||||
<mat-icon>polymer</mat-icon>
|
||||
<span translate>Pseudoanonymize</span>
|
||||
<button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="openDialog()">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span translate>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
*osPerms="'assignments.can_manage_polls'; and: poll && poll.type === 'named'"
|
||||
(click)="pseudoanonymizePoll()"
|
||||
>
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span translate>Anonymize votes</span>
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-menu-item (click)="deletePoll()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
<button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="deletePoll()">
|
||||
<mat-icon color="warn">delete</mat-icon>
|
||||
<span translate>Delete</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
@ -1,15 +1,40 @@
|
||||
.chart-wrapper {
|
||||
&.flex {
|
||||
display: flex;
|
||||
@import '~assets/styles/variables.scss';
|
||||
|
||||
.mat-table {
|
||||
flex: 2;
|
||||
.mat-column-votes {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.chart-inner-wrapper {
|
||||
flex: 3;
|
||||
}
|
||||
.result-wrapper {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-areas:
|
||||
'chart'
|
||||
'results'
|
||||
'names';
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
.result-wrapper {
|
||||
grid-template-areas:
|
||||
'results chart'
|
||||
'names names';
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.result-table {
|
||||
grid-area: results;
|
||||
}
|
||||
|
||||
.result-chart {
|
||||
grid-area: chart;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.named-result-table {
|
||||
grid-area: names;
|
||||
.mat-form-field {
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.poll-content {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
@ -91,6 +91,8 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
|
||||
if (this.isVotedPoll) {
|
||||
this._chartType = 'doughnut';
|
||||
this.chartDataSubject.next(this.poll.generateCircleChartData());
|
||||
} else {
|
||||
super.initChartData();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,79 +1,80 @@
|
||||
<ng-container *ngIf="poll">
|
||||
<!-- Poll progress bar -->
|
||||
<div *osPerms="'assignments.can_manage_polls'; and: poll.isStarted">
|
||||
<os-poll-progress [poll]="poll"></os-poll-progress>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="vmanager.canVote(poll)">
|
||||
<form *ngIf="voteForm" [formGroup]="voteForm" class="voting-grid">
|
||||
<!-- empty divs to fit the grid -->
|
||||
<div></div><div></div>
|
||||
<div>
|
||||
<span *ngIf="poll.pollmethod === pollMethods.Votes">
|
||||
({{ getVotesCount() }}/{{ poll.votes_amount }} {{ 'Votes' | translate }})
|
||||
</span>
|
||||
</div>
|
||||
<!-- Leftover votes -->
|
||||
<h4 *ngIf="poll.pollmethod === pollMethods.Votes">
|
||||
{{ 'Votes for this poll' | translate }}: {{ poll.votes_amount }}
|
||||
<!-- ({{ getVotesCount() }}/{{ poll.votes_amount }} {{ 'Votes' | translate }}) -->
|
||||
</h4>
|
||||
|
||||
<!-- candidate votes -->
|
||||
<ng-container *ngFor="let option of poll.options" formGroupName="votes">
|
||||
<div>
|
||||
<span *ngIf="option.user">{{ option.user.getFullName() }}</span>
|
||||
<span *ngIf="!option.user">{{ "Unknown user" | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="current-vote">
|
||||
<ng-container *ngIf="poll.pollmethod !== pollMethods.Votes && currentVotes[option.user_id]">
|
||||
({{ 'Current' | translate }}: {{ currentVotes[option.user_id] | translate }})
|
||||
</ng-container>
|
||||
<ng-container *ngIf="poll.pollmethod === pollMethods.Votes && currentVotes[option.user_id]">
|
||||
({{ 'Current choice' | translate }})
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<mat-radio-group
|
||||
name="votes-{{ poll.id }}-{{ option.id }}"
|
||||
[formControlName]="option.id"
|
||||
>
|
||||
<mat-radio-button value="Y" (click)="yesButtonClicked($event, option.id.toString())">
|
||||
<span translate>Yes</span>
|
||||
</mat-radio-button>
|
||||
<mat-radio-button value="N" *ngIf="poll.pollmethod !== pollMethods.Votes">
|
||||
<span translate>No</span>
|
||||
</mat-radio-button>
|
||||
<mat-radio-button value="A" *ngIf="poll.pollmethod === pollMethods.YNA">
|
||||
<span translate>Abstain</span>
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</ng-container>
|
||||
|
||||
<!-- global no/abstain -->
|
||||
<ng-container *ngIf="poll.pollmethod === pollMethods.Votes && (poll.global_no || poll.global_abstain)">
|
||||
<!-- empty div to fit the grid -->
|
||||
<div></div>
|
||||
<div class="current-vote">
|
||||
<ng-container *ngIf="currentVotes.global">
|
||||
({{ 'Current' | translate }}: {{ currentVotes.global | translate }})
|
||||
</ng-container>
|
||||
</div>
|
||||
<mat-radio-group
|
||||
name="votes-{{ poll.id }}-global"
|
||||
formControlName="global"
|
||||
>
|
||||
<mat-radio-button value="N" *ngIf="poll.global_no">
|
||||
<span translate>Global no</span>
|
||||
</mat-radio-button>
|
||||
<mat-radio-button value="A" *ngIf="poll.global_abstain">
|
||||
<span translate>Global abstain</span>
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</ng-container>
|
||||
</form>
|
||||
<div class="right-align" *ngIf="poll.type !== PollType.Named || poll.pollmethod !== pollMethods.Votes">
|
||||
<button
|
||||
mat-button
|
||||
mat-button-default
|
||||
(click)="saveVotes()"
|
||||
[disabled]="isSaveButtonDisabled()"
|
||||
<!-- Options and Actions -->
|
||||
<div *ngFor="let option of poll.options; let i = index">
|
||||
<div
|
||||
[ngClass]="{
|
||||
'yna-grid': poll.pollmethod === pollMethods.YNA,
|
||||
'yn-grid': poll.pollmethod === pollMethods.YN,
|
||||
'single-vote-grid': poll.pollmethod === pollMethods.Votes
|
||||
}"
|
||||
>
|
||||
<span translate>Save</span>
|
||||
</button>
|
||||
<div class="vote-candidate-name">
|
||||
<span *ngIf="option.user">{{ option.user.getFullName() }}</span>
|
||||
<span *ngIf="!option.user">{{ 'Unknown user' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div *ngFor="let action of voteActions">
|
||||
<button
|
||||
mat-raised-button
|
||||
(click)="saveSingleVote(option.id, action.vote)"
|
||||
[ngClass]="currentVotes[option.id] ? action.css : ''"
|
||||
>
|
||||
<mat-icon> {{ action.icon }}</mat-icon>
|
||||
</button>
|
||||
<span *ngIf="poll.pollmethod !== pollMethods.Votes" class="vote-label">
|
||||
{{ action.label | translate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider *ngIf="poll.options.length - 1 > i"></mat-divider>
|
||||
</div>
|
||||
|
||||
<!-- global no/abstain -->
|
||||
<ng-container *ngIf="poll.pollmethod === pollMethods.Votes && (poll.global_no || poll.global_abstain)">
|
||||
<mat-divider></mat-divider>
|
||||
<div class="global-option-grid">
|
||||
<div *ngIf="poll.global_no">
|
||||
<button
|
||||
mat-raised-button
|
||||
(click)="saveGlobalVote('N')"
|
||||
[ngClass]="currentVotes['global'] === 'No' ? 'voted-no' : ''"
|
||||
>
|
||||
<mat-icon> thumb_down </mat-icon>
|
||||
</button>
|
||||
<span class="vote-label">
|
||||
{{ 'No to all' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="poll.global_abstain">
|
||||
<button
|
||||
mat-raised-button
|
||||
(click)="saveGlobalVote('A')"
|
||||
[ngClass]="currentVotes['global'] === 'Abstain' ? 'voted-abstain' : ''"
|
||||
>
|
||||
<mat-icon> trip_origin</mat-icon>
|
||||
</button>
|
||||
<span class="vote-label">
|
||||
{{ 'Abstain' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Shows the permission error -->
|
||||
<ng-container *ngIf="!vmanager.canVote(poll)">
|
||||
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span>
|
||||
</ng-container>
|
||||
|
@ -1,12 +1,61 @@
|
||||
.current-vote {
|
||||
color: #777;
|
||||
margin-right: 10px;
|
||||
@import '~assets/styles/poll-colors.scss';
|
||||
|
||||
%vote-grid-base {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.voting-grid {
|
||||
display: grid;
|
||||
grid-gap: 5px;
|
||||
padding: 5px;
|
||||
align-items: baseline;
|
||||
grid-template-columns: auto max-content max-content;
|
||||
.yn-grid {
|
||||
@extend %vote-grid-base;
|
||||
grid-template-areas:
|
||||
'name name'
|
||||
'yes no';
|
||||
}
|
||||
|
||||
.yna-grid {
|
||||
@extend %vote-grid-base;
|
||||
grid-template-areas:
|
||||
'name name name'
|
||||
'yes no abstain';
|
||||
}
|
||||
|
||||
.single-vote-grid {
|
||||
@extend %vote-grid-base;
|
||||
grid-template-areas: 'yes name';
|
||||
grid-template-columns: min-content auto;
|
||||
}
|
||||
|
||||
.global-option-grid {
|
||||
@extend %vote-grid-base;
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
|
||||
.vote-candidate-name {
|
||||
grid-area: name;
|
||||
display: flex;
|
||||
span {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.voted-yes {
|
||||
background-color: $votes-yes-color;
|
||||
}
|
||||
|
||||
.voted-no {
|
||||
background-color: $votes-no-color;
|
||||
}
|
||||
|
||||
.voted-abstain {
|
||||
background-color: $votes-abstain-color;
|
||||
}
|
||||
|
||||
.vote-label {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.mat-divider-horizontal {
|
||||
position: initial;
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { AbstractControl, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
@ -15,6 +14,14 @@ import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.
|
||||
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
|
||||
import { ViewAssignmentVote } from '../../models/view-assignment-vote';
|
||||
|
||||
// TODO: Duplicate
|
||||
interface VoteActions {
|
||||
vote: 'Y' | 'N' | 'A';
|
||||
css: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'os-assignment-poll-vote',
|
||||
templateUrl: './assignment-poll-vote.component.html',
|
||||
@ -23,8 +30,7 @@ import { ViewAssignmentVote } from '../../models/view-assignment-vote';
|
||||
export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssignmentPoll> implements OnInit {
|
||||
public pollMethods = AssignmentPollMethods;
|
||||
public PollType = PollType;
|
||||
|
||||
public voteForm: FormGroup;
|
||||
public voteActions: VoteActions[] = [];
|
||||
|
||||
/** holds the currently saved votes */
|
||||
public currentVotes: { [key: number]: string | null; global?: string } = {};
|
||||
@ -38,13 +44,13 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
|
||||
vmanager: VotingService,
|
||||
operator: OperatorService,
|
||||
private voteRepo: AssignmentVoteRepositoryService,
|
||||
private pollRepo: AssignmentPollRepositoryService,
|
||||
private formBuilder: FormBuilder
|
||||
private pollRepo: AssignmentPollRepositoryService
|
||||
) {
|
||||
super(title, translate, matSnackbar, vmanager, operator);
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.defineVoteOptions();
|
||||
this.subscriptions.push(
|
||||
this.voteRepo.getViewModelListObservable().subscribe(votes => {
|
||||
this.votes = votes;
|
||||
@ -53,119 +59,74 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
|
||||
);
|
||||
}
|
||||
|
||||
private defineVoteOptions(): void {
|
||||
this.voteActions.push({
|
||||
vote: 'Y',
|
||||
css: 'voted-yes',
|
||||
icon: 'thumb_up',
|
||||
label: 'Yes'
|
||||
});
|
||||
|
||||
if (this.poll.pollmethod !== AssignmentPollMethods.Votes) {
|
||||
this.voteActions.push({
|
||||
vote: 'N',
|
||||
css: 'voted-no',
|
||||
icon: 'thumb_down',
|
||||
label: 'No'
|
||||
});
|
||||
}
|
||||
|
||||
if (this.poll.pollmethod === AssignmentPollMethods.YNA) {
|
||||
this.voteActions.push({
|
||||
vote: 'A',
|
||||
css: 'voted-abstain',
|
||||
icon: 'trip_origin',
|
||||
label: 'Abstain'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected updateVotes(): void {
|
||||
if (this.user && this.votes && this.poll) {
|
||||
const filtered = this.votes.filter(
|
||||
vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id
|
||||
);
|
||||
this.voteForm = this.formBuilder.group({
|
||||
votes: this.formBuilder.group(
|
||||
this.poll.options.mapToObject(option => ({ [option.id]: ['', [Validators.required]] }))
|
||||
)
|
||||
});
|
||||
if (
|
||||
this.poll.pollmethod === AssignmentPollMethods.Votes &&
|
||||
(this.poll.global_no || this.poll.global_abstain)
|
||||
) {
|
||||
this.voteForm.addControl('global', new FormControl('', Validators.required));
|
||||
}
|
||||
|
||||
for (const option of this.poll.options) {
|
||||
let curr_vote = filtered.find(vote => vote.option.id === option.id);
|
||||
if (this.poll.pollmethod === AssignmentPollMethods.Votes && curr_vote) {
|
||||
if (curr_vote.value !== 'Y') {
|
||||
this.currentVotes.global = curr_vote.valueVerbose;
|
||||
this.voteForm.controls.global.setValue(curr_vote.value);
|
||||
curr_vote = null;
|
||||
} else {
|
||||
this.currentVotes.global = null;
|
||||
}
|
||||
}
|
||||
this.currentVotes[option.user_id] = curr_vote && curr_vote.valueVerbose;
|
||||
this.voteForm.get(['votes', option.id]).setValue(curr_vote && curr_vote.value);
|
||||
}
|
||||
|
||||
if (this.poll.pollmethod === AssignmentPollMethods.Votes) {
|
||||
this.voteForm.controls.votes.valueChanges.subscribe(value => {
|
||||
if (Object.values(value).some(vote => vote)) {
|
||||
const ctrl = this.voteForm.controls.global;
|
||||
if (ctrl) {
|
||||
ctrl.reset();
|
||||
}
|
||||
this.saveVotesIfNamed();
|
||||
}
|
||||
});
|
||||
|
||||
this.voteForm.controls.global.valueChanges.subscribe(value => {
|
||||
if (value) {
|
||||
this.voteForm.controls.votes.reset();
|
||||
this.saveVotesIfNamed();
|
||||
}
|
||||
});
|
||||
this.currentVotes[option.id] = curr_vote && curr_vote.valueVerbose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveVotesIfNamed(): void {
|
||||
if (this.poll.type === PollType.Named && !this.isSaveButtonDisabled()) {
|
||||
this.saveVotes();
|
||||
}
|
||||
private getPollOptionIds(): number[] {
|
||||
return this.poll.options.map(option => option.id);
|
||||
}
|
||||
|
||||
public saveVotes(): void {
|
||||
let values = this.voteForm.value.votes;
|
||||
// convert Y to 1 and null to 0 for votes method
|
||||
if (this.poll.pollmethod === this.pollMethods.Votes) {
|
||||
if (this.voteForm.value.global) {
|
||||
values = JSON.stringify(this.voteForm.value.global);
|
||||
public saveSingleVote(optionId: number, vote: 'Y' | 'N' | 'A'): void {
|
||||
const pollOptionIds = this.getPollOptionIds();
|
||||
const requestMap = pollOptionIds.reduce((o, n) => {
|
||||
if ((n === optionId && vote === 'Y') !== (this.currentVotes[n] === 'Yes')) {
|
||||
o[n] = 1;
|
||||
} else {
|
||||
this.poll.options.forEach(option => {
|
||||
values[option.id] = this.voteForm.value.votes[option.id] === 'Y' ? 1 : 0;
|
||||
});
|
||||
o[n] = 0;
|
||||
}
|
||||
}
|
||||
this.pollRepo.vote(values, this.poll.id).catch(this.raiseError);
|
||||
|
||||
return o;
|
||||
}, {});
|
||||
|
||||
this.pollRepo.vote(JSON.stringify(requestMap), this.poll.id).catch(this.raiseError);
|
||||
}
|
||||
|
||||
public isSaveButtonDisabled(): boolean {
|
||||
return (
|
||||
!this.voteForm ||
|
||||
this.voteForm.pristine ||
|
||||
(this.poll.pollmethod === AssignmentPollMethods.Votes
|
||||
? !this.getAllFormControls().some(control => control.valid)
|
||||
: this.voteForm.invalid)
|
||||
);
|
||||
}
|
||||
|
||||
public getVotesCount(): number {
|
||||
return Object.values(this.voteForm.value.votes).filter(vote => vote).length;
|
||||
}
|
||||
|
||||
private getAllFormControls(): AbstractControl[] {
|
||||
if (this.voteForm) {
|
||||
const votesFormGroup = this.voteForm.controls.votes as FormGroup;
|
||||
return [...Object.values(votesFormGroup.controls), this.voteForm.controls.global];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public yesButtonClicked($event: MouseEvent, optionId: string): void {
|
||||
if (this.poll.pollmethod === AssignmentPollMethods.Votes) {
|
||||
// check current value (before click)
|
||||
if (this.voteForm.value.votes[optionId] === 'Y') {
|
||||
// this handler is executed before the mat-radio-button handler, so we have to set a timeout or else the other handler will just set the value again
|
||||
setTimeout(() => {
|
||||
this.voteForm.get(['votes', optionId]).setValue(null);
|
||||
this.voteForm.markAsDirty();
|
||||
this.saveVotesIfNamed();
|
||||
});
|
||||
} else {
|
||||
// check if by clicking this button, the amount of votes would succeed the permitted amount
|
||||
if (this.getVotesCount() >= this.poll.votes_amount) {
|
||||
$event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
public saveGlobalVote(globalVote: 'N' | 'A'): void {
|
||||
this.pollRepo.vote(`"${globalVote}"`, this.poll.id).catch(this.raiseError);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<mat-card class="os-card" *ngIf="poll">
|
||||
<mat-card class="os-card" *ngIf="poll && showPoll()">
|
||||
<div class="assignment-poll-wrapper">
|
||||
<div class="assignment-poll-title-header">
|
||||
<mat-card-title>
|
||||
@ -8,28 +8,24 @@
|
||||
</mat-card-title>
|
||||
<div class="poll-properties">
|
||||
<mat-chip
|
||||
*osPerms="'assignments.can_manage'"
|
||||
*osPerms="'assignments.can_manage_polls'"
|
||||
class="poll-state active"
|
||||
[disableRipple]="true"
|
||||
[matMenuTriggerFor]="triggerMenu"
|
||||
[ngClass]="poll.stateVerbose.toLowerCase()"
|
||||
>
|
||||
{{ poll.stateVerbose | translate }}
|
||||
</mat-chip>
|
||||
<mat-chip
|
||||
*ngIf="!canManage && poll.isPublished"
|
||||
[disableRipple]="true"
|
||||
class="poll-state active"
|
||||
[ngClass]="poll.stateVerbose.toLowerCase()"
|
||||
[class]="poll.stateVerbose.toLowerCase()"
|
||||
[ngClass]="{ disabled: !poll.getNextStates() }"
|
||||
>
|
||||
{{ poll.stateVerbose | translate }}
|
||||
</mat-chip>
|
||||
<span *ngIf="poll.type !== 'analog'">
|
||||
{{ poll.typeVerbose | translate }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="poll-menu">
|
||||
<!-- Buttons -->
|
||||
<button
|
||||
mat-icon-button
|
||||
*osPerms="'assignments.can_manage'; "core.can_manage_projector""
|
||||
*osPerms="'assignments.motions.can_manage_polls';or: 'core.can_manage_projector'"
|
||||
[matMenuTriggerFor]="pollItemMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@ -39,9 +35,19 @@
|
||||
</div>
|
||||
|
||||
<div *ngIf="hasVotes">
|
||||
<os-charts [type]="chartType" [labels]="candidatesLabels" [data]="chartDataSubject"></os-charts>
|
||||
<os-charts
|
||||
[type]="chartType"
|
||||
[labels]="candidatesLabels"
|
||||
[data]="chartDataSubject"
|
||||
[hasPadding]="false"
|
||||
></os-charts>
|
||||
</div>
|
||||
<os-assignment-poll-vote *ngIf="poll.canBeVotedFor" [poll]="poll"></os-assignment-poll-vote>
|
||||
<div class="poll-detail-button-wrapper">
|
||||
<a mat-button routerLink="/assignments/polls/{{ poll.id }}">
|
||||
{{ 'More' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
@ -64,6 +70,10 @@
|
||||
<os-projector-button menuItem="true" [object]="poll"></os-projector-button>
|
||||
</div>
|
||||
<div *osPerms="'assignments.can_manage'">
|
||||
<button mat-menu-item (click)="printBallot()">
|
||||
<mat-icon>picture_as_pdf</mat-icon>
|
||||
<span translate>Ballot paper</span>
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-menu-item class="red-warning-text" (click)="onDeletePoll()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
|
@ -1,7 +1,7 @@
|
||||
.assignment-poll-wrapper {
|
||||
@import '~assets/styles/poll-common-styles.scss';
|
||||
position: relative;
|
||||
padding: 0 15px;
|
||||
margin: 0 15px;
|
||||
|
||||
.poll-menu {
|
||||
position: absolute;
|
||||
@ -39,4 +39,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.poll-detail-button-wrapper {
|
||||
display: flex;
|
||||
margin: auto 0;
|
||||
> a {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import { PollState } from 'app/shared/models/poll/base-poll';
|
||||
import { BasePollComponent } from 'app/site/polls/components/base-poll.component';
|
||||
import { PollService } from 'app/site/polls/services/poll.service';
|
||||
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
|
||||
import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service';
|
||||
import { ViewAssignmentOption } from '../../models/view-assignment-option';
|
||||
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
|
||||
|
||||
@ -89,7 +90,8 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
|
||||
pollDialog: AssignmentPollDialogService,
|
||||
public pollService: PollService,
|
||||
private operator: OperatorService,
|
||||
private formBuilder: FormBuilder
|
||||
private formBuilder: FormBuilder,
|
||||
private pdfService: AssignmentPollPdfService
|
||||
) {
|
||||
super(titleService, matSnackBar, translate, dialog, promptService, repo, pollDialog);
|
||||
}
|
||||
@ -105,11 +107,17 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
|
||||
|
||||
/**
|
||||
* Print the PDF of this poll with the corresponding options and numbers
|
||||
*
|
||||
*/
|
||||
public printBallot(): void {
|
||||
throw new Error('TODO');
|
||||
// this.pdfService.printBallots(this.poll);
|
||||
this.pdfService.printBallots(this.poll);
|
||||
}
|
||||
|
||||
public showPoll(): boolean {
|
||||
return (
|
||||
this.operator.hasPerms('assignments.can_manage_polls') ||
|
||||
this.poll.isPublished ||
|
||||
(this.poll.type !== 'analog' && this.poll.isStarted)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2,7 +2,7 @@ import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { ChartData } from 'app/shared/components/charts/charts.component';
|
||||
import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { PollColor } from 'app/shared/models/poll/base-poll';
|
||||
import { PollColor, PollState } from 'app/shared/models/poll/base-poll';
|
||||
import { BaseViewModel } from 'app/site/base/base-view-model';
|
||||
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
|
||||
import { PollData, ViewBasePoll } from 'app/site/polls/models/view-base-poll';
|
||||
@ -87,6 +87,16 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override from base poll to skip started state in analog poll type
|
||||
*/
|
||||
public getNextStates(): { [key: number]: string } {
|
||||
if (this.poll.type === 'analog' && this.state === PollState.Created) {
|
||||
return null;
|
||||
}
|
||||
return super.getNextStates();
|
||||
}
|
||||
|
||||
public getPercentBase(): number {
|
||||
return 0;
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { PdfDocumentService } from 'app/core/pdf-services/pdf-document.service';
|
||||
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
|
||||
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
|
||||
import { ConfigService } from 'app/core/ui-services/config.service';
|
||||
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
|
||||
|
||||
/**
|
||||
@ -138,7 +139,7 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
|
||||
// TODO: typing of result
|
||||
private createCandidateFields(poll: ViewAssignmentPoll): object {
|
||||
/*const candidates = poll.options.sort((a, b) => {
|
||||
const candidates = poll.options.sort((a, b) => {
|
||||
return a.weight - b.weight;
|
||||
});
|
||||
const resultObject = candidates.map(cand => {
|
||||
@ -151,13 +152,12 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
noEntry.margin[1] = 25;
|
||||
resultObject.push(noEntry);
|
||||
}
|
||||
return resultObject;*/
|
||||
throw new Error('TODO');
|
||||
return resultObject;
|
||||
}
|
||||
|
||||
// TODO: typing of result
|
||||
/*private createYNBallotEntry(option: string, method: AssignmentPollmethods): object {
|
||||
const choices = method === 'yna' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No'];
|
||||
private createYNBallotEntry(option: string, method: AssignmentPollMethods): object {
|
||||
const choices = method === 'YNA' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No'];
|
||||
const columnstack = choices.map(choice => {
|
||||
return {
|
||||
width: 'auto',
|
||||
@ -174,7 +174,7 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
columns: columnstack
|
||||
}
|
||||
];
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the poll description
|
||||
@ -184,10 +184,9 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
*/
|
||||
// TODO: typing of result
|
||||
private createPollHint(poll: ViewAssignmentPoll): object {
|
||||
/*return {
|
||||
return {
|
||||
text: poll.description || '',
|
||||
style: 'description'
|
||||
};*/
|
||||
throw new Error('TODO');
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -31,7 +31,6 @@
|
||||
class="result-chart"
|
||||
*ngIf="chartDataSubject.value"
|
||||
[type]="chartType"
|
||||
[showLegend]="true"
|
||||
[data]="chartDataSubject"
|
||||
></os-charts>
|
||||
|
||||
|
@ -42,7 +42,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Publish immediately button. Only show for new polls -->
|
||||
<div *ngIf="!pollData.isStatePublished">
|
||||
<div *ngIf="!pollData.isPublished">
|
||||
<mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)">
|
||||
<span translate>Publish immediately</span>
|
||||
</mat-checkbox>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<ng-container *ngIf="poll">
|
||||
<div *osPerms="'motions.can_manage_polls';and:poll.isStateStarted">
|
||||
<div *osPerms="'motions.can_manage_polls';and:poll.isStarted">
|
||||
<os-poll-progress [poll]="poll"></os-poll-progress>
|
||||
</div>
|
||||
<ng-container *ngIf="vmanager.canVote(poll)">
|
||||
|
@ -20,19 +20,11 @@
|
||||
</div>
|
||||
|
||||
<mat-chip
|
||||
*ngIf="poll.getNextStates()"
|
||||
disableRipple
|
||||
class="poll-state active"
|
||||
[matMenuTriggerFor]="triggerMenu"
|
||||
[ngClass]="poll.stateVerbose.toLowerCase()"
|
||||
>
|
||||
{{ poll.stateVerbose }}
|
||||
</mat-chip>
|
||||
<mat-chip
|
||||
*ngIf="!poll.getNextStates()"
|
||||
disableRipple
|
||||
class="poll-state active"
|
||||
[ngClass]="poll.stateVerbose.toLowerCase()"
|
||||
[class]="poll.stateVerbose.toLowerCase()"
|
||||
[ngClass]="{ 'disabled': !poll.getNextStates() }"
|
||||
>
|
||||
{{ poll.stateVerbose }}
|
||||
</mat-chip>
|
||||
@ -87,9 +79,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="poll-detail-button-wrapper">
|
||||
<button mat-button [routerLink]="pollLink">
|
||||
<a mat-button [routerLink]="pollLink">
|
||||
{{ 'More' | translate }}
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
@ -88,7 +88,7 @@
|
||||
.poll-detail-button-wrapper {
|
||||
display: flex;
|
||||
margin: auto 0;
|
||||
> button {
|
||||
> a {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
@ -130,8 +130,8 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
|
||||
public showPoll(): boolean {
|
||||
return (
|
||||
this.operator.hasPerms('motions.can_manage_polls') ||
|
||||
this.poll.isStatePublished ||
|
||||
(this.poll.type !== 'analog' && this.poll.isStateStarted)
|
||||
this.poll.isPublished ||
|
||||
(this.poll.type !== 'analog' && this.poll.isStarted)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -18,7 +18,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<form [formGroup]="contentForm" class="poll-preview-meta-info-form">
|
||||
<ng-container *ngIf="!data || !data.state || data.isStateCreated">
|
||||
<ng-container *ngIf="!data || !data.state || data.isCreated">
|
||||
<!-- Poll Type -->
|
||||
<mat-form-field *ngIf="pollService.isElectronicVotingEnabled">
|
||||
<mat-select [placeholder]="PollPropertyVerbose.type | translate" formControlName="type" required>
|
||||
@ -71,7 +71,7 @@
|
||||
|
||||
<!-- Amount of Votes -->
|
||||
<ng-container
|
||||
*ngIf="contentForm.get('pollmethod').value === 'votes' && (!data || !data.state || data.isStateCreated)"
|
||||
*ngIf="contentForm.get('pollmethod').value === 'votes' && (!data || !data.state || data.isCreated)"
|
||||
>
|
||||
<mat-form-field>
|
||||
<input
|
||||
|
@ -856,6 +856,13 @@ button.mat-menu-item.selected {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to disable events on (i.e) matMenuTriggerFor
|
||||
*/
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// custom horrizontal scroll-bar
|
||||
|
||||
.h-scroller {
|
||||
|
@ -407,7 +407,7 @@ class AssignmentPollViewSet(BasePollViewSet):
|
||||
|
||||
YN/YNA:
|
||||
{<option_id>: 'Y' | 'N' [|'A']}
|
||||
- all option_ids must be given
|
||||
- all option_ids must be given TODO: No it must not be that way. Single Votes have to be accepted
|
||||
- 'A' is only allowed in YNA pollmethod
|
||||
|
||||
Votes for all options have to be given
|
||||
|
Loading…
Reference in New Issue
Block a user