Vote delegations on client

Add "your vote was delegated" error

Observe operator alterations during vote

Add "canVoteFor" getter

Adjust poll progress bars
This commit is contained in:
Sean 2020-09-22 14:50:55 +02:00
parent 3ac8569712
commit 8c28b03ffc
26 changed files with 465 additions and 183 deletions

View File

@ -7,8 +7,8 @@ import { HttpService } from 'app/core/core-services/http.service';
import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations'; import { RelationDefinition } from 'app/core/definitions/relations';
import { VotingService } from 'app/core/ui-services/voting.service';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { UserVote } from 'app/shared/models/poll/base-vote';
import { ViewAssignment } from 'app/site/assignments/models/view-assignment'; import { ViewAssignment } from 'app/site/assignments/models/view-assignment';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
@ -97,7 +97,6 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
viewModelStoreService: ViewModelStoreService, viewModelStoreService: ViewModelStoreService,
translate: TranslateService, translate: TranslateService,
relationManager: RelationManagerService, relationManager: RelationManagerService,
votingService: VotingService,
http: HttpService http: HttpService
) { ) {
super( super(
@ -110,7 +109,6 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
AssignmentPoll, AssignmentPoll,
AssignmentPollRelations, AssignmentPollRelations,
{}, {},
votingService,
http http
); );
} }
@ -123,14 +121,11 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
return this.translate.instant(plural ? 'Polls' : 'Poll'); return this.translate.instant(plural ? 'Polls' : 'Poll');
}; };
public vote(data: VotingData, poll_id: number): Promise<void> { public vote(data: VotingData, poll_id: number, userId?: number): Promise<void> {
let requestData; const requestData: UserVote = {
if (data.global) { data: data.global ?? data.votes,
requestData = `"${data.global}"`; user_id: userId ?? undefined
} else { };
requestData = data.votes;
}
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, requestData); return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, requestData);
} }
} }

View File

@ -7,9 +7,8 @@ import { HttpService } from 'app/core/core-services/http.service';
import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations'; import { RelationDefinition } from 'app/core/definitions/relations';
import { VotingService } from 'app/core/ui-services/voting.service';
import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { VoteValue } from 'app/shared/models/poll/base-vote'; import { UserVote, VoteValue } from 'app/shared/models/poll/base-vote';
import { ViewMotion } from 'app/site/motions/models/view-motion'; import { ViewMotion } from 'app/site/motions/models/view-motion';
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
@ -66,7 +65,6 @@ export class MotionPollRepositoryService extends BasePollRepositoryService<
viewModelStoreService: ViewModelStoreService, viewModelStoreService: ViewModelStoreService,
translate: TranslateService, translate: TranslateService,
relationManager: RelationManagerService, relationManager: RelationManagerService,
votingService: VotingService,
http: HttpService http: HttpService
) { ) {
super( super(
@ -79,7 +77,6 @@ export class MotionPollRepositoryService extends BasePollRepositoryService<
MotionPoll, MotionPoll,
MotionPollRelations, MotionPollRelations,
{}, {},
votingService,
http http
); );
} }
@ -92,7 +89,11 @@ export class MotionPollRepositoryService extends BasePollRepositoryService<
return this.translate.instant(plural ? 'Polls' : 'Poll'); return this.translate.instant(plural ? 'Polls' : 'Poll');
}; };
public vote(vote: VoteValue, poll_id: number): Promise<void> { public vote(vote: VoteValue, poll_id: number, userId?: number): Promise<void> {
return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, JSON.stringify(vote)); const requestData: UserVote = {
data: vote,
user_id: userId ?? undefined
};
return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, requestData);
} }
} }

View File

@ -43,6 +43,18 @@ const UserRelations: RelationDefinition[] = [
ownIdKey: 'groups_id', ownIdKey: 'groups_id',
ownKey: 'groups', ownKey: 'groups',
foreignViewModel: ViewGroup foreignViewModel: ViewGroup
},
{
type: 'M2O',
ownIdKey: 'vote_delegated_to_id',
ownKey: 'voteDelegatedTo',
foreignViewModel: ViewUser
},
{
type: 'M2M',
ownIdKey: 'vote_delegated_from_users_id',
ownKey: 'voteDelegationsFrom',
foreignViewModel: ViewUser
} }
]; ];
@ -255,6 +267,8 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
public async update(update: Partial<User>, viewModel: ViewUser): Promise<void> { public async update(update: Partial<User>, viewModel: ViewUser): Promise<void> {
this.preventAlterationOnDemoUsers(viewModel); this.preventAlterationOnDemoUsers(viewModel);
console.log('update: ', update);
return super.update(update, viewModel); return super.update(update, viewModel);
} }

View File

