Merge pull request 'Worker calendar - Add absence' (#336) from 1876-worker_calendar into dev

Reviewed-on: #336
Reviewed-by: Bernat Exposito <bernat@verdnatura.es>
Reviewed-by: Carlos Jimenez <carlosjr@verdnatura.es>
This commit is contained in:
Joan Sanchez 2020-07-16 10:44:13 +00:00
commit 16be14534b
16 changed files with 662 additions and 39 deletions

View File

@ -0,0 +1,5 @@
ALTER TABLE `postgresql`.`calendar_employee`
ADD COLUMN `id` INT NULL AUTO_INCREMENT FIRST,
ADD UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE,
ADD INDEX `id_index` (`id` ASC) VISIBLE;
;

View File

@ -0,0 +1,12 @@
USE `vn`;
CREATE
OR REPLACE ALGORITHM = UNDEFINED
DEFINER = `root`@`%`
SQL SECURITY DEFINER
VIEW `workerCalendar2` AS
SELECT
`ce`.`id` AS `id`,
`ce`.`business_id` AS `businessFk`,
`ce`.`calendar_state_id` AS `absenceTypeFk`,
`ce`.`date` AS `dated`
FROM `postgresql`.`calendar_employee` `ce`;

View File

@ -135,7 +135,7 @@ export default class Calendar extends FormInput {
$days: [day],
$type: 'day'
});
this.repaint();
// this.repaint();
}
/*

View File

@ -0,0 +1,58 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('createAbsence', {
description: 'Creates a new worker absence',
accepts: [{
arg: 'id',
type: 'Number',
description: 'The worker id',
http: {source: 'path'}
},
{
arg: 'absenceTypeId',
type: 'Number',
required: true
},
{
arg: 'dated',
type: 'Date',
required: false
}],
returns: {
type: 'Object',
root: true
},
http: {
path: `/:id/createAbsence`,
verb: 'POST'
}
});
Self.createAbsence = async(ctx, id, absenceTypeId, dated) => {
const models = Self.app.models;
const userId = ctx.req.accessToken.userId;
const isSubordinate = await models.Worker.isSubordinate(ctx, id);
const isTeamBoss = await models.Account.hasRole(userId, 'teamBoss');
if (!isSubordinate || (isSubordinate && userId == id && !isTeamBoss))
throw new UserError(`You don't have enough privileges`);
const labour = await models.WorkerLabour.findOne({
where: {
and: [
{workerFk: id},
{or: [{
ended: {gte: [dated]}
}, {ended: null}]}
]
}
});
return models.WorkerCalendar.create({
businessFk: labour.businessFk,
absenceTypeFk: absenceTypeId,
dated: dated
});
};
};

View File

@ -0,0 +1,37 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('deleteAbsence', {
description: 'Deletes a worker absence',
accepts: [{
arg: 'id',
type: 'Number',
description: 'The worker id',
http: {source: 'path'}
},
{
arg: 'absenceId',
type: 'Number',
required: true
}],
returns: 'Object',
http: {
path: `/:id/deleteAbsence`,
verb: 'DELETE'
}
});
Self.deleteAbsence = async(ctx, id, absenceId) => {
const models = Self.app.models;
const userId = ctx.req.accessToken.userId;
const isSubordinate = await models.Worker.isSubordinate(ctx, id);
const isTeamBoss = await models.Account.hasRole(userId, 'teamBoss');
if (!isSubordinate || (isSubordinate && userId == id && !isTeamBoss))
throw new UserError(`You don't have enough privileges`);
const absence = await models.WorkerCalendar.findById(absenceId);
return absence.destroy();
};
};

View File

@ -0,0 +1,39 @@
const app = require('vn-loopback/server/server');
describe('Worker createAbsence()', () => {
const workerId = 106;
let createdAbsence;
afterAll(async() => {
const absence = await app.models.WorkerCalendar.findById(createdAbsence.id);
await absence.destroy();
});
it('should return an error for a user without enough privileges', async() => {
const ctx = {req: {accessToken: {userId: 106}}};
const absenceTypeId = 1;
const dated = new Date();
let error;
await app.models.Worker.createAbsence(ctx, workerId, absenceTypeId, dated).catch(e => {
error = e;
}).finally(() => {
expect(error.message).toEqual(`You don't have enough privileges`);
});
expect(error).toBeDefined();
});
it('should create a new absence', async() => {
const ctx = {req: {accessToken: {userId: 37}}};
const absenceTypeId = 1;
const dated = new Date();
createdAbsence = await app.models.Worker.createAbsence(ctx, workerId, absenceTypeId, dated);
const expectedBusinessId = 106;
const expectedAbsenceTypeId = 1;
expect(createdAbsence.businessFk).toEqual(expectedBusinessId);
expect(createdAbsence.absenceTypeFk).toEqual(expectedAbsenceTypeId);
});
});

View File

@ -0,0 +1,38 @@
const app = require('vn-loopback/server/server');
describe('Worker deleteAbsence()', () => {
const workerId = 106;
let createdAbsence;
it('should return an error for a user without enough privileges', async() => {
const ctx = {req: {accessToken: {userId: 106}}};
const businessId = 106;
createdAbsence = await app.models.WorkerCalendar.create({
businessFk: businessId,
absenceTypeFk: 1,
dated: new Date()
});
let error;
await app.models.Worker.deleteAbsence(ctx, workerId, createdAbsence.id).catch(e => {
error = e;
}).finally(() => {
expect(error.message).toEqual(`You don't have enough privileges`);
});
expect(error).toBeDefined();
});
it('should create a new absence', async() => {
const ctx = {req: {accessToken: {userId: 37}}};
const businessId = 106;
expect(createdAbsence.businessFk).toEqual(businessId);
await app.models.Worker.deleteAbsence(ctx, workerId, createdAbsence.id);
const deletedAbsence = await app.models.WorkerCalendar.findById(createdAbsence.id);
expect(deletedAbsence).toBeNull();
});
});

View File

@ -0,0 +1,38 @@
const app = require('vn-loopback/server/server');
describe('Worker updateAbsence()', () => {
const workerId = 106;
let createdAbsence;
afterAll(async() => {
const absence = await app.models.WorkerCalendar.findById(createdAbsence.id);
await absence.destroy();
});
it('should return an error for a user without enough privileges', async() => {
const ctx = {req: {accessToken: {userId: 106}}};
const expectedAbsenceTypeId = 2;
createdAbsence = await app.models.WorkerCalendar.create({
businessFk: 106,
absenceTypeFk: 1,
dated: new Date()
});
let error;
await app.models.Worker.updateAbsence(ctx, workerId, createdAbsence.id, expectedAbsenceTypeId).catch(e => {
error = e;
}).finally(() => {
expect(error.message).toEqual(`You don't have enough privileges`);
});
expect(error).toBeDefined();
});
it('should create a new absence', async() => {
const ctx = {req: {accessToken: {userId: 37}}};
const expectedAbsenceTypeId = 2;
const updatedAbsence = await app.models.Worker.updateAbsence(ctx, workerId, createdAbsence.id, expectedAbsenceTypeId);
expect(updatedAbsence.absenceTypeFk).toEqual(expectedAbsenceTypeId);
});
});

View File

@ -0,0 +1,42 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('updateAbsence', {
description: 'Updates a worker absence',
accepts: [{
arg: 'id',
type: 'Number',
description: 'The worker id',
http: {source: 'path'}
},
{
arg: 'absenceId',
type: 'Number',
required: true
},
{
arg: 'absenceTypeId',
type: 'Number',
required: true
}],
returns: 'Object',
http: {
path: `/:id/updateAbsence`,
verb: 'PATCH'
}
});
Self.updateAbsence = async(ctx, id, absenceId, absenceTypeId) => {
const models = Self.app.models;
const userId = ctx.req.accessToken.userId;
const isSubordinate = await models.Worker.isSubordinate(ctx, id);
const isTeamBoss = await models.Account.hasRole(userId, 'teamBoss');
if (!isSubordinate || (isSubordinate && userId == id && !isTeamBoss))
throw new UserError(`You don't have enough privileges`);
const absence = await models.WorkerCalendar.findById(absenceId);
return absence.updateAttribute('absenceTypeFk', absenceTypeId);
};
};

View File

@ -3,29 +3,22 @@
"base": "VnModel",
"options": {
"mysql": {
"table": "workerCalendar"
"table": "workerCalendar2"
}
},
"properties": {
"businessFk": {
"id": 1,
"id": {
"id": true,
"type": "Number"
},
"workerFk": {
"id": 2,
"businessFk": {
"type": "Number"
},
"dated": {
"id": 3,
"type": "Date"
}
},
"relations": {
"worker": {
"type": "belongsTo",
"model": "Worker",
"foreignKey": "workerFk"
},
"absenceType": {
"type": "belongsTo",
"model": "AbsenceType",

View File

@ -4,4 +4,7 @@ module.exports = Self => {
require('../methods/worker/isSubordinate')(Self);
require('../methods/worker/getWorkedHours')(Self);
require('../methods/worker/uploadFile')(Self);
require('../methods/worker/createAbsence')(Self);
require('../methods/worker/deleteAbsence')(Self);
require('../methods/worker/updateAbsence')(Self);
};

View File

@ -5,15 +5,19 @@
</vn-crud-model>
<div class="vn-w-lg">
<vn-card class="vn-pa-sm calendars">
<vn-calendar
ng-repeat="month in $ctrl.months"
data="$ctrl.events"
default-date="month"
format-day="$ctrl.formatDay($day, $element)"
display-controls="false"
hide-contiguous="true"
hide-year="true">
</vn-calendar>
<vn-icon ng-if="::$ctrl.isSubordinate" icon="info" color-marginal
vn-tooltip="To start adding absences, click an absence type from the right menu and then on the day you want to add an absence">
</vn-icon>
<vn-calendar
ng-repeat="month in $ctrl.months"
data="$ctrl.events"
default-date="month"
format-day="$ctrl.formatDay($day, $element)"
display-controls="false"
hide-contiguous="true"
hide-year="true"
on-selection="$ctrl.onSelection($event, $days)">
</vn-calendar>
</vn-card>
</div>
<vn-side-menu side="right">
@ -26,12 +30,19 @@
</div>
</div>
<div class="vn-pt-md" style="overflow: hidden;">
<vn-chip ng-repeat="absenceType in absenceTypes">
<vn-chip ng-repeat="absenceType in absenceTypes" ng-class="::{'selectable': $ctrl.isSubordinate}"
ng-click="$ctrl.pick(absenceType)">
<vn-avatar
ng-style="{backgroundColor: absenceType.rgb}">
<vn-icon icon="check" ng-if="absenceType.id == $ctrl.absenceType.id"></vn-icon>
</vn-avatar>
{{absenceType.name}}
</vn-chip>
</div>
</div>
</vn-side-menu>
</vn-side-menu>
<vn-confirm
vn-id="confirm"
message="This item will be deleted"
question="Are you sure you want to continue?">
</vn-confirm>

View File

@ -43,15 +43,17 @@ class Controller extends Section {
set worker(value) {
this._worker = value;
if (!value) return;
let params = {
workerFk: this.worker.id,
started: this.started,
ended: this.ended
};
this.$http.get(`WorkerCalendars/absences`, {params})
.then(res => this.onData(res.data));
if (value) {
this.refresh().then(() => this.repaint());
this.getIsSubordinate();
}
}
getIsSubordinate() {
this.$http.get(`Workers/${this.worker.id}/isSubordinate`).then(res =>
this.isSubordinate = res.data
);
}
onData(data) {
@ -79,12 +81,12 @@ class Controller extends Section {
let type = absence.absenceType;
addEvent(absence.dated, {
name: type.name,
color: type.rgb
color: type.rgb,
type: type.code,
absenceId: absence.id
});
});
}
this.repaint();
}
repaint() {
@ -102,6 +104,105 @@ class Controller extends Section {
dayNumber.style.backgroundColor = event.color;
dayNumber.style.color = 'rgba(0, 0, 0, 0.7)';
}
pick(absenceType) {
if (!this.isSubordinate) return;
if (absenceType == this.absenceType)
absenceType = null;
this.absenceType = absenceType;
}
onSelection($event, $days) {
if (!this.absenceType)
return this.vnApp.showMessage(this.$t('Choose an absence type from the right menu'));
const day = $days[0];
const stamp = day.getTime();
const event = this.events[stamp];
const calendar = $event.target.closest('vn-calendar').$ctrl;
if (event) {
if (event.type == this.absenceType.code)
this.delete(calendar, day, event);
else
this.edit(calendar, event);
} else
this.create(calendar, day);
}
create(calendar, dated) {
const absenceType = this.absenceType;
const params = {
dated: dated,
absenceTypeId: absenceType.id
};
const path = `Workers/${this.$params.id}/createAbsence`;
this.$http.post(path, params).then(res => {
const newEvent = res.data;
this.events[dated.getTime()] = {
name: absenceType.name,
color: absenceType.rgb,
type: absenceType.code,
absenceId: newEvent.id
};
this.repaintCanceller(() =>
this.refresh().then(calendar.repaint())
);
});
}
edit(calendar, event) {
const absenceType = this.absenceType;
const params = {
absenceId: event.absenceId,
absenceTypeId: absenceType.id
};
const path = `Workers/${this.$params.id}/updateAbsence`;
this.$http.patch(path, params).then(() => {
event.color = absenceType.rgb;
event.name = absenceType.name;
event.type = absenceType.code;
this.repaintCanceller(() =>
this.refresh().then(calendar.repaint())
);
});
}
delete(calendar, day, event) {
const params = {absenceId: event.absenceId};
const path = `Workers/${this.$params.id}/deleteAbsence`;
this.$http.delete(path, {params}).then(() => {
delete this.events[day.getTime()];
this.repaintCanceller(() =>
this.refresh().then(calendar.repaint())
);
});
}
repaintCanceller(cb) {
if (this.canceller) {
clearTimeout(this.canceller);
this.canceller = null;
}
this.canceller = setTimeout(
() => cb(), 500);
}
refresh() {
const params = {
workerFk: this.worker.id,
started: this.started,
ended: this.ended
};
return this.$http.get(`WorkerCalendars/absences`, {params})
.then(res => this.onData(res.data));
}
}
ngModule.component('vnWorkerCalendar', {

View File

@ -3,17 +3,23 @@ import './index';
describe('Worker', () => {
describe('Component vnWorkerCalendar', () => {
let $httpBackend;
let $httpParamSerializer;
let $scope;
let controller;
let year = new Date().getFullYear();
beforeEach(ngModule('worker'));
beforeEach(angular.mock.inject(($componentController, $rootScope, _$httpBackend_) => {
beforeEach(angular.mock.inject(($componentController, $rootScope, _$httpParamSerializer_, _$httpBackend_) => {
$scope = $rootScope.$new();
$httpBackend = _$httpBackend_;
const $element = angular.element('<vn-worker-calencar></vn-worker-calencar>');
$httpParamSerializer = _$httpParamSerializer_;
const $element = angular.element('<vn-worker-calendar></vn-worker-calendar>');
controller = $componentController('vnWorkerCalendar', {$element, $scope});
controller.isSubordinate = true;
controller.absenceType = {id: 1, name: 'Holiday', code: 'holiday', rgb: 'red'};
controller.$params.id = 106;
controller._worker = {id: 106};
}));
describe('started property', () => {
@ -44,8 +50,9 @@ describe('Worker', () => {
describe('worker() setter', () => {
it(`should perform a get query and set the reponse data on the model`, () => {
let today = new Date();
jest.spyOn(controller, 'getIsSubordinate').mockReturnValue(true);
let today = new Date();
let tomorrow = new Date(today.getTime());
tomorrow.setDate(tomorrow.getDate() + 1);
@ -64,7 +71,7 @@ describe('Worker', () => {
]
});
controller.worker = {id: 1};
controller.worker = {id: 107};
$httpBackend.flush();
let events = controller.events;
@ -73,11 +80,14 @@ describe('Worker', () => {
expect(events[tomorrow.getTime()].name).toEqual('Easter');
expect(events[yesterday.getTime()].name).toEqual('Leave');
expect(events[yesterday.getTime()].color).toEqual('#bbb');
expect(controller.getIsSubordinate).toHaveBeenCalledWith();
});
});
describe('formatDay()', () => {
it(`should set the day element style`, () => {
jest.spyOn(controller, 'getIsSubordinate').mockReturnThis();
let today = new Date();
$httpBackend.whenRoute('GET', 'WorkerCalendars/absences')
@ -99,5 +109,219 @@ describe('Worker', () => {
expect(dayNumber.style.backgroundColor).toEqual('rgb(0, 0, 0)');
});
});
describe('pick()', () => {
it(`should set the absenceType property to null if they match with the current one`, () => {
const absenceType = {id: 1, name: 'Holiday'};
controller.absenceType = absenceType;
controller.pick(absenceType);
expect(controller.absenceType).toBeNull();
});
it(`should set the absenceType property`, () => {
const absenceType = {id: 1, name: 'Holiday'};
const expectedAbsence = {id: 2, name: 'Leave of absence'};
controller.absenceType = absenceType;
controller.pick(expectedAbsence);
expect(controller.absenceType).toEqual(expectedAbsence);
});
});
describe('onSelection()', () => {
it(`should show an snackbar message if no absence type is selected`, () => {
jest.spyOn(controller.vnApp, 'showMessage').mockReturnThis();
const $event = {};
const $days = [];
controller.absenceType = null;
controller.onSelection($event, $days);
expect(controller.vnApp.showMessage).toHaveBeenCalledWith('Choose an absence type from the right menu');
});
it(`should call to the create() method`, () => {
jest.spyOn(controller, 'create').mockReturnThis();
const selectedDay = new Date();
const $event = {
target: {
closest: () => {
return {$ctrl: {}};
}
}
};
const $days = [selectedDay];
controller.absenceType = {id: 1};
controller.onSelection($event, $days);
expect(controller.create).toHaveBeenCalledWith(jasmine.any(Object), selectedDay);
});
it(`should call to the delete() method`, () => {
jest.spyOn(controller, 'delete').mockReturnThis();
const selectedDay = new Date();
const expectedEvent = {
dated: selectedDay,
type: 'holiday'
};
const $event = {
target: {
closest: () => {
return {$ctrl: {}};
}
}
};
const $days = [selectedDay];
controller.events[selectedDay.getTime()] = expectedEvent;
controller.absenceType = {id: 1, code: 'holiday'};
controller.onSelection($event, $days);
expect(controller.delete).toHaveBeenCalledWith(jasmine.any(Object), selectedDay, expectedEvent);
});
it(`should call to the edit() method`, () => {
jest.spyOn(controller, 'edit').mockReturnThis();
const selectedDay = new Date();
const expectedEvent = {
dated: selectedDay,
type: 'leaveOfAbsence'
};
const $event = {
target: {
closest: () => {
return {$ctrl: {}};
}
}
};
const $days = [selectedDay];
controller.events[selectedDay.getTime()] = expectedEvent;
controller.absenceType = {id: 1, code: 'holiday'};
controller.onSelection($event, $days);
expect(controller.edit).toHaveBeenCalledWith(jasmine.any(Object), expectedEvent);
});
});
describe('create()', () => {
it(`should make a HTTP POST query and then call to the repaintCanceller() method`, () => {
jest.spyOn(controller, 'repaintCanceller').mockReturnThis();
const dated = new Date();
const calendarElement = {};
const expectedResponse = {id: 10};
$httpBackend.expect('POST', `Workers/106/createAbsence`).respond(200, expectedResponse);
controller.create(calendarElement, dated);
$httpBackend.flush();
const createdEvent = controller.events[dated.getTime()];
const absenceType = controller.absenceType;
expect(createdEvent.absenceId).toEqual(expectedResponse.id);
expect(createdEvent.color).toEqual(absenceType.rgb);
expect(controller.repaintCanceller).toHaveBeenCalled();
});
});
describe('edit()', () => {
it(`should make a HTTP PATCH query and then call to the repaintCanceller() method`, () => {
jest.spyOn(controller, 'repaintCanceller').mockReturnThis();
const event = {absenceId: 10};
const calendarElement = {};
const newAbsenceType = {
id: 2,
name: 'Leave of absence',
code: 'leaveOfAbsence',
rgb: 'purple'
};
controller.absenceType = newAbsenceType;
const expectedParams = {absenceId: 10, absenceTypeId: 2};
$httpBackend.expect('PATCH', `Workers/106/updateAbsence`, expectedParams).respond(200);
controller.edit(calendarElement, event);
$httpBackend.flush();
expect(event.name).toEqual(newAbsenceType.name);
expect(event.color).toEqual(newAbsenceType.rgb);
expect(event.type).toEqual(newAbsenceType.code);
expect(controller.repaintCanceller).toHaveBeenCalled();
});
});
describe('delete()', () => {
it(`should make a HTTP DELETE query and then call to the repaintCanceller() method`, () => {
jest.spyOn(controller, 'repaintCanceller').mockReturnThis();
const expectedParams = {absenceId: 10};
const calendarElement = {};
const selectedDay = new Date();
const expectedEvent = {
dated: selectedDay,
type: 'leaveOfAbsence',
absenceId: 10
};
controller.events[selectedDay.getTime()] = expectedEvent;
const serializedParams = $httpParamSerializer(expectedParams);
$httpBackend.expect('DELETE', `Workers/106/deleteAbsence?${serializedParams}`).respond(200);
controller.delete(calendarElement, selectedDay, expectedEvent);
$httpBackend.flush();
const event = controller.events[selectedDay.getTime()];
expect(event).toBeUndefined();
expect(controller.repaintCanceller).toHaveBeenCalled();
});
});
describe('repaintCanceller()', () => {
it(`should cancell the callback execution timer`, () => {
jest.spyOn(window, 'clearTimeout');
jest.spyOn(window, 'setTimeout');
const timeoutId = 90;
controller.canceller = timeoutId;
controller.repaintCanceller(() => {
return 'My callback';
});
expect(window.clearTimeout).toHaveBeenCalledWith(timeoutId);
expect(window.setTimeout).toHaveBeenCalledWith(jasmine.any(Function), 500);
});
});
describe('refresh()', () => {
it(`should make a HTTP GET query and then call to the onData() method`, () => {
jest.spyOn(controller, 'onData').mockReturnThis();
const dated = controller.date;
const started = new Date(dated.getTime());
started.setMonth(0);
started.setDate(1);
const ended = new Date(dated.getTime());
ended.setMonth(12);
ended.setDate(0);
controller.started = started;
controller.ended = ended;
const expecteResponse = [{id: 1}];
const expectedParams = {workerFk: 106, started: started, ended: ended};
const serializedParams = $httpParamSerializer(expectedParams);
$httpBackend.expect('GET', `WorkerCalendars/absences?${serializedParams}`).respond(200, expecteResponse);
controller.refresh();
$httpBackend.flush();
expect(controller.onData).toHaveBeenCalledWith(expecteResponse);
});
});
});
});

View File

@ -2,4 +2,6 @@ Calendar: Calendario
Holidays: Vacaciones
Used: Utilizados
of: de
days: días
days: días
Choose an absence type from the right menu: Elige un tipo de ausencia desde el menú de la derecha
To start adding absences, click an absence type from the right menu and then on the day you want to add an absence: Para empezar a añadir ausencias, haz clic en un tipo de ausencia desde el menu de la derecha y después en el día que quieres añadir la ausencia

View File

@ -2,6 +2,7 @@
vn-worker-calendar {
.calendars {
position: relative;
display: flex;
flex-wrap: wrap;
justify-content: center;
@ -16,4 +17,23 @@ vn-worker-calendar {
max-width: 288px;
}
}
vn-chip.selectable {
cursor: pointer
}
vn-chip.selectable:hover {
opacity: 0.8
}
vn-chip vn-avatar {
text-align: center;
color: white
}
vn-icon[icon="info"] {
position: absolute;
top: 16px;
right: 16px
}
}