#1852 worker.time-control

This commit is contained in:
Carlos Jimenez Ruiz 2019-11-05 08:59:48 +01:00
parent 4685b4166b
commit 03ab88bcd9
12 changed files with 372 additions and 72 deletions

View File

@ -1,7 +1,7 @@
@import "variables"; @import "variables";
vn-chip { vn-chip {
border-radius: 16px; border-radius: 1em;
background-color: $color-bg; background-color: $color-bg;
margin: 0 0.5em 0.5em 0; margin: 0 0.5em 0.5em 0;
color: $color-font; color: $color-font;
@ -11,7 +11,7 @@ vn-chip {
align-items: center; align-items: center;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
height: 28px; height: 2em;
padding: 0 .7em; padding: 0 .7em;
overflow: hidden; overflow: hidden;
@ -47,7 +47,7 @@ vn-chip {
vn-avatar { vn-avatar {
display: inline-block; display: inline-block;
height: 28px; height: 2em;
width: 28px; width: 2em;
border-radius: 50%; border-radius: 50%;
} }

View File

@ -1,7 +1,7 @@
const UserError = require('vn-loopback/util/user-error'); const UserError = require('vn-loopback/util/user-error');
module.exports = Self => { module.exports = Self => {
Self.remoteMethodCtx('addTime', { Self.remoteMethodCtx('addTimeEntry', {
description: 'Adds a new hour registry', description: 'Adds a new hour registry',
accessType: 'WRITE', accessType: 'WRITE',
accepts: [{ accepts: [{
@ -16,12 +16,12 @@ module.exports = Self => {
root: true root: true
}], }],
http: { http: {
path: `/addTime`, path: `/addTimeEntry`,
verb: 'POST' verb: 'POST'
} }
}); });
Self.addTime = async(ctx, data) => { Self.addTimeEntry = async(ctx, data) => {
const Worker = Self.app.models.Worker; const Worker = Self.app.models.Worker;
const myUserId = ctx.req.accessToken.userId; const myUserId = ctx.req.accessToken.userId;
const myWorker = await Worker.findOne({where: {userFk: myUserId}}); const myWorker = await Worker.findOne({where: {userFk: myUserId}});

View File

@ -0,0 +1,35 @@
module.exports = Self => {
Self.remoteMethodCtx('deleteTimeEntry', {
description: 'Deletes a manual time entry for a worker if the user role is above the worker',
accessType: 'READ',
accepts: [{
arg: 'id',
type: 'number',
required: true,
description: 'The time entry id',
http: {source: 'path'}
}],
returns: {
type: 'boolean',
root: true
},
http: {
path: `/:id/deleteTimeEntry`,
verb: 'POST'
}
});
Self.deleteTimeEntry = async(ctx, id) => {
const workerModel = Self.app.models.Worker;
const targetTimeEntry = await Self.findById(id);
const hasRightsToDelete = await workerModel.isSubordinate(ctx, targetTimeEntry.userFk);
if (!hasRightsToDelete)
throw new UserError(`You don't have enough privileges`);
return Self.rawSql('CALL vn.workerTimeControl_remove(?, ?)', [
targetTimeEntry.userFk, targetTimeEntry.timed]);
};
};

View File

@ -0,0 +1,67 @@
const ParameterizedSQL = require('loopback-connector').ParameterizedSQL;
module.exports = Self => {
Self.remoteMethod('getWorkedHours', {
description: 'returns the total worked hours per day for a given range of dates in format YYYY-mm-dd hh:mm:ss',
accessType: 'READ',
accepts: [{
arg: 'id',
type: 'number',
required: true,
description: 'The worker id',
http: {source: 'path'}
},
{
arg: 'from',
type: 'Date',
required: true,
description: `The from date`
},
{
arg: 'to',
type: 'Date',
required: true,
description: `The to date`
}],
returns: {
type: ['Object'],
root: true
},
http: {
path: `/:id/getWorkedHours`,
verb: 'GET'
}
});
Self.getWorkedHours = async(id, from, to) => {
const conn = Self.dataSource.connector;
const stmts = [];
let worker = await Self.app.models.Worker.findById(id);
let userId = worker.userFk;
stmts.push(`
DROP TEMPORARY TABLE IF EXISTS
tmp.timeControlCalculate,
tmp.timeBusinessCalculate
`);
stmts.push(new ParameterizedSQL('CALL vn.timeControl_calculateByUser(?, ?, ?)', [userId, from, to]));
stmts.push(new ParameterizedSQL('CALL vn.timeBusiness_calculateByUser(?, ?, ?)', [userId, from, to]));
let resultIndex = stmts.push(`
SELECT tbc.dated, tbc.timeWorkSeconds expectedHours, tcc.timeWorkSeconds workedHours
FROM tmp.timeBusinessCalculate tbc
LEFT JOIN tmp.timeControlCalculate tcc ON tcc.dated = tbc.dated
`) - 1;
stmts.push(`
DROP TEMPORARY TABLE IF EXISTS
tmp.timeControlCalculate,
tmp.timeBusinessCalculate
`);
let sql = ParameterizedSQL.join(stmts, ';');
let result = await conn.executeStmt(sql);
return result[resultIndex];
};
};

View File

@ -0,0 +1,50 @@
const ParameterizedSQL = require('loopback-connector').ParameterizedSQL;
module.exports = Self => {
Self.remoteMethod('timeControl', {
description: 'Returns a range of worked hours for a given worker id',
accessType: 'READ',
accepts: [
{
arg: 'id',
type: 'number',
required: true,
description: 'The worker id',
http: {source: 'path'}
},
{
arg: 'dateFrom',
type: 'datetime',
required: true,
description: 'the date from the time control begins in format YYYY-mm-dd-hh:mm:ss'
},
{
arg: 'dateTo',
type: 'datetime',
required: true,
description: 'the date when the time control finishes in format YYYY-mm-dd-hh:mm:ss'
}],
returns: {
type: ['Object'],
root: true
},
http: {
path: `/:id/timeControl`,
verb: 'GET'
}
});
Self.timeControl = async(id, from, to) => {
const conn = Self.dataSource.connector;
const stmts = [];
stmts.push(new ParameterizedSQL('CALL vn.timeControl_calculateByUser(?, ?, ?)', [id, from, to]));
let sql = ParameterizedSQL.join(stmts, ';');
let result = await conn.executeStmt(sql);
return result[0];
};
};

View File

@ -2,7 +2,8 @@ const UserError = require('vn-loopback/util/user-error');
module.exports = Self => { module.exports = Self => {
require('../methods/worker-time-control/filter')(Self); require('../methods/worker-time-control/filter')(Self);
require('../methods/worker-time-control/addTime')(Self); require('../methods/worker-time-control/addTimeEntry')(Self);
require('../methods/worker-time-control/deleteTimeEntry')(Self);
Self.rewriteDbError(function(err) { Self.rewriteDbError(function(err) {
if (err.code === 'ER_DUP_ENTRY') if (err.code === 'ER_DUP_ENTRY')

View File

@ -19,6 +19,9 @@
}, },
"order": { "order": {
"type": "Number" "type": "Number"
},
"direction": {
"type": "string"
} }
}, },
"relations": { "relations": {
@ -26,6 +29,11 @@
"type": "belongsTo", "type": "belongsTo",
"model": "Account", "model": "Account",
"foreignKey": "userFk" "foreignKey": "userFk"
},
"worker": {
"type": "hasOne",
"model": "Worker",
"foreignKey": "userFk"
}, },
"warehouse": { "warehouse": {
"type": "belongsTo", "type": "belongsTo",

View File

@ -3,4 +3,5 @@ module.exports = Self => {
require('../methods/worker/mySubordinates')(Self); require('../methods/worker/mySubordinates')(Self);
require('../methods/worker/isSubordinate')(Self); require('../methods/worker/isSubordinate')(Self);
require('../methods/worker/getWorkerInfo')(Self); require('../methods/worker/getWorkerInfo')(Self);
require('../methods/worker/getWorkedHours')(Self);
}; };

View File

@ -5,28 +5,55 @@
data="$ctrl.hours"> data="$ctrl.hours">
</vn-crud-model> </vn-crud-model>
<div class="main-with-right-menu"> <div class="main-with-right-menu">
<vn-card class="vn-pa-lg vn-w-md"> <vn-card class="vn-pa-lg vn-w-lg">
<vn-table model="model" auto-load="false"> <vn-table model="model" auto-load="false">
<vn-thead> <vn-thead>
<vn-tr> <vn-tr>
<vn-td ng-repeat="weekday in $ctrl.weekDays" center> <vn-td ng-repeat="weekday in $ctrl.weekDays" center>
<div translate>{{::$ctrl.weekdayNames[$index].name}}</div> <div class="weekday" translate>{{::$ctrl.weekdayNames[$index].name}}</div>
<span>{{::weekday.dated | date: 'dd'}}</span> <div>
<span title="{{::weekday.dated | date: 'MMMM' | translate}}" translate> <span>{{::weekday.dated | date: 'dd'}}</span>
{{::weekday.dated | date: 'MMMM'}} <span title="{{::weekday.dated | date: 'MMMM' | translate}}" translate>
</span> {{::weekday.dated | date: 'MMMM'}}
</span>
</div>
<vn-chip
title="{{::weekday.event.name}}"
ng-class="{invisible: !weekday.event}">
<vn-avatar
ng-style="::{backgroundColor: weekday.event.color}">
</vn-avatar>
<div>
{{::weekday.event.name}}
</div>
</vn-chip>
</vn-td> </vn-td>
</vn-tr> </vn-tr>
</vn-thead> </vn-thead>
<vn-tbody> <vn-tbody>
<vn-tr> <vn-tr>
<vn-td ng-repeat="weekday in $ctrl.weekDays" class="hours vn-pa-none" center> <vn-td ng-repeat="weekday in $ctrl.weekDays" class="hours vn-pa-none" expand center>
<section ng-repeat="hour in weekday.hours" center> <section ng-repeat="hour in weekday.hours" center>
<vn-icon <vn-icon
icon="arrow_{{($index % 2) == 0 ? 'forward' : 'back'}}" icon="{{
title="{{(($index % 2) == 0 ? 'In' : 'Out') | translate}}"> ::hour.direction == 'in' ? 'arrow_forward'
: hour.direction == 'out' ? 'arrow_back'
: 'arrow_forward'
}}"
title="{{
::(hour.direction == 'in' ? 'In'
: hour.direction == 'out' ? 'Out'
: '') | translate
}}"
ng-class="::{'invisible': hour.direction == 'middle'}">
</vn-icon> </vn-icon>
<span class="chip {{$ctrl.hourColor(hour)}}">{{hour.timed | date: 'HH:mm'}}</span> <vn-chip
ng-class="::{'colored': hour.manual}"
removable="::hour.manual"
translate-attr="{title: 'Category'}"
on-remove="$ctrl.showDeleteDialog(hour)">
{{::hour.timed | date: 'HH:mm'}}
</vn-chip>
</section> </section>
</vn-td> </vn-td>
</vn-tr> </vn-tr>
@ -34,7 +61,7 @@
<vn-tfoot> <vn-tfoot>
<vn-tr> <vn-tr>
<vn-td ng-repeat="weekday in $ctrl.weekDays" center> <vn-td ng-repeat="weekday in $ctrl.weekDays" center>
{{$ctrl.getWeekdayTotalHours(weekday)}} h. {{$ctrl.formatHours(weekday.workedHours)}} h.
</vn-td> </vn-td>
</vn-tr> </vn-tr>
<vn-tr> <vn-tr>
@ -51,6 +78,12 @@
</vn-card> </vn-card>
<vn-side-menu side="right"> <vn-side-menu side="right">
<div class="vn-pa-md"> <div class="vn-pa-md">
<div style="text-align: center;">
<vn-label-value
label="Finish at"
value="{{$ctrl.getFinishTime()}}">
</vn-label-value>
</div>
<div class="totalBox" style="text-align: center;"> <div class="totalBox" style="text-align: center;">
<h6 translate>Hours</h6> <h6 translate>Hours</h6>
<vn-label-value <vn-label-value
@ -86,4 +119,10 @@
<input type="button" response="CANCEL" translate-attr="{value: 'Cancel'}"/> <input type="button" response="CANCEL" translate-attr="{value: 'Cancel'}"/>
<button response="ACCEPT" translate>Save</button> <button response="ACCEPT" translate>Save</button>
</tpl-buttons> </tpl-buttons>
</vn-dialog> </vn-dialog>
<vn-confirm
vn-id="delete-entry-dialog"
on-response="$ctrl.deleteTimeEntry(response)"
message="This time entry will be deleted"
question="Are you sure you want to delete this entry?">
</vn-confirm>

View File

@ -2,13 +2,15 @@ import ngModule from '../module';
import './style.scss'; import './style.scss';
class Controller { class Controller {
constructor($scope, $http, $stateParams, $element, vnWeekDays) { constructor($scope, $http, $stateParams, $element, vnWeekDays, vnApp, $translate) {
this.$stateParams = $stateParams; this.$stateParams = $stateParams;
this.$ = $scope; this.$ = $scope;
this.$http = $http; this.$http = $http;
this.$element = $element; this.$element = $element;
this.weekDays = []; this.weekDays = [];
this.weekdayNames = vnWeekDays.locales; this.weekdayNames = vnWeekDays.locales;
this.vnApp = vnApp;
this.$translate = $translate;
} }
$postLink() { $postLink() {
@ -34,9 +36,20 @@ class Controller {
this.started = started; this.started = started;
let ended = new Date(started.getTime()); let ended = new Date(started.getTime());
ended.setDate(ended.getDate() + 7); ended.setHours(23, 59, 59, 59);
ended.setDate(ended.getDate() + 6);
this.ended = ended; this.ended = ended;
this.weekDays = [];
let dayIndex = new Date(started.getTime());
while (dayIndex < ended) {
this.weekDays.push({
dated: new Date(dayIndex.getTime())
});
dayIndex.setDate(dayIndex.getDate() + 1);
}
this.fetchHours(); this.fetchHours();
} }
@ -49,23 +62,15 @@ class Controller {
set hours(value) { set hours(value) {
this._hours = value; this._hours = value;
this.weekDays = [];
if (!this.hours) return;
let dayIndex = new Date(this.started.getTime()); for (const weekDay of this.weekDays) {
if (value) {
while (dayIndex < this.ended) { let day = weekDay.dated.getDay();
let weekDay = dayIndex.getDay(); weekDay.hours = value
.filter(hour => new Date(hour.timed).getDay() == day)
let hours = this.hours .sort((a, b) => new Date(a.timed) - new Date(b.timed));
.filter(hour => new Date(hour.timed).getDay() == weekDay) } else
.sort((a, b) => new Date(a.timed) - new Date(b.timed)); weekDay.hours = null;
this.weekDays.push({
dated: new Date(dayIndex.getTime()),
hours
});
dayIndex.setDate(dayIndex.getDate() + 1);
} }
} }
@ -74,61 +79,89 @@ class Controller {
const filter = { const filter = {
where: {and: [ where: {and: [
{timed: {gte: this.started}}, {timed: {gte: this.started}},
{timed: {lt: this.ended}} {timed: {lte: this.ended}}
]} ]}
}; };
this.$.model.applyFilter(filter, params); this.$.model.applyFilter(filter, params);
this.getAbsences();
this.getWorkedHours(this.started, this.ended);
} }
hasEvents(day) { hasEvents(day) {
return day >= this.started && day < this.ended; return day >= this.started && day < this.ended;
} }
hourColor(weekDay) { getWorkedHours(from, to) {
return weekDay.manual ? 'alert' : 'warning'; let params = {
id: this.$stateParams.id,
from: from,
to: to
};
const query = `api/workers/${this.$stateParams.id}/getWorkedHours`;
return this.$http.get(query, {params}).then(res => {
const workDays = res.data;
const map = new Map();
for (const workDay of workDays) {
workDay.dated = new Date(workDay.dated);
map.set(workDay.dated, workDay);
}
for (const weekDay of this.weekDays) {
const workDay = workDays.find(day => {
let from = new Date(day.dated);
from.setHours(0, 0, 0, 0);
let to = new Date(day.dated);
to.setHours(23, 59, 59, 59);
return weekDay.dated >= from && weekDay.dated <= to;
});
weekDay.expectedHours = workDay.expectedHours;
weekDay.workedHours = workDay.workedHours;
}
});
} }
getWeekdayTotalHours(weekday) { getFinishTime() {
if (weekday.hours.length == 0) return 0; let weekOffset = new Date().getDay() - 1;
if (weekOffset < 0) weekOffset = 6;
const today = this.weekDays[weekOffset];
const hours = weekday.hours; if (today && today.workedHours) {
const remainingTime = (today.expectedHours - today.workedHours) * 1000;
const lastKnownTime = new Date(today.hours[today.hours.length - 1].timed).getTime();
const finishTimeStamp = lastKnownTime + remainingTime;
let totalStamp = 0; let finishDate = new Date(finishTimeStamp);
let hour = finishDate.getHours();
let minute = finishDate.getMinutes();
hours.forEach((hour, index) => { if (hour < 10) hour = `0${hour}`;
let currentHour = new Date(hour.timed); if (minute < 10) minute = `0${minute}`;
let previousHour = new Date(hour.timed);
if (index > 0 && (index % 2 == 1)) return `${hour}:${minute} h.`;
previousHour = new Date(hours[index - 1].timed); }
const dif = Math.abs(previousHour - currentHour);
totalStamp += dif;
});
if (totalStamp / 3600 / 1000 > 5)
totalStamp += (20 * 60 * 1000);
weekday.total = totalStamp;
return this.formatHours(totalStamp);
} }
get weekTotalHours() { get weekTotalHours() {
let total = 0; let total = 0;
this.weekDays.forEach(weekday => { this.weekDays.forEach(weekday => {
if (weekday.total) if (weekday.workedHours)
total += weekday.total; total += weekday.workedHours;
}); });
return this.formatHours(total); return this.formatHours(total);
} }
formatHours(timestamp) { formatHours(timestamp) {
let hour = Math.floor(timestamp / 3600 / 1000); timestamp = timestamp || 0;
let min = Math.floor(timestamp / 60 / 1000 - 60 * hour);
let hour = Math.floor(timestamp / 3600);
let min = Math.floor(timestamp / 60 - 60 * hour);
if (hour < 10) hour = `0${hour}`; if (hour < 10) hour = `0${hour}`;
if (min < 10) min = `0${min}`; if (min < 10) min = `0${min}`;
@ -159,12 +192,75 @@ class Controller {
workerFk: this.$stateParams.id, workerFk: this.$stateParams.id,
timed: this.newTime timed: this.newTime
}; };
this.$http.post(`api/WorkerTimeControls/addTime`, data) this.$http.post(`api/WorkerTimeControls/addTimeEntry`, data)
.then(() => this.fetchHours()); .then(() => this.fetchHours());
} }
showDeleteDialog(hour) {
this.timeEntryToDelete = hour;
this.$.deleteEntryDialog.show();
}
deleteTimeEntry(response) {
if (response !== 'ACCEPT') return;
const entryId = this.timeEntryToDelete.id;
this.$http.post(`api/WorkerTimeControls/${entryId}/deleteTimeEntry`).then(() => {
this.fetchHours();
this.vnApp.showSuccess(this.$translate.instant('Entry removed'));
});
}
getAbsences() {
let params = {
workerFk: this.$stateParams.id,
started: this.started,
ended: this.ended
};
return this.$http.get(`api/WorkerCalendars/absences`, {params})
.then(res => this.onData(res.data));
}
onData(data) {
const events = {};
let addEvent = (day, event) => {
events[new Date(day).getTime()] = event;
};
if (data.holidays) {
data.holidays.forEach(holiday => {
const holidayDetail = holiday.detail && holiday.detail.description;
const holidayType = holiday.type && holiday.type.name;
const holidayName = holidayDetail || holidayType;
addEvent(holiday.dated, {
name: holidayName,
color: '#ff0'
});
});
}
if (data.absences) {
data.absences.forEach(absence => {
const type = absence.absenceType;
addEvent(absence.dated, {
name: type.name,
color: type.rgb
});
});
}
this.weekDays.forEach(day => {
const timestamp = day.dated.getTime();
if (events[timestamp])
day.event = events[timestamp];
});
}
} }
Controller.$inject = ['$scope', '$http', '$stateParams', '$element', 'vnWeekDays']; Controller.$inject = ['$scope', '$http', '$stateParams', '$element', 'vnWeekDays', 'vnApp', '$translate'];
ngModule.component('vnWorkerTimeControl', { ngModule.component('vnWorkerTimeControl', {
template: require('./index.html'), template: require('./index.html'),

View File

@ -4,4 +4,7 @@ Hour: Hora
Hours: Horas Hours: Horas
Add time: Añadir hora Add time: Añadir hora
Week total: Total semana Week total: Total semana
Current week: Semana actual Current week: Semana actual
This time entry will be deleted: Se borrará la hora fichada
Are you sure you want to delete this entry?: ¿Seguro que quieres eliminarla?
Finish at: Termina a las

View File

@ -1,7 +1,7 @@
@import "variables"; @import "variables";
vn-worker-time-control { vn-worker-time-control {
vn-thead > vn-tr > vn-td > div { vn-thead > vn-tr > vn-td > div.weekday {
margin-bottom: 5px; margin-bottom: 5px;
color: $color-main color: $color-main
} }