+
+
-
-
{{ patron.city}}
+
+
-
-
-
{{ patronType }}
+
+
+
{{ patron.birth_date | dateTranslate:'mediumDate' }}
+
{{ patron.city }}
-
-
- {{ patron.patron.barcode }}
-
+
+
+ {{ patronTypeName }}
+
+
{{ patron.patron.barcode }}
-
-
0" class="col alert alert-warning" role="alert">
-
- {{ note.type | translate }}
-
+
+
+ {{ note.type.toString() }}
+
-
+
+
+
+
+
+
+
+
diff --git a/projects/admin/src/app/circulation/patron/card/card.component.ts b/projects/admin/src/app/circulation/patron/card/card.component.ts
index 4c0e6df70..9a74a2ce6 100644
--- a/projects/admin/src/app/circulation/patron/card/card.component.ts
+++ b/projects/admin/src/app/circulation/patron/card/card.component.ts
@@ -15,56 +15,28 @@
* along with this program. If not, see
.
*/
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
-import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
-import { RecordService } from '@rero/ng-core';
-import { map } from 'rxjs/operators';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
import { User } from '../../../class/user';
+import { getBootstrapLevel } from '../../../utils/utils';
+
@Component({
selector: 'admin-circulation-patron-detailed',
templateUrl: './card.component.html',
styleUrls: ['./card.component.scss']
})
-export class CardComponent implements OnInit {
+export class CardComponent {
@Input() patron: User;
+ @Input() circulationMessages = false;
@Output() clearPatron = new EventEmitter
();
- patronType$: any;
-
- constructor(
- private recordService: RecordService,
- private _sanitizer: DomSanitizer
- ) { }
-
- ngOnInit() {
- if (this.patron) {
- this.patronType$ = this.recordService.getRecord('patron_types', this.patron.patron.type.pid).pipe(
- map(patronType => patronType.metadata.name)
- );
- }
- }
clear() {
if (this.patron) {
this.clearPatron.emit(this.patron);
}
}
- /**
- * Get the patron notes.
- *
- * It replace a new line to the corresponding html code.
- * Allows to render html.
- */
- get notes(): Array<{ type: string, content: SafeHtml }> {
- if (!this.patron || !this.patron.notes || this.patron.notes.length < 1) {
- return null;
- }
- return this.patron.notes.map((note: any) => {
- return {
- type: note.type,
- content: this._sanitizer.bypassSecurityTrustHtml(
- note.content.replace('\n', '
'))
- };
- });
+
+ getBootstrapColor(level: string): string {
+ return getBootstrapLevel(level);
}
}
diff --git a/projects/admin/src/app/circulation/patron/loan/loan.component.ts b/projects/admin/src/app/circulation/patron/loan/loan.component.ts
index 43c74c135..eac760617 100644
--- a/projects/admin/src/app/circulation/patron/loan/loan.component.ts
+++ b/projects/admin/src/app/circulation/patron/loan/loan.component.ts
@@ -21,15 +21,13 @@ import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subscription } from 'rxjs';
import { Item, ItemAction, ItemNoteType, ItemStatus } from '../../../class/items';
import { User } from '../../../class/user';
-import { PatronBlockedMessagePipe } from '../../../pipe/patron-blocked-message.pipe';
import { ItemsService } from '../../../service/items.service';
import { PatronService } from '../../../service/patron.service';
import { UserService } from '../../../service/user.service';
@Component({
selector: 'admin-loan',
- templateUrl: './loan.component.html',
- providers: [PatronBlockedMessagePipe]
+ templateUrl: './loan.component.html'
})
export class LoanComponent implements OnInit, OnDestroy {
public placeholder: string = this._translate.instant(
@@ -65,15 +63,13 @@ export class LoanComponent implements OnInit, OnDestroy {
* @param _toastService: Toastr Service
* @param _patronService: Patron Service
* @param _userService: UserService
- * @param _patronBlockedMessagePipe: PatronBlockingPipe
*/
constructor(
private _itemsService: ItemsService,
private _translate: TranslateService,
private _toastService: ToastrService,
private _patronService: PatronService,
- private _userService: UserService,
- private _patronBlockedMessagePipe: PatronBlockedMessagePipe
+ private _userService: UserService
) {}
ngOnInit() {
@@ -158,7 +154,7 @@ export class LoanComponent implements OnInit, OnDestroy {
} else {
if (newItem.pending_loans && newItem.pending_loans[0].patron_pid !== this.patron.pid) {
this._toastService.error(
- this._translate.instant('Checkout impossible: the item is pending by another patron'),
+ this._translate.instant('Checkout impossible: the item is requested by another patron'),
this._translate.instant('Checkout')
);
this.searchText = '';
@@ -212,12 +208,14 @@ export class LoanComponent implements OnInit, OnDestroy {
this._translate.instant('Checkin')
);
}
+ this.patron.decrementCirculationStatistic('loans');
break;
}
case ItemAction.checkout: {
this._displayCirculationNote(newItem, ItemNoteType.CHECKOUT);
this.checkedOutItems.unshift(newItem);
this.checkedInItems = this.checkedInItems.filter(currItem => currItem.pid !== newItem.pid);
+ this.patron.incrementCirculationStatistic('loans');
break;
}
case ItemAction.extend_loan: {
@@ -236,23 +234,24 @@ export class LoanComponent implements OnInit, OnDestroy {
errorMessage = err.error.message;
}
if (err.error.status === 403) {
- // Specific case when user is blocked (for better user comprehension)
- if (errorMessage !== '' && errorMessage.startsWith('BLOCKED USER')) {
- const blockedMessage = this._patronBlockedMessagePipe.transform(this.patron);
- this._toastService.error(
- `${this._translate.instant('Checkout not possible.')} ${blockedMessage}`,
- this._translate.instant('Circulation')
- );
- } else {
- this._toastService.error(
- this._translate.instant('Checkout is not allowed by circulation policy'),
- this._translate.instant('Checkout')
- );
+ let message = errorMessage || this._translate.instant('Checkout is not allowed by circulation policy');
+ let title = this._translate.instant('Circulation');
+ if (message.includes(': ')) {
+ const splittedData = message.split(': ', 2);
+ title = splittedData[0].trim();
+ message = splittedData[1].trim();
+ message = message.charAt(0).toUpperCase() + message.slice(1);
}
+ this._toastService.error(
+ message,
+ title,
+ {disableTimeOut: true, closeButton: true, enableHtml: true}
+ );
} else {
this._toastService.error(
this._translate.instant('An error occurred on the server: ') + errorMessage,
- this._translate.instant('Circulation')
+ this._translate.instant('Circulation'),
+ {disableTimeOut: true, closeButton: true, enableHtml: true}
);
}
this.searchText = '';
diff --git a/projects/admin/src/app/circulation/patron/main/main.component.html b/projects/admin/src/app/circulation/patron/main/main.component.html
index 68fa54e87..90211bd8b 100644
--- a/projects/admin/src/app/circulation/patron/main/main.component.html
+++ b/projects/admin/src/app/circulation/patron/main/main.component.html
@@ -17,18 +17,11 @@
-
-
@@ -39,10 +32,12 @@
class="nav-link"
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: true}"
- [routerLink]="['/circulation', 'patron', patron.patron.barcode, 'loan']"
- translate
- >Checkin/Checkout
+ [routerLink]="['/circulation', 'patron', patron.patron.barcode, 'loan']">
+ {{ 'Checkin/Checkout' | translate }}
+
0" class="badge badge-info font-weight-normal">
+ {{ getCirculationStatistics('loans') }}
+
+
To pick up
+ [routerLink]="['/circulation', 'patron', patron.patron.barcode, 'pickup']">
+ {{ 'To pick up' | translate }}
+ 0" class="badge badge-info font-weight-normal">
+ {{ getCirculationStatistics('pickup') }}
+
+
Pending
+ [routerLink]="['/circulation', 'patron', patron.patron.barcode, 'pending']">
+ {{ 'Pending' | translate }}
+ 0 as stat" class="badge badge-info font-weight-normal">
+ {{ getCirculationStatistics('pending') }}
+
+
- {{ 'Fees' }}
+ {{ 'Fees' | translate }}
0" class="badge badge-warning font-weight-normal">
{{ transactionsTotalAmount | currency: organisation.default_currency }}
diff --git a/projects/admin/src/app/circulation/patron/main/main.component.ts b/projects/admin/src/app/circulation/patron/main/main.component.ts
index 94ff3a305..8a9a87c2b 100644
--- a/projects/admin/src/app/circulation/patron/main/main.component.ts
+++ b/projects/admin/src/app/circulation/patron/main/main.component.ts
@@ -17,7 +17,8 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
-import { Observable, Subscription } from 'rxjs';
+import { Subscription } from 'rxjs';
+import { LoanState } from '../../../class/items';
import { User } from '../../../class/user';
import { OrganisationService } from '../../../service/organisation.service';
import { PatronService } from '../../../service/patron.service';
@@ -40,31 +41,45 @@ export class MainComponent implements OnInit, OnDestroy {
/** Subsription to current patron */
private _patronSubscription$: Subscription;
+ /** Constructor
+ * @param _route - ActivatedRoute
+ * @param _router - Router
+ * @param _patronService - PatronService
+ * @param _patronTransactionService - PatronTransactionService
+ * @param _organisationService - OrganisationService
+ */
constructor(
- private route: ActivatedRoute,
- private router: Router,
- private patronService: PatronService,
- private patronTransactionService: PatronTransactionService,
- private organisationService: OrganisationService) { }
+ private _route: ActivatedRoute,
+ private _router: Router,
+ private _patronService: PatronService,
+ private _patronTransactionService: PatronTransactionService,
+ private _organisationService: OrganisationService) { }
+
ngOnInit() {
- const barcode = this.route.snapshot.paramMap.get('barcode');
- this._patronSubscription$ = this.patronService.getPatron(barcode).subscribe((patron) => {
+ const barcode = this._route.snapshot.paramMap.get('barcode');
+ this._patronSubscription$ = this._patronService.getPatron(barcode).subscribe((patron) => {
if (patron) {
this.patron = patron;
- this._patronTransactionSubscription$ = this.patronTransactionService.patronTransactionsSubject$.subscribe(
+ this._patronService.getCirculationInformations(patron.pid).subscribe((data) => {
+ this._parseStatistics(data.statistics || {});
+ for (const message of (data.messages || [])) {
+ this.patron.addCirculationMessage(message as any);
+ }
+ });
+ this._patronTransactionSubscription$ = this._patronTransactionService.patronTransactionsSubject$.subscribe(
(transactions) => {
- this.transactionsTotalAmount = this.patronTransactionService.computeTotalTransactionsAmount(transactions);
+ this.transactionsTotalAmount = this._patronTransactionService.computeTotalTransactionsAmount(transactions);
}
);
- this.patronTransactionService.emitPatronTransactionByPatron(patron.pid, undefined, 'open');
+ this._patronTransactionService.emitPatronTransactionByPatron(patron.pid, undefined, 'open');
}
});
}
clearPatron() {
- this.patronService.clearPatron();
- this.router.navigate(['/circulation']);
+ this._patronService.clearPatron();
+ this._router.navigate(['/circulation']);
}
ngOnDestroy() {
@@ -74,13 +89,47 @@ export class MainComponent implements OnInit, OnDestroy {
if (this._patronSubscription$) {
this._patronSubscription$.unsubscribe();
}
- this.patronService.clearPatron();
+ this._patronService.clearPatron();
}
/** Get current organisation
* @return: current organisation
*/
get organisation() {
- return this.organisationService.organisation;
+ return this._organisationService.organisation;
+ }
+
+ /** Find and return a circulation statistic.
+ * @param type: the type of circulation statistics to find.
+ */
+ getCirculationStatistics(type: string): number {
+ return (
+ this.patron
+ && 'circulation_informations' in this.patron
+ && 'statistics' in this.patron.circulation_informations
+ && type in this.patron.circulation_informations.statistics
+ ) ? this.patron.circulation_informations.statistics[type]
+ : 0;
+ }
+
+ /**
+ * Parse statistics from API into corresponding tab statistic.
+ * @param data: a dictionary of loan state/value
+ */
+ private _parseStatistics(data: any) {
+ for (const key of Object.keys(data)) {
+ switch (key) {
+ case LoanState[LoanState.PENDING]:
+ case LoanState[LoanState.ITEM_IN_TRANSIT_FOR_PICKUP]:
+ this.patron.incrementCirculationStatistic('pending', Number(data[key]));
+ break;
+ case LoanState[LoanState.ITEM_AT_DESK]:
+ this.patron.incrementCirculationStatistic('pickup', Number(data[key]));
+ break;
+ case LoanState[LoanState.ITEM_ON_LOAN]:
+ this.patron.incrementCirculationStatistic('loans', Number(data[key]));
+ break;
+ }
+ }
}
}
diff --git a/projects/admin/src/app/class/user.ts b/projects/admin/src/app/class/user.ts
index 9c9010814..03162cecc 100644
--- a/projects/admin/src/app/class/user.ts
+++ b/projects/admin/src/app/class/user.ts
@@ -20,11 +20,6 @@ export function _(str) {
return marker(str);
}
-export enum UserNoteType {
- PUBLIC = _('public_note'),
- STAFF = _('staff_note')
-}
-
/* tslint:disable */
// required as json properties is not lowerCamelCase
export class User {
@@ -63,6 +58,11 @@ export class User {
items?: any[];
displayPatronMode = true;
currentLibrary: string;
+ circulation_informations: {
+ messages: Array<{type: string, content: string}>,
+ statistics: any;
+ }
+
/** Locale storage name key */
static readonly STORAGE_KEY = 'user';
@@ -98,7 +98,7 @@ export class User {
* @return boolean
*/
hasRoles(roles: Array, operator: string = 'and') {
- const intersection = roles.filter(role => this.roles.includes(role));
+ const intersection = roles.filter(role => this.getRoles().includes(role));
return (operator === 'and')
? intersection.length == roles.length // all requested roles are present into user roles.
: intersection.length > 0 // at least one requested roles are present into user roles.
@@ -112,6 +112,22 @@ export class User {
return this.roles ? this.roles : [];
}
+ /**
+ * Is this user a patron?
+ * @return a boolean
+ */
+ get isPatron() {
+ return this.hasRole('patron');
+ }
+
+ /**
+ * Is this user a librarian?
+ * @return a boolean
+ */
+ get isLibrarian() {
+ return this.hasRole('librarian');
+ }
+
/**
* Set current library pid
* @param pid - string
@@ -129,21 +145,48 @@ export class User {
}
/**
- * Is this user a patron?
- * @return a boolean
+ * Increment a circulation statistic for this user.
+ * @param type - string: the statistic type (pending, request, loans, ...)
+ * @param idx - number: the number to increment.
+ * @return the new statistic counter
*/
- get isPatron() {
- return this.hasRole('patron');
+ incrementCirculationStatistic(type: string, idx: number = 1): number {
+ this.circulation_informations = this.circulation_informations || {messages: [], statistics: {}};
+ this.circulation_informations.statistics[type] = (this.circulation_informations.statistics[type] || 0) + idx;
+ return this.circulation_informations.statistics[type];
}
/**
- * Is this user a librarian?
- * @return a boolean
+ * Decrement a circulation statistic for this user.
+ * @param type - string: the statistic type (pending, request, loans, ...)
+ * @param idx - number: the number to decrement.
+ * @return the new statistic counter
*/
- get isLibrarian() {
- return this.hasRole('librarian');
+ decrementCirculationStatistic(type: string, idx: number = 1): number {
+ this.circulation_informations = this.circulation_informations || {messages: [], statistics: {}};
+ if (!(type in this.circulation_informations.statistics)) {
+ return 0;
+ }
+ const new_stat = this.circulation_informations.statistics[type] - idx;
+ this.circulation_informations.statistics[type] = (new_stat > 0)
+ ? new_stat
+ : 0;
+ return this.circulation_informations.statistics[type];
}
+ /**
+ * Append a circulation message for this user.
+ * @param message: the message to append
+ */
+ addCirculationMessage(message: {type: string, content: string}) {
+ this.circulation_informations = this.circulation_informations || {messages: [], statistics: {}};
+ this.circulation_informations.messages.push(message);
+ }
+}
+
+export enum UserNoteType {
+ PUBLIC = _('public_note'),
+ STAFF = _('staff_note')
}
export interface Organisation {
diff --git a/projects/admin/src/app/record/detail-view/patron-types-detail-view/patron-types-detail-view.component.html b/projects/admin/src/app/record/detail-view/patron-types-detail-view/patron-types-detail-view.component.html
index 4b706b114..aeeed0bbc 100644
--- a/projects/admin/src/app/record/detail-view/patron-types-detail-view/patron-types-detail-view.component.html
+++ b/projects/admin/src/app/record/detail-view/patron-types-detail-view/patron-types-detail-view.component.html
@@ -53,7 +53,7 @@ Limits
- General limit
- - {{ record.metadata.limits.checkout_limits.general_limit }}
+ - {{ record.metadata.limits.checkout_limits.global_limit }}
- Library limit
diff --git a/projects/admin/src/app/record/detail-view/template-detail-view/template-detail-view.component.html b/projects/admin/src/app/record/detail-view/template-detail-view/template-detail-view.component.html
index bfb38345f..a0a965618 100644
--- a/projects/admin/src/app/record/detail-view/template-detail-view/template-detail-view.component.html
+++ b/projects/admin/src/app/record/detail-view/template-detail-view/template-detail-view.component.html
@@ -29,9 +29,7 @@
Description
- -
- {{ record.metadata.description }}
-
+
@@ -51,7 +49,7 @@
Type
- -
+
-
{{ record.metadata.template_type }}
diff --git a/projects/admin/src/app/service/patron.service.ts b/projects/admin/src/app/service/patron.service.ts
index 57add0b33..985ef81d5 100644
--- a/projects/admin/src/app/service/patron.service.ts
+++ b/projects/admin/src/app/service/patron.service.ts
@@ -168,6 +168,16 @@ export class PatronService {
return this.getLoans(query, '-end_date');
}
+ /**
+ * Get circulation statistics about a patron
+ * @param patronPid - string : the patron pid to search
+ * @return Observable
+ */
+ getCirculationInformations(patronPid: string): Observable {
+ const url = [this._apiService.getEndpointByType('patrons'), patronPid, 'circulation_informations'].join('/');
+ return this._http.get(url);
+ }
+
/**
* Get Loans by query
* @param query - string : Query to execute to find loans
@@ -182,4 +192,5 @@ export class PatronService {
map(hits => this._recordService.totalHits(hits.total) === 0 ? [] : hits.hits)
);
}
+
}
diff --git a/projects/admin/src/app/service/user.service.ts b/projects/admin/src/app/service/user.service.ts
index c8cc9e9d9..0c95e2877 100644
--- a/projects/admin/src/app/service/user.service.ts
+++ b/projects/admin/src/app/service/user.service.ts
@@ -18,8 +18,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { RecordService } from '@rero/ng-core';
-import { forkJoin, of, Subject } from 'rxjs';
-import { concatAll, map } from 'rxjs/operators';
+import { Subject } from 'rxjs';
import { User } from '../class/user';
import { AppConfigService } from './app-config.service';
@@ -28,15 +27,21 @@ import { AppConfigService } from './app-config.service';
})
export class UserService {
+ // CLASS ATTRIBUTES ================================================
+ /** is user already loaded */
+ public userLoaded = false;
+
+ /** Subject emitting the user loaded */
private onUserLoaded: Subject = new Subject();
+ /** user */
private user: User;
- public userLoaded = false;
-
/** Allow interface access */
private _allowInterfaceAccess = false;
+ // GETTER FUNCTIONS ================================================
+ /** return onUserLoaded subject as observable */
get onUserLoaded$() {
return this.onUserLoaded.asObservable();
}
@@ -46,71 +51,60 @@ export class UserService {
return this._allowInterfaceAccess;
}
+ // CLASS CONSTRUCTOR ===============================================
+ /**
+ * Constructor
+ * @param http - HttpClient
+ * @param recordService - RecordService
+ * @param _appConfigService - AppConfigService
+ */
constructor(
private http: HttpClient,
private recordService: RecordService,
private _appConfigService: AppConfigService
) { }
+ // CLASS FUNCTIONS =================================================
+ /**
+ * Get the current user. Should be used only when the user is loaded
+ * @return the current user as User class
+ */
getCurrentUser() {
return this.user;
}
- hasRole(role: string) {
+ /**
+ * Check if the current load user has a specific role
+ * @param role - string: the role to check
+ * @return True is the user has the role, False otherwise
+ */
+ hasRole(role: string): boolean {
return this.getCurrentUser().hasRole(role);
}
- hasRoles(roles: Array) {
- return this.getCurrentUser().hasRoles(roles);
- }
-
- public loadLoggedUser() {
+ /**
+ * Load the user resource corresponding to the user logged.
+ * This function will populate the `user` class parameter and emit the user at the end
+ */
+ loadLoggedUser() {
this.http.get(User.LOGGED_URL).subscribe(data => {
const user = data.metadata;
if (user && user.library) {
user.currentLibrary = user.library.pid;
}
this.user = new User(user);
- this.isAuthorizedAccess();
+ this._allowInterfaceAccess = this.isAuthorizedAccess();
this.userLoaded = true;
this.onUserLoaded.next(data);
});
}
- getUser(pid: string) {
- return this.recordService.getRecord('patrons', pid, 1).pipe(
- map(data => {
- if (data) {
- const patron = new User(data.metadata);
- return forkJoin(
- of(patron),
- this.recordService.getRecord('patron_types', patron.patron.type.pid)
- ).pipe(
- map(patronAndType => {
- const newPatron = patronAndType[0];
- const patronType = patronAndType[1];
- if (patronType) {
- newPatron.patron.type = patronType.metadata;
- }
- return newPatron;
- })
- );
- }
- }),
- concatAll()
- );
- }
-
+ // private methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/**
- * Check if you are an access ton admin interface
+ * Check if the current logged user are an access ton admin interface
+ * @return True is access is authorized, False otherwise
*/
- private isAuthorizedAccess() {
- const adminRoles = this._appConfigService.adminRoles;
- this._allowInterfaceAccess = this.user.getRoles()
- .filter((role: string) => {
- if (adminRoles.indexOf(role) > -1) {
- return role;
- }
- }).length > 0;
+ private isAuthorizedAccess(): boolean {
+ return this.user.hasRoles(this._appConfigService.adminRoles, 'or');
}
}
diff --git a/projects/admin/src/app/utils/utils.ts b/projects/admin/src/app/utils/utils.ts
new file mode 100644
index 000000000..31afb1947
--- /dev/null
+++ b/projects/admin/src/app/utils/utils.ts
@@ -0,0 +1,35 @@
+/*
+ * RERO ILS UI
+ * Copyright (C) 2020 RERO
+ * Copyright (C) 2020 UCLouvain
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/** Convert a level string to boostrap level
+ * See : https://getbootstrap.com/docs/4.5/components/alerts/
+ * @param level - string: the level string to convert
+ * @return the bootstrap level corresponding (info by default)
+ */
+export function getBootstrapLevel(level: string) {
+ switch (level) {
+ case 'error':
+ return 'danger';
+ case 'warning':
+ return 'warning';
+ case 'debug':
+ return 'dark';
+ default:
+ return 'info';
+ }
+}