@ -1,7 +1,10 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { PollState, PollType } from 'app/shared/models/poll/base-poll'; import { PollState, PollType } from 'app/shared/models/poll/base-poll';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { ViewUser } from 'app/site/users/models/view-user';
import { OperatorService } from '../core-services/operator.service'; import { OperatorService } from '../core-services/operator.service';
export enum VotingError { export enum VotingError {
@ -9,19 +12,21 @@ export enum VotingError {
POLL_WRONG_TYPE, POLL_WRONG_TYPE,
USER_HAS_NO_PERMISSION, USER_HAS_NO_PERMISSION,
USER_IS_ANONYMOUS, USER_IS_ANONYMOUS,
USER_NOT_PRESENT USER_NOT_PRESENT,
USER_HAS_DELEGATED_RIGHT
} }
/** /**
* TODO: It appears that the only message that makes sense for the user to see it the last one. * TODO: It appears that the only message that makes sense for the user to see it the last one.
*/ */
export const VotingErrorVerbose = { const VotingErrorVerbose = {
1: "You can't vote on this poll right now because it's not in the 'Started' state.", 1: _("You can not vote on this poll right now because it is not in the 'Started' state."),
2: "You can't vote on this poll because its type is set to analog voting.", 2: _('You can not vote on this poll because its type is set to analog voting.'),
3: "You don't have permission to vote on this poll.", 3: _('You do not not have the permission to vote on this poll.'),
4: 'You have to be logged in to be able to vote.', 4: _('You have to be logged in to be able to vote.'),
5: 'You have to be present to vote on a poll.', 5: _('You have to be present to vote on a poll.'),
6: "You have already voted on this poll. You can't change your vote in a pseudoanonymous poll." 6: _('Your right to vote was delegated to another user.'),
7: _('You have already voted on this poll. You can not change your vote in a pseudoanonymous poll.')
}; };
@Injectable({ @Injectable({
@ -33,8 +38,8 @@ export class VotingService {
/** /**
* checks whether the operator can vote on the given poll * checks whether the operator can vote on the given poll
*/ */
public canVote(poll: ViewBasePoll): boolean { public canVote(poll: ViewBasePoll, user?: ViewUser): boolean {
const error = this.getVotePermissionError(poll); const error = this.getVotePermissionError(poll, user);
return !error; return !error;
} }
@ -42,12 +47,11 @@ export class VotingService {
* checks whether the operator can vote on the given poll * checks whether the operator can vote on the given poll
* @returns null if no errors exist (= user can vote) or else a VotingError * @returns null if no errors exist (= user can vote) or else a VotingError
*/ */
public getVotePermissionError(poll: ViewBasePoll): VotingError | void { public getVotePermissionError(poll: ViewBasePoll, user: ViewUser = this.operator.viewUser): VotingError | void {
if (this.operator.isAnonymous) { if (this.operator.isAnonymous) {
return VotingError.USER_IS_ANONYMOUS; return VotingError.USER_IS_ANONYMOUS;
} }
const user = this.operator.user;
if (!poll.groups_id.intersect(user.groups_id).length) { if (!poll.groups_id.intersect(user.groups_id).length) {
return VotingError.USER_HAS_NO_PERMISSION; return VotingError.USER_HAS_NO_PERMISSION;
} }
@ -57,13 +61,16 @@ export class VotingService {
if (poll.state !== PollState.Started) { if (poll.state !== PollState.Started) {
return VotingError.POLL_WRONG_STATE; return VotingError.POLL_WRONG_STATE;
} }
if (!user.is_present) { if (!user.is_present && !this.operator.viewUser.canVoteFor(user)) {
return VotingError.USER_NOT_PRESENT; return VotingError.USER_NOT_PRESENT;
} }
if (user.isVoteRightDelegated && this.operator.user.id === user.id) {
return VotingError.USER_HAS_DELEGATED_RIGHT;
}
} }
public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void { public getVotePermissionErrorVerbose(poll: ViewBasePoll, user: ViewUser = this.operator.viewUser): string | void {
const error = this.getVotePermissionError(poll); const error = this.getVotePermissionError(poll, user);
if (error) { if (error) {
return VotingErrorVerbose[error]; return VotingErrorVerbose[error];
} }

View File

@ -29,7 +29,7 @@
</button> </button>
</ng-container> </ng-container>
<ng-container *ngIf="!multiple && includeNone"> <ng-container *ngIf="!multiple && includeNone">
<mat-option> <mat-option [value]="null">
{{ noneTitle | translate }} {{ noneTitle | translate }}
</mat-option> </mat-option>
<mat-divider></mat-divider> <mat-divider></mat-divider>

View File

@ -56,8 +56,9 @@ export abstract class BasePoll<
public votescast: number; public votescast: number;
public groups_id: number[]; public groups_id: number[];
public majority_method: MajorityMethod; public majority_method: MajorityMethod;
public voted_id: number[];
public user_has_voted: boolean; public user_has_voted: boolean;
public user_has_voted_for_delegations: number[];
public pollmethod: PM; public pollmethod: PM;
public onehundred_percent_base: PB; public onehundred_percent_base: PB;

View File

@ -16,6 +16,13 @@ export const GeneralValueVerbose = {
votesabstain: 'Votes abstain' votesabstain: 'Votes abstain'
}; };
export interface UserVote {
// the voting payload is hard to describe.
// Can be "VoteValue" or any userID-Number sequence in combination with any VoteValue
data: Object;
user_id?: number;
}
export abstract class BaseVote<T = any> extends BaseDecimalModel<T> { export abstract class BaseVote<T = any> extends BaseDecimalModel<T> {
public weight: number; public weight: number;
public value: VoteValue; public value: VoteValue;

View File

@ -30,6 +30,8 @@ export class User extends BaseDecimalModel<User> {
public is_present: boolean; public is_present: boolean;
public is_committee: boolean; public is_committee: boolean;
public email?: string; public email?: string;
public vote_delegated_to_id: number;
public vote_delegated_from_users_id: number[];
public last_email_send?: string; // ISO datetime string public last_email_send?: string; // ISO datetime string
public comment?: string; public comment?: string;
public is_active?: boolean; public is_active?: boolean;
@ -41,6 +43,10 @@ export class User extends BaseDecimalModel<User> {
return this.vote_weight === 1; return this.vote_weight === 1;
} }
public get isVoteRightDelegated(): boolean {
return !!this.vote_delegated_to_id;
}
public constructor(input?: Partial<User>) { public constructor(input?: Partial<User>) {
super(User.COLLECTIONSTRING, input); super(User.COLLECTIONSTRING, input);
} }

View File

@ -1,5 +1,27 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll) && !alreadyVoted && !deliveringVote; else cannotVote"> <!-- own voting -->
<ng-container [ngTemplateOutlet]="votingArea"></ng-container>
<!-- Delegations -->
<ng-container *ngIf="user.is_present">
<div class="assignment-vote-delegation" *ngFor="let delegation of delegations">
<mat-divider></mat-divider>
<ng-container
[ngTemplateOutlet]="votingArea"
[ngTemplateOutletContext]="{ delegation: delegation }"
></ng-container>
</div>
</ng-container>
</ng-container>
<ng-template #votingArea let-delegation="delegation">
<h4 *ngIf="delegation" class="assignment-delegation-title">
<span>{{ 'Vote delegation for' | translate }}</span>
<span>&nbsp;{{ delegation.getFullName() }}</span>
</h4>
<ng-container *ngIf="canVote(delegation)">
<!-- Poll hint --> <!-- Poll hint -->
<p *ngIf="pollHint"> <p *ngIf="pollHint">
<i>{{ pollHint }}</i> <i>{{ pollHint }}</i>
@ -9,7 +31,7 @@
<h4 *ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1"> <h4 *ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1">
{{ 'Available votes' | translate }}: {{ 'Available votes' | translate }}:
<b> {{ getVotesAvailable() }}/{{ poll.votes_amount }} </b> <b> {{ getVotesAvailable(delegation) }}/{{ poll.votes_amount }} </b>
</h4> </h4>
<!-- Options and Actions --> <!-- Options and Actions -->
@ -36,14 +58,9 @@
<button <button
class="vote-button" class="vote-button"
mat-raised-button mat-raised-button
(click)="saveSingleVote(option.id, action.vote)" (click)="saveSingleVote(option.id, action.vote, delegation)"
[disabled]="deliveringVote" [disabled]="isDeliveringVote(delegation)"
[ngClass]=" [ngClass]="getActionButtonClass(action, option, delegation)"
voteRequestData.votes[option.id] === action.vote ||
voteRequestData.votes[option.id] === 1
? action.css
: ''
"
> >
<mat-icon> {{ action.icon }}</mat-icon> <mat-icon> {{ action.icon }}</mat-icon>
</button> </button>
@ -64,9 +81,9 @@
<button <button
class="vote-button" class="vote-button"
mat-raised-button mat-raised-button
(click)="saveGlobalVote('N')" (click)="saveGlobalVote('N', delegation)"
[ngClass]="voteRequestData.global === 'N' ? 'voted-no' : ''" [ngClass]="getGlobalNoClass(delegation)"
[disabled]="deliveringVote" [disabled]="isDeliveringVote(delegation)"
> >
<mat-icon> thumb_down </mat-icon> <mat-icon> thumb_down </mat-icon>
</button> </button>
@ -79,8 +96,9 @@
<button <button
class="vote-button" class="vote-button"
mat-raised-button mat-raised-button
(click)="saveGlobalVote('A')" (click)="saveGlobalVote('A', delegation)"
[ngClass]="voteRequestData.global === 'A' ? 'voted-abstain' : ''" [ngClass]="getGlobalAbstainClass(delegation)"
[disabled]="isDeliveringVote(delegation)"
> >
<mat-icon> trip_origin</mat-icon> <mat-icon> trip_origin</mat-icon>
</button> </button>
@ -92,39 +110,47 @@
</ng-container> </ng-container>
<!-- Submit Vote --> <!-- Submit Vote -->
<ng-container [ngTemplateOutlet]="sendNow"></ng-container> <ng-container
[ngTemplateOutlet]="sendNow"
[ngTemplateOutletContext]="{ delegation: delegation }"
></ng-container>
</ng-container> </ng-container>
<!-- Shows the permission error --> <ng-container
<ng-container *ngIf="!vmanager.canVote(poll)"> *ngIf="!canVote(delegation)"
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span> [ngTemplateOutlet]="cannotVote"
[ngTemplateOutletContext]="{ delegation: delegation }"
>
</ng-container> </ng-container>
</ng-container> </ng-template>
<ng-template #cannotVote> <ng-template #cannotVote let-delegation="delegation">
<div class="centered-button-wrapper"> <div class="centered-button-wrapper">
<div *ngIf="!deliveringVote"> <!-- Success -->
<mat-icon class="vote-submitted"> <div *ngIf="hasAlreadyVoted(delegation) && !isDeliveringVote(delegation)">
check_circle <mat-icon class="vote-submitted"> check_circle </mat-icon>
</mat-icon>
<br /> <br />
<span>{{ 'Voting successful.' | translate }}</span> <span>{{ 'Voting successful.' | translate }}</span>
</div> </div>
<div *ngIf="deliveringVote" class="submit-vote-indicator"> <!-- Delivering -->
<div *ngIf="isDeliveringVote(delegation)" class="submit-vote-indicator">
<mat-spinner class="small-spinner"></mat-spinner> <mat-spinner class="small-spinner"></mat-spinner>
<br /> <br />
<span>{{ 'Delivering vote... Please wait!' | translate }}</span> <span>{{ 'Delivering vote... Please wait!' | translate }}</span>
</div> </div>
<!-- Permission error error -->
<div *ngIf="!hasAlreadyVoted(delegation) && !isDeliveringVote(delegation)">
<span>{{ getVotingError(delegation) | translate }}</span>
</div>
</div> </div>
</ng-template> </ng-template>
<ng-template #sendNow> <ng-template #sendNow let-delegation="delegation">
<div class="centered-button-wrapper"> <div class="centered-button-wrapper">
<button mat-flat-button color="accent" (click)="submitVote()"> <button mat-flat-button color="accent" (click)="submitVote(delegation)">
<mat-icon> <mat-icon> how_to_vote </mat-icon>
how_to_vote
</mat-icon>
<span> <span>
{{ 'Submit vote now' | translate }} {{ 'Submit vote now' | translate }}
</span> </span>

View File

@ -7,6 +7,14 @@
margin: 20px 0; margin: 20px 0;
} }
.assignment-vote-delegation {
margin-top: 1em;
.assignment-delegation-title {
font-weight: 500;
}
}
.yn-grid { .yn-grid {
@extend %vote-grid-base; @extend %vote-grid-base;
grid-template-areas: grid-template-areas:

View File

@ -7,24 +7,17 @@ import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { import {
AssignmentPollRepositoryService, AssignmentPollRepositoryService,
GlobalVote, GlobalVote
VotingData
} from 'app/core/repositories/assignments/assignment-poll-repository.service'; } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { VotingService } from 'app/core/ui-services/voting.service'; import { VotingService } from 'app/core/ui-services/voting.service';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { PollType } from 'app/shared/models/poll/base-poll'; import { PollType } from 'app/shared/models/poll/base-poll';
import { VoteValue } from 'app/shared/models/poll/base-vote'; import { VoteValue } from 'app/shared/models/poll/base-vote';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollVoteComponentDirective } from 'app/site/polls/components/base-poll-vote.component'; import { BasePollVoteComponentDirective, VoteOption } from 'app/site/polls/components/base-poll-vote.component';
import { ViewUser } from 'app/site/users/models/view-user';
// TODO: Duplicate
interface VoteActions {
vote: VoteValue;
css: string;
icon: string;
label: string;
}
@Component({ @Component({
selector: 'os-assignment-poll-vote', selector: 'os-assignment-poll-vote',
@ -35,37 +28,60 @@ interface VoteActions {
export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<ViewAssignmentPoll> implements OnInit { export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<ViewAssignmentPoll> implements OnInit {
public AssignmentPollMethod = AssignmentPollMethod; public AssignmentPollMethod = AssignmentPollMethod;
public PollType = PollType; public PollType = PollType;
public voteActions: VoteActions[] = []; public voteActions: VoteOption[] = [];
public voteRequestData: VotingData = {
votes: {} public get pollHint(): string {
}; return this.poll.assignment.default_poll_description;
public alreadyVoted: boolean; }
public constructor( public constructor(
title: Title, title: Title,
protected translate: TranslateService, protected translate: TranslateService,
matSnackbar: MatSnackBar, matSnackbar: MatSnackBar,
operator: OperatorService, operator: OperatorService,
public vmanager: VotingService, votingService: VotingService,
private pollRepo: AssignmentPollRepositoryService, private pollRepo: AssignmentPollRepositoryService,
private promptService: PromptService, private promptService: PromptService,
private cd: ChangeDetectorRef private cd: ChangeDetectorRef
) { ) {
super(title, translate, matSnackbar, operator); super(title, translate, matSnackbar, operator, votingService);
// observe user updates to refresh the view on dynamic changes
this.subscriptions.push(
operator.getViewUserObservable().subscribe(() => {
this.cd.markForCheck();
})
);
} }
public ngOnInit(): void { public ngOnInit(): void {
if (this.poll && !this.poll.user_has_voted) { this.createVotingDataObjects();
this.alreadyVoted = false; this.defineVoteOptions();
this.defineVoteOptions(); this.cd.markForCheck();
} else {
this.alreadyVoted = true;
this.cd.markForCheck();
}
} }
public get pollHint(): string { public getActionButtonClass(actions: VoteOption, option: ViewAssignmentOption, user: ViewUser = this.user): string {
return this.poll.assignment.default_poll_description; if (
this.voteRequestData[user.id]?.votes[option.id] === actions.vote ||
this.voteRequestData[user.id]?.votes[option.id] === 1
) {
return actions.css;
}
return '';
}
public getGlobalAbstainClass(user: ViewUser = this.user): string {
if (this.voteRequestData[user.id]?.global === 'A') {
return 'voted-abstain';
}
return '';
}
public getGlobalNoClass(user: ViewUser = this.user): string {
if (this.voteRequestData[user.id]?.global === 'N') {
return 'voted-no';
}
return '';
} }
private defineVoteOptions(): void { private defineVoteOptions(): void {
@ -76,7 +92,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
label: 'Yes' label: 'Yes'
}); });
if (this.poll.pollmethod !== AssignmentPollMethod.Votes) { if (this.poll?.pollmethod !== AssignmentPollMethod.Votes) {
this.voteActions.push({ this.voteActions.push({
vote: 'N', vote: 'N',
css: 'voted-no', css: 'voted-no',
@ -85,7 +101,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
}); });
} }
if (this.poll.pollmethod === AssignmentPollMethod.YNA) { if (this.poll?.pollmethod === AssignmentPollMethod.YNA) {
this.voteActions.push({ this.voteActions.push({
vote: 'A', vote: 'A',
css: 'voted-abstain', css: 'voted-abstain',
@ -95,40 +111,48 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
} }
} }
public getVotesCount(): number { public getVotesCount(user: ViewUser = this.user): number {
return Object.keys(this.voteRequestData.votes).filter(key => this.voteRequestData.votes[key]).length; if (this.voteRequestData[user.id]) {
return Object.keys(this.voteRequestData[user.id].votes).filter(
key => this.voteRequestData[user.id].votes[key]
).length;
}
} }
public getVotesAvailable(): number { public getVotesAvailable(user: ViewUser = this.user): number {
return this.poll.votes_amount - this.getVotesCount(); return this.poll.votes_amount - this.getVotesCount(user);
} }
private isGlobalOptionSelected(): boolean { private isGlobalOptionSelected(user: ViewUser = this.user): boolean {
return !!this.voteRequestData.global; return !!this.voteRequestData[user.id]?.global;
} }
public async submitVote(): Promise<void> { public async submitVote(user: ViewUser = this.user): Promise<void> {
const title = this.translate.instant('Submit selection now?'); const title = this.translate.instant('Submit selection now?');
const content = this.translate.instant('Your decision cannot be changed afterwards.'); const content = this.translate.instant('Your decision cannot be changed afterwards.');
const confirmed = await this.promptService.open(title, content); const confirmed = await this.promptService.open(title, content);
if (confirmed) { if (confirmed) {
this.deliveringVote = true; this.deliveringVote[user.id] = true;
this.cd.markForCheck(); this.cd.markForCheck();
this.pollRepo this.pollRepo
.vote(this.voteRequestData, this.poll.id) .vote(this.voteRequestData[user.id], this.poll.id, user.id)
.then(() => { .then(() => {
this.alreadyVoted = true; this.alreadyVoted[user.id] = true;
}) })
.catch(this.raiseError) .catch(this.raiseError)
.finally(() => { .finally(() => {
this.deliveringVote = false; this.deliveringVote[user.id] = false;
}); });
} }
} }
public saveSingleVote(optionId: number, vote: VoteValue): void { public saveSingleVote(optionId: number, vote: VoteValue, user: ViewUser = this.user): void {
if (this.isGlobalOptionSelected()) { if (!this.voteRequestData[user.id]) {
delete this.voteRequestData.global; throw new Error('The user for your voting request does not exist');
}
if (this.isGlobalOptionSelected(user)) {
delete this.voteRequestData[user.id].global;
} }
if (this.poll.pollmethod === AssignmentPollMethod.Votes) { if (this.poll.pollmethod === AssignmentPollMethod.Votes) {
@ -138,10 +162,10 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
.reduce((o, n) => { .reduce((o, n) => {
o[n] = 0; o[n] = 0;
if (votesAmount === 1) { if (votesAmount === 1) {
if (n === optionId && this.voteRequestData.votes[n] !== 1) { if (n === optionId && this.voteRequestData[user.id].votes[n] !== 1) {
o[n] = 1; o[n] = 1;
} }
} else if ((n === optionId) !== (this.voteRequestData.votes[n] === 1)) { } else if ((n === optionId) !== (this.voteRequestData[user.id].votes[n] === 1)) {
o[n] = 1; o[n] = 1;
} }
@ -151,11 +175,11 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
// check if you can still vote // check if you can still vote
const countedVotes = Object.keys(tmpVoteRequest).filter(key => tmpVoteRequest[key]).length; const countedVotes = Object.keys(tmpVoteRequest).filter(key => tmpVoteRequest[key]).length;
if (countedVotes <= votesAmount) { if (countedVotes <= votesAmount) {
this.voteRequestData.votes = tmpVoteRequest; this.voteRequestData[user.id].votes = tmpVoteRequest;
// if you have no options anymore, try to send // if you have no options anymore, try to send
if (this.getVotesCount() === votesAmount) { if (this.getVotesCount(user) === votesAmount) {
this.submitVote(); this.submitVote(user);
} }
} else { } else {
this.raiseError( this.raiseError(
@ -164,26 +188,29 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
} }
} else { } else {
// YN/YNA // YN/YNA
if (this.voteRequestData.votes[optionId] && this.voteRequestData.votes[optionId] === vote) { if (
delete this.voteRequestData.votes[optionId]; this.voteRequestData[user.id].votes[optionId] &&
this.voteRequestData[user.id].votes[optionId] === vote
) {
delete this.voteRequestData[user.id].votes[optionId];
} else { } else {
this.voteRequestData.votes[optionId] = vote; this.voteRequestData[user.id].votes[optionId] = vote;
} }
// if you filled out every option, try to send // if you filled out every option, try to send
if (Object.keys(this.voteRequestData.votes).length === this.poll.options.length) { if (Object.keys(this.voteRequestData[user.id].votes).length === this.poll.options.length) {
this.submitVote(); this.submitVote(user);
} }
} }
} }
public saveGlobalVote(globalVote: GlobalVote): void { public saveGlobalVote(globalVote: GlobalVote, user: ViewUser = this.user): void {
this.voteRequestData.votes = {}; this.voteRequestData[user.id].votes = {};
if (this.voteRequestData.global && this.voteRequestData.global === globalVote) { if (this.voteRequestData[user.id].global && this.voteRequestData[user.id].global === globalVote) {
delete this.voteRequestData.global; delete this.voteRequestData[user.id].global;
} else { } else {
this.voteRequestData.global = globalVote; this.voteRequestData[user.id].global = globalVote;
this.submitVote(); this.submitVote(user);
} }
} }
} }

View File

@ -72,6 +72,8 @@ export class AssignmentPollComponent
this.descriptionForm = this.formBuilder.group({ this.descriptionForm = this.formBuilder.group({
description: this.poll ? this.poll.description : '' description: this.poll ? this.poll.description : ''
}); });
console.log('the poll: ', this.poll);
} }
/** /**

View File

@ -4,10 +4,10 @@
</p> </p>
<div *ngIf="poll.pollClassType === 'motion'"> <div *ngIf="poll.pollClassType === 'motion'">
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote> <os-motion-poll-vote [poll]="poll" *ngIf="poll.canBeVotedFor()"></os-motion-poll-vote>
</div> </div>
<div *ngIf="poll.pollClassType === 'assignment'"> <div *ngIf="poll.pollClassType === 'assignment'">
<os-assignment-poll-vote [poll]="poll"></os-assignment-poll-vote> <os-assignment-poll-vote [poll]="poll" *ngIf="poll.canBeVotedFor()"></os-assignment-poll-vote>
</div> </div>
</mat-card> </mat-card>

View File

@ -1,12 +1,34 @@
<ng-container *ngIf="poll && !poll.user_has_voted; else userHasVotes"> <ng-container *ngIf="poll">
<div *ngIf="vmanager.canVote(poll) && !deliveringVote" class="vote-button-grid"> <!-- own voting -->
<ng-container [ngTemplateOutlet]="votingArea"></ng-container>
<!-- Delegations -->
<ng-container *ngIf="user.is_present">
<div class="motion-vote-delegation" *ngFor="let delegation of delegations">
<mat-divider></mat-divider>
<ng-container
[ngTemplateOutlet]="votingArea"
[ngTemplateOutletContext]="{ delegation: delegation }"
></ng-container>
</div>
</ng-container>
</ng-container>
<ng-template #votingArea let-delegation="delegation">
<h4 *ngIf="delegation" class="motion-delegation-title">
<span>{{ 'Vote delegation for' | translate }}</span>
<span>&nbsp;{{ delegation.getFullName() }}</span>
</h4>
<div *ngIf="canVote(delegation)" class="vote-button-grid">
<!-- Voting --> <!-- Voting -->
<div class="vote-button" *ngFor="let option of voteOptions"> <div class="vote-button" *ngFor="let option of voteOptions">
<button <button
mat-raised-button mat-raised-button
(click)="saveVote(option.vote)" (click)="saveVote(option.vote, delegation)"
[ngClass]="currentVote && currentVote.vote === option.vote ? option.css : ''" [ngClass]="getActionButtonClass(option, delegation)"
[disabled]="deliveringVote" [disabled]="isDeliveringVote(delegation)"
> >
<mat-icon> {{ option.icon }}</mat-icon> <mat-icon> {{ option.icon }}</mat-icon>
</button> </button>
@ -14,21 +36,33 @@
</div> </div>
</div> </div>
<div *ngIf="deliveringVote" class="submit-vote-indicator"> <ng-container
*ngIf="!canVote(delegation)"
[ngTemplateOutlet]="cannotVote"
[ngTemplateOutletContext]="{ delegation: delegation }"
>
</ng-container>
<!-- Delivering -->
<div *ngIf="isDeliveringVote(delegation)" class="submit-vote-indicator">
<mat-spinner class="small-spinner"></mat-spinner> <mat-spinner class="small-spinner"></mat-spinner>
<br /> <br />
<span>{{ 'Delivering vote... Please wait!' | translate }}</span> <span>{{ 'Delivering vote... Please wait!' | translate }}</span>
</div> </div>
</ng-container> </ng-template>
<ng-template #userHasVotes> <ng-template #cannotVote let-delegation="delegation">
<div class="user-has-voted"> <!-- Success -->
<div *ngIf="hasAlreadyVoted(delegation) && !isDeliveringVote(delegation)" class="user-has-voted">
<div> <div>
<mat-icon class="vote-submitted"> <mat-icon class="vote-submitted"> check_circle </mat-icon>
check_circle
</mat-icon>
<br /> <br />
<span>{{ 'Voting successful.' | translate }}</span> <span>{{ 'Voting successful.' | translate }}</span>
</div> </div>
</div> </div>
<!-- Error -->
<div *ngIf="!hasAlreadyVoted(delegation) && !isDeliveringVote(delegation)">
<span>{{ getVotingError(delegation) | translate }}</span>
</div>
</ng-template> </ng-template>

View File

@ -8,6 +8,14 @@
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
} }
.motion-vote-delegation {
margin-top: 1em;
.motion-delegation-title {
font-weight: 500;
}
}
.submit-vote-indicator { .submit-vote-indicator {
margin-top: 1em; margin-top: 1em;
text-align: center; text-align: center;
@ -26,6 +34,10 @@
} }
} }
.mat-divider-horizontal {
position: initial;
}
.user-has-voted { .user-has-voted {
display: flex; display: flex;
text-align: center; text-align: center;

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -10,14 +10,8 @@ import { PromptService } from 'app/core/ui-services/prompt.service';
import { VotingService } from 'app/core/ui-services/voting.service'; import { VotingService } from 'app/core/ui-services/voting.service';
import { VoteValue } from 'app/shared/models/poll/base-vote'; import { VoteValue } from 'app/shared/models/poll/base-vote';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { BasePollVoteComponentDirective } from 'app/site/polls/components/base-poll-vote.component'; import { BasePollVoteComponentDirective, VoteOption } from 'app/site/polls/components/base-poll-vote.component';
import { ViewUser } from 'app/site/users/models/view-user';
interface VoteOption {
vote?: VoteValue;
css?: string;
icon?: string;
label?: string;
}
@Component({ @Component({
selector: 'os-motion-poll-vote', selector: 'os-motion-poll-vote',
@ -25,8 +19,7 @@ interface VoteOption {
styleUrls: ['./motion-poll-vote.component.scss'], styleUrls: ['./motion-poll-vote.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class MotionPollVoteComponent extends BasePollVoteComponentDirective<ViewMotionPoll> { export class MotionPollVoteComponent extends BasePollVoteComponentDirective<ViewMotionPoll> implements OnInit {
public currentVote: VoteOption = {};
public voteOptions: VoteOption[] = [ public voteOptions: VoteOption[] = [
{ {
vote: 'Y', vote: 'Y',
@ -53,30 +46,56 @@ export class MotionPollVoteComponent extends BasePollVoteComponentDirective<View
translate: TranslateService, translate: TranslateService,
matSnackbar: MatSnackBar, matSnackbar: MatSnackBar,
operator: OperatorService, operator: OperatorService,
public vmanager: VotingService, public votingService: VotingService,
private pollRepo: MotionPollRepositoryService, private pollRepo: MotionPollRepositoryService,
private promptService: PromptService, private promptService: PromptService,
private cd: ChangeDetectorRef private cd: ChangeDetectorRef
) { ) {
super(title, translate, matSnackbar, operator); super(title, translate, matSnackbar, operator, votingService);
// observe user updates to refresh the view on dynamic changes
this.subscriptions.push(
operator.getViewUserObservable().subscribe(() => {
this.cd.markForCheck();
})
);
} }
public async saveVote(vote: VoteValue): Promise<void> { public ngOnInit(): void {
this.currentVote.vote = vote; this.createVotingDataObjects();
const title = this.translate.instant('Submit selection now?'); this.cd.markForCheck();
const content = this.translate.instant('Your decision cannot be changed afterwards.'); }
const confirmed = await this.promptService.open(title, content);
if (confirmed) { public getActionButtonClass(voteOption: VoteOption, user: ViewUser = this.user): string {
this.deliveringVote = true; if (this.voteRequestData[user.id]?.vote === voteOption.vote) {
this.cd.markForCheck(); return voteOption.css;
}
return '';
}
this.pollRepo public async saveVote(vote: VoteValue, user: ViewUser = this.user): Promise<void> {
.vote(vote, this.poll.id) if (this.voteRequestData[user.id]) {
.catch(this.raiseError) this.voteRequestData[user.id].vote = vote;
.finally(() => {
this.deliveringVote = false; const title = this.translate.instant('Submit selection now?');
}); const content = this.translate.instant('Your decision cannot be changed afterwards.');
const confirmed = await this.promptService.open(title, content);
if (confirmed) {
this.deliveringVote[user.id] = true;
this.cd.markForCheck();
this.pollRepo
.vote(vote, this.poll.id, user.id)
.then(() => {
this.alreadyVoted[user.id] = true;
})
.catch(this.raiseError)
.finally(() => {
this.deliveringVote[user.id] = false;
this.cd.markForCheck();
});
}
} }
} }
} }

View File

@ -60,7 +60,7 @@
<div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted"> <div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress> <os-poll-progress [poll]="poll"></os-poll-progress>
</div> </div>
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote> <os-motion-poll-vote [poll]="poll" *ngIf="poll.canBeVotedFor()"></os-motion-poll-vote>
</ng-container> </ng-container>
<!-- Detail link --> <!-- Detail link -->

View File

@ -5,11 +5,20 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { VotingError } from 'app/core/ui-services/voting.service'; import { VotingData } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { VotingError, VotingService } from 'app/core/ui-services/voting.service';
import { VoteValue } from 'app/shared/models/poll/base-vote';
import { BaseViewComponentDirective } from 'app/site/base/base-view'; import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { ViewBasePoll } from '../models/view-base-poll'; import { ViewBasePoll } from '../models/view-base-poll';
export interface VoteOption {
vote?: VoteValue;
css?: string;
icon?: string;
label?: string;
}
@Directive() @Directive()
export abstract class BasePollVoteComponentDirective<V extends ViewBasePoll> extends BaseViewComponentDirective { export abstract class BasePollVoteComponentDirective<V extends ViewBasePoll> extends BaseViewComponentDirective {
@Input() @Input()
@ -17,22 +26,71 @@ export abstract class BasePollVoteComponentDirective<V extends ViewBasePoll> ext
public votingErrors = VotingError; public votingErrors = VotingError;
public deliveringVote = false; protected voteRequestData = {};
protected alreadyVoted = {};
protected deliveringVote = {};
protected user: ViewUser; protected user: ViewUser;
protected delegations: ViewUser[];
public constructor( public constructor(
title: Title, title: Title,
translate: TranslateService, translate: TranslateService,
matSnackbar: MatSnackBar, matSnackbar: MatSnackBar,
protected operator: OperatorService operator: OperatorService,
protected votingService: VotingService
) { ) {
super(title, translate, matSnackbar); super(title, translate, matSnackbar);
this.subscriptions.push( this.subscriptions.push(
this.operator.getViewUserObservable().subscribe(user => { operator.getViewUserObservable().subscribe(user => {
this.user = user; if (user) {
this.user = user;
this.delegations = user.voteDelegationsFrom;
}
}) })
); );
} }
protected createVotingDataObjects(): void {
if (this.user) {
this.voteRequestData[this.user.id] = {
votes: {}
} as VotingData;
this.alreadyVoted[this.user.id] = this.poll.user_has_voted;
this.deliveringVote[this.user.id] = false;
}
if (this.delegations) {
for (const delegation of this.delegations) {
this.voteRequestData[delegation.id] = {
votes: {}
} as VotingData;
this.alreadyVoted[delegation.id] = this.poll.hasVotedId(delegation.id);
this.deliveringVote[delegation.id] = false;
}
}
}
public isDeliveringVote(user: ViewUser = this.user): boolean {
return this.deliveringVote[user.id] === true;
}
public hasAlreadyVoted(user: ViewUser = this.user): boolean {
return this.alreadyVoted[user.id] === true;
}
public canVote(user: ViewUser = this.user): boolean {
return (
this.votingService.canVote(this.poll, user) && !this.isDeliveringVote(user) && !this.hasAlreadyVoted(user)
);
}
public getVotingError(user: ViewUser = this.user): string | void {
console.log('error ', this.votingService.getVotePermissionErrorVerbose(this.poll, user));
return this.votingService.getVotePermissionErrorVerbose(this.poll, user);
}
} }

View File

@ -39,8 +39,15 @@ export class PollProgressComponent extends BaseViewComponentDirective implements
.getViewModelListObservable() .getViewModelListObservable()
.pipe( .pipe(
map(users => map(users =>
/**
* Filter the users who would be able to vote:
* They are present or have their right to vote delegated
* They are in one of the voting groups
*/
users.filter( users.filter(
user => user.is_present && this.poll.groups_id.intersect(user.groups_id).length user =>
(user.is_present || user.isVoteRightDelegated) &&
this.poll.groups_id.intersect(user.groups_id).length
) )
) )
) )

View File

@ -114,6 +114,10 @@ export abstract class ViewBasePoll<
public canBeVotedFor: () => boolean; public canBeVotedFor: () => boolean;
public hasVotedId(userId: number): boolean {
return this.user_has_voted_for_delegations?.includes(userId);
}
public abstract getSlide(): ProjectorElementBuildDeskriptor; public abstract getSlide(): ProjectorElementBuildDeskriptor;
public abstract getContentObject(): BaseViewModel; public abstract getContentObject(): BaseViewModel;

View File

@ -8,7 +8,6 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager.
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations'; import { RelationDefinition } from 'app/core/definitions/relations';
import { BaseRepository, NestedModelDescriptors } from 'app/core/repositories/base-repository'; import { BaseRepository, NestedModelDescriptors } from 'app/core/repositories/base-repository';
import { VotingService } from 'app/core/ui-services/voting.service';
import { ModelConstructor } from 'app/shared/models/base/base-model'; import { ModelConstructor } from 'app/shared/models/base/base-model';
import { BasePoll, PollState } from 'app/shared/models/poll/base-poll'; import { BasePoll, PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel, TitleInformation } from 'app/site/base/base-view-model'; import { BaseViewModel, TitleInformation } from 'app/site/base/base-view-model';
@ -30,7 +29,6 @@ export abstract class BasePollRepositoryService<
protected baseModelCtor: ModelConstructor<M>, protected baseModelCtor: ModelConstructor<M>,
protected relationDefinitions: RelationDefinition<BaseViewModel>[] = [], protected relationDefinitions: RelationDefinition<BaseViewModel>[] = [],
protected nestedModelDescriptors: NestedModelDescriptors = {}, protected nestedModelDescriptors: NestedModelDescriptors = {},
private votingService: VotingService,
protected http: HttpService protected http: HttpService
) { ) {
super( super(
@ -53,7 +51,7 @@ export abstract class BasePollRepositoryService<
protected createViewModelWithTitles(model: M): V { protected createViewModelWithTitles(model: M): V {
const viewModel = super.createViewModelWithTitles(model); const viewModel = super.createViewModelWithTitles(model);
Object.defineProperty(viewModel, 'canBeVotedFor', { Object.defineProperty(viewModel, 'canBeVotedFor', {
value: () => this.votingService.canVote(viewModel) value: () => viewModel.isStarted
}); });
return viewModel; return viewModel;
} }

View File

@ -164,6 +164,16 @@
[inputListValues]="groups" [inputListValues]="groups"
></os-search-value-selector> ></os-search-value-selector>
</mat-form-field> </mat-form-field>
<!-- Delegate Vote -->
<mat-form-field>
<os-search-value-selector
formControlName="vote_delegated_from_users_id"
[multiple]="true"
placeholder="{{ 'Transferred voting rights from' | translate }}"
[inputListValues]="users"
></os-search-value-selector>
</mat-form-field>
</div> </div>
<div *ngIf="isAllowed('manage')"> <div *ngIf="isAllowed('manage')">
@ -303,6 +313,18 @@
</div> </div>
<div *ngIf="isAllowed('manage')"> <div *ngIf="isAllowed('manage')">
<!-- Own Vote delegations -->
<div *ngIf="user.voteDelegatedTo">
<h4>{{ 'Vote right delegated to:' | translate }}</h4>
<span>{{ user.voteDelegatedTo }}</span>
</div>
<!-- Received Vote delegations -->
<div *ngIf="user.voteDelegationsFrom && user.voteDelegationsFrom.length">
<h4>{{ 'Vote delegations from' | translate }}</h4>
<span>{{ user.voteDelegationsFrom }}</span>
</div>
<!-- Vote weight --> <!-- Vote weight -->
<div *ngIf="user.vote_weight && showVoteWeight"> <div *ngIf="user.vote_weight && showVoteWeight">
<h4>{{ 'Vote weight' | translate }}</h4> <h4>{{ 'Vote weight' | translate }}</h4>

View File

@ -71,6 +71,8 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O
*/ */
public readonly groups: BehaviorSubject<ViewGroup[]> = new BehaviorSubject<ViewGroup[]>([]); public readonly groups: BehaviorSubject<ViewGroup[]> = new BehaviorSubject<ViewGroup[]>([]);
public readonly users: BehaviorSubject<ViewUser[]> = new BehaviorSubject<ViewUser[]>([]);
/** /**
* Hold the list of genders (sexes) publicly to dynamically iterate in the view * Hold the list of genders (sexes) publicly to dynamically iterate in the view
*/ */
@ -124,6 +126,7 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O
.subscribe(active => (this.isVoteWeightActive = active)); .subscribe(active => (this.isVoteWeightActive = active));
this.groupRepo.getViewModelListObservableWithoutDefaultGroup().subscribe(this.groups); this.groupRepo.getViewModelListObservableWithoutDefaultGroup().subscribe(this.groups);
this.users = this.repo.getViewModelListBehaviorSubject();
} }
/** /**
@ -173,6 +176,7 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O
vote_weight: [], vote_weight: [],
about_me: [''], about_me: [''],
groups_id: [''], groups_id: [''],
vote_delegated_from_users_id: [''],
is_present: [true], is_present: [true],
is_committee: [false], is_committee: [false],
email: ['', Validators.email], email: ['', Validators.email],

View File

@ -62,7 +62,7 @@
> >
<div class="groupsCell"> <div class="groupsCell">
<div *ngIf="user.groups && user.groups.length"> <div *ngIf="user.groups && user.groups.length">
<os-icon-container icon="people"> <os-icon-container icon="people" noWrap="true">
<span *ngFor="let group of user.groups; let last = last"> <span *ngFor="let group of user.groups; let last = last">
{{ group.getTitle() | translate }}<span *ngIf="!last">,</span> {{ group.getTitle() | translate }}<span *ngIf="!last">,</span>
</span> </span>
@ -74,6 +74,10 @@
<div *ngIf="user.number" class="spacer-top-5"> <div *ngIf="user.number" class="spacer-top-5">
<os-icon-container icon="perm_identity">{{ user.number }}</os-icon-container> <os-icon-container icon="perm_identity">{{ user.number }}</os-icon-container>
</div> </div>
<div *ngIf="user.vote_delegated_to_id" class="spacer-top-5">
<os-icon-container icon="forward" noWrap="true">{{ user.voteDelegatedTo }}</os-icon-container>
</div>
</div> </div>
</div> </div>
@ -90,9 +94,7 @@
</mat-icon> </mat-icon>
<!-- Has comment indicator --> <!-- Has comment indicator -->
<mat-icon inline *ngIf="!!user.comment" matTooltip="{{ user.comment }}"> <mat-icon inline *ngIf="!!user.comment" matTooltip="{{ user.comment }}"> comment </mat-icon>
comment
</mat-icon>
<os-icon-container *ngIf="user.isSamlUser" icon="device_hub" <os-icon-container *ngIf="user.isSamlUser" icon="device_hub"
><span>{{ 'Is SAML user' | translate }}</span></os-icon-container ><span>{{ 'Is SAML user' | translate }}</span></os-icon-container
@ -270,6 +272,14 @@
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field>
<os-search-value-selector
[(ngModel)]="infoDialog.vote_delegated_from_users_id"
[multiple]="true"
placeholder="{{ 'Transferred voting rights from' | translate }}"
[inputListValues]="users"
></os-search-value-selector>
</mat-form-field>
<mat-form-field> <mat-form-field>
<mat-select placeholder="{{ 'Gender' | translate }}" [(ngModel)]="infoDialog.gender"> <mat-select placeholder="{{ 'Gender' | translate }}" [(ngModel)]="infoDialog.gender">
<mat-option>-</mat-option> <mat-option>-</mat-option>
@ -300,6 +310,7 @@
color="accent" color="accent"
[mat-dialog-close]="{ [mat-dialog-close]="{
groups_id: infoDialog.groups_id, groups_id: infoDialog.groups_id,
vote_delegated_from_users_id: infoDialog.vote_delegated_from_users_id,
gender: infoDialog.gender, gender: infoDialog.gender,
number: infoDialog.number, number: infoDialog.number,
structure_level: infoDialog.structure_level structure_level: infoDialog.structure_level

View File

@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid'; import { PblColumnDefinition } from '@pebula/ngrid';
import { BehaviorSubject } from 'rxjs';
import { OperatorService, Permission } from 'app/core/core-services/operator.service'; import { OperatorService, Permission } from 'app/core/core-services/operator.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
@ -55,6 +56,11 @@ interface InfoDialog {
* Structure level for one user. * Structure level for one user.
*/ */
structure_level: string; structure_level: string;
/**
* Transfer voting rights
*/
vote_delegated_from_users_id: number[];
} }
/** /**
@ -82,6 +88,8 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
*/ */
public groups: ViewGroup[]; public groups: ViewGroup[];
public readonly users: BehaviorSubject<ViewUser[]> = new BehaviorSubject<ViewUser[]>([]);
/** /**
* The list of all genders. * The list of all genders.
*/ */
@ -187,6 +195,7 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
// enable multiSelect for this listView // enable multiSelect for this listView
this.canMultiSelect = true; this.canMultiSelect = true;
this.users = this.repo.getViewModelListBehaviorSubject();
config.get<boolean>('users_enable_presence_view').subscribe(state => (this._presenceViewConfigured = state)); config.get<boolean>('users_enable_presence_view').subscribe(state => (this._presenceViewConfigured = state));
config.get<boolean>('users_activate_vote_weight').subscribe(active => (this.isVoteWeightActive = active)); config.get<boolean>('users_activate_vote_weight').subscribe(active => (this.isVoteWeightActive = active));
config.get<boolean>(this.selfPresentConfStr).subscribe(allowed => (this.allowSelfSetPresent = allowed)); config.get<boolean>(this.selfPresentConfStr).subscribe(allowed => (this.allowSelfSetPresent = allowed));
@ -203,9 +212,12 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
// Initialize the groups // Initialize the groups
this.groups = this.groupRepo.getViewModelList().filter(group => group.id !== 1); this.groups = this.groupRepo.getViewModelList().filter(group => group.id !== 1);
this.groupRepo
.getViewModelListObservable() this.subscriptions.push(
.subscribe(groups => (this.groups = groups.filter(group => group.id !== 1))); this.groupRepo
.getViewModelListObservable()
.subscribe(groups => (this.groups = groups.filter(group => group.id !== 1)))
);
} }
/** /**
@ -242,7 +254,8 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
groups_id: user.groups_id, groups_id: user.groups_id,
gender: user.gender, gender: user.gender,
structure_level: user.structure_level, structure_level: user.structure_level,
number: user.number number: user.number,
vote_delegated_from_users_id: user.vote_delegated_from_users_id
}; };
const dialogRef = this.dialog.open(this.userInfoDialog, infoDialogSettings); const dialogRef = this.dialog.open(this.userInfoDialog, infoDialogSettings);

View File

@ -82,9 +82,15 @@ export class ViewUser extends BaseProjectableViewModel<User> implements UserTitl
getDialogTitle: () => this.getTitle() getDialogTitle: () => this.getTitle()
}; };
} }
public canVoteFor(user: ViewUser): boolean {
return this.vote_delegated_from_users_id.includes(user.id);
}
} }
interface IUserRelations { interface IUserRelations {
groups: ViewGroup[]; groups: ViewGroup[];
voteDelegatedTo: ViewUser;
voteDelegationsFrom: ViewUser[];
} }
export interface ViewUser extends User, IUserRelations {} export interface ViewUser extends User, IUserRelations {}