first commit
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<at-modal :value="showModal" :title="$t('control.add_new_task')" @on-cancel="cancel" @on-confirm="confirm">
|
||||
<validation-observer ref="form" v-slot="{}">
|
||||
<validation-provider
|
||||
ref="project"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.project')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.project') }}</small>
|
||||
|
||||
<resource-select
|
||||
v-model="projectId"
|
||||
class="input"
|
||||
:service="projectsService"
|
||||
:class="{ 'at-select--error': errors.length > 0 }"
|
||||
/>
|
||||
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
|
||||
<validation-provider
|
||||
ref="taskName"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.task_name')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.task_name') }}</small>
|
||||
|
||||
<at-input v-model="taskName" class="input" />
|
||||
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
|
||||
<validation-provider
|
||||
ref="taskDescription"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.task_description')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.task_description') }}</small>
|
||||
|
||||
<at-textarea v-model="taskDescription" class="input" />
|
||||
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
</validation-observer>
|
||||
|
||||
<div slot="footer">
|
||||
<at-button @click="cancel">{{ $t('control.cancel') }}</at-button>
|
||||
<at-button type="primary" :disabled="disableButtons" @click="confirm">{{ $t('control.save') }} </at-button>
|
||||
</div>
|
||||
</at-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ResourceSelect from '@/components/ResourceSelect';
|
||||
import ProjectService from '@/services/resource/project.service';
|
||||
import TasksService from '@/services/resource/task.service';
|
||||
import { ValidationObserver, ValidationProvider } from 'vee-validate';
|
||||
|
||||
export default {
|
||||
name: 'AddNewTaskModal',
|
||||
components: {
|
||||
ResourceSelect,
|
||||
ValidationObserver,
|
||||
ValidationProvider,
|
||||
},
|
||||
props: {
|
||||
showModal: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
},
|
||||
disableButtons: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
projectId: '',
|
||||
taskName: '',
|
||||
taskDescription: '',
|
||||
|
||||
projectsService: new ProjectService(),
|
||||
tasksService: new TasksService(),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
cancel() {
|
||||
this.$refs.form.reset();
|
||||
|
||||
this.projectId = '';
|
||||
this.taskName = '';
|
||||
this.taskDescription = '';
|
||||
|
||||
this.$emit('cancel');
|
||||
},
|
||||
|
||||
async confirm() {
|
||||
const valid = await this.$refs.form.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectId, taskName, taskDescription } = this;
|
||||
|
||||
this.projectId = '';
|
||||
this.taskName = '';
|
||||
this.taskDescription = '';
|
||||
|
||||
this.$emit('confirm', { projectId, taskName, taskDescription });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-group {
|
||||
margin-bottom: $layout-01;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-bottom: $spacing-02;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<at-modal :value="showModal" :title="$t('control.edit_intervals')" @on-cancel="cancel" @on-confirm="confirm">
|
||||
<validation-observer ref="form" v-slot="{}">
|
||||
<validation-provider
|
||||
ref="project"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.project')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.project') }}</small>
|
||||
|
||||
<resource-select
|
||||
v-model="projectId"
|
||||
class="input"
|
||||
:service="projectsService"
|
||||
:class="{ 'at-select--error': errors.length > 0 }"
|
||||
/>
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
|
||||
<validation-provider
|
||||
ref="task"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.task')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.task') }}</small>
|
||||
|
||||
<at-select
|
||||
v-if="enableTaskSelect"
|
||||
v-model="taskId"
|
||||
filterable
|
||||
class="input"
|
||||
:placeholder="$t('control.select')"
|
||||
:class="{ 'at-select--error': errors.length > 0 }"
|
||||
>
|
||||
<at-option
|
||||
v-for="option of tasksOptionList"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
>
|
||||
<div class="input__select-wrap">
|
||||
<div class="flex flex-wrap flex-gap">
|
||||
<at-tooltip
|
||||
v-for="(user, userKey) in option.users"
|
||||
:key="userKey"
|
||||
:content="user.full_name"
|
||||
placement="right"
|
||||
class="user-tooltips"
|
||||
>
|
||||
<user-avatar :user="user" />
|
||||
</at-tooltip>
|
||||
</div>
|
||||
<span>{{ option.label }}</span>
|
||||
</div>
|
||||
</at-option>
|
||||
</at-select>
|
||||
<at-input v-else class="input" disabled />
|
||||
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
</validation-observer>
|
||||
|
||||
<div slot="footer">
|
||||
<at-button @click="cancel">{{ $t('control.cancel') }}</at-button>
|
||||
<at-button type="primary" :disabled="disableButtons" @click="confirm">{{ $t('control.save') }} </at-button>
|
||||
</div>
|
||||
</at-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ResourceSelect from '@/components/ResourceSelect';
|
||||
import ProjectService from '@/services/resource/project.service';
|
||||
import TasksService from '@/services/resource/task.service';
|
||||
import { ValidationObserver, ValidationProvider } from 'vee-validate';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
export default {
|
||||
name: 'ChangeTaskModal',
|
||||
components: {
|
||||
ResourceSelect,
|
||||
ValidationObserver,
|
||||
ValidationProvider,
|
||||
UserAvatar,
|
||||
},
|
||||
props: {
|
||||
showModal: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
},
|
||||
disableButtons: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
enableTaskSelect() {
|
||||
return !!(this.projectId && this.tasksOptionList);
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
projectId: '',
|
||||
taskId: '',
|
||||
|
||||
projectsService: new ProjectService(),
|
||||
tasksService: new TasksService(),
|
||||
|
||||
tasksOptionList: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
cancel() {
|
||||
this.$refs.form.reset();
|
||||
|
||||
this.projectId = '';
|
||||
this.taskId = '';
|
||||
|
||||
this.$emit('cancel');
|
||||
},
|
||||
|
||||
async confirm() {
|
||||
const valid = await this.$refs.form.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskId } = this;
|
||||
|
||||
this.projectId = '';
|
||||
this.taskId = '';
|
||||
|
||||
this.$emit('confirm', taskId);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
async projectId(projectId) {
|
||||
try {
|
||||
const taskList = (
|
||||
await this.tasksService.getWithFilters({ where: { project_id: projectId }, with: ['users'] })
|
||||
).data.data;
|
||||
this.tasksOptionList = taskList.map(option => ({
|
||||
value: option.id,
|
||||
label: option['task_name'],
|
||||
users: option.users,
|
||||
}));
|
||||
} catch ({ response }) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(response ? response : 'Request to tasks is canceled');
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (Object.prototype.hasOwnProperty.call(this.$refs, 'project') && this.$refs.project) {
|
||||
this.$refs.project.reset();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(this.$refs, 'task') && this.$refs.project) {
|
||||
this.$refs.task.reset();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-group {
|
||||
margin-bottom: $layout-01;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-bottom: $spacing-02;
|
||||
|
||||
&__select-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flex {
|
||||
max-width: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,557 @@
|
||||
<template>
|
||||
<div class="canvas-wrapper">
|
||||
<div
|
||||
v-show="hoverPopup.show && !clickPopup.show"
|
||||
:style="{
|
||||
left: `${hoverPopup.x - 30}px`,
|
||||
bottom: `${height() - hoverPopup.y + 50}px`,
|
||||
}"
|
||||
class="popup"
|
||||
>
|
||||
<div v-if="hoverPopup.event">
|
||||
{{ hoverPopup.event.task_name }}
|
||||
({{ hoverPopup.event.project_name }})
|
||||
</div>
|
||||
|
||||
<div v-if="hoverPopup.event">
|
||||
{{ formatDuration(hoverPopup.event.duration) }}
|
||||
</div>
|
||||
|
||||
<a :style="{ left: `${hoverPopup.borderX}px` }" class="corner"></a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="clickPopup.show"
|
||||
:style="{
|
||||
left: `${clickPopup.x - 30}px`,
|
||||
bottom: `${height() - clickPopup.y + 50}px`,
|
||||
}"
|
||||
class="popup"
|
||||
>
|
||||
<template v-if="clickPopup.event">
|
||||
<div>
|
||||
<Screenshot
|
||||
:disableModal="true"
|
||||
:lazyImage="false"
|
||||
:project="{ id: clickPopup.event.project_id, name: clickPopup.event.project_name }"
|
||||
:interval="clickPopup.event"
|
||||
:showText="false"
|
||||
:task="{ id: clickPopup.event.task_id, name: clickPopup.event.task_name }"
|
||||
:user="clickPopup.event"
|
||||
@click="showPopup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<router-link :to="`/tasks/view/${clickPopup.event.task_id}`">
|
||||
{{ clickPopup.event.task_name }}
|
||||
</router-link>
|
||||
|
||||
<router-link :to="`/projects/view/${clickPopup.event.project_id}`">
|
||||
({{ clickPopup.event.project_name }})
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a :style="{ left: `${clickPopup.borderX}px` }" class="corner" />
|
||||
</div>
|
||||
|
||||
<ScreenshotModal
|
||||
:project="modal.project"
|
||||
:interval="modal.interval"
|
||||
:show="modal.show"
|
||||
:showNavigation="true"
|
||||
:task="modal.task"
|
||||
:user="modal.user"
|
||||
@close="onHide"
|
||||
@remove="onRemove"
|
||||
@showNext="showNext"
|
||||
@showPrevious="showPrevious"
|
||||
/>
|
||||
<div ref="canvas" class="canvas" @pointerdown="onDown">
|
||||
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
|
||||
<div :style="{ width: `${totalWidth}px` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Screenshot from '@/components/Screenshot';
|
||||
import ScreenshotModal from '@/components/ScreenshotModal';
|
||||
import IntervalService from '@/services/resource/time-interval.service';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import moment from 'moment-timezone';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { SVG } from '@svgdotjs/svg.js';
|
||||
|
||||
let intervalService = new IntervalService();
|
||||
|
||||
const titleHeight = 20;
|
||||
const subtitleHeight = 20;
|
||||
const rowHeight = 65;
|
||||
const columns = 24;
|
||||
const minColumnWidth = 42;
|
||||
const popupWidth = 270;
|
||||
const canvasPadding = 20;
|
||||
const defaultCornerOffset = 15;
|
||||
|
||||
export default {
|
||||
name: 'TeamDayGraph',
|
||||
components: {
|
||||
Screenshot,
|
||||
ScreenshotModal,
|
||||
},
|
||||
props: {
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hoverPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
event: null,
|
||||
borderX: 0,
|
||||
},
|
||||
clickPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
event: null,
|
||||
intervalID: null,
|
||||
borderX: 0,
|
||||
},
|
||||
modal: {
|
||||
show: false,
|
||||
project: null,
|
||||
task: null,
|
||||
user: null,
|
||||
interval: null,
|
||||
},
|
||||
totalWidth: 0,
|
||||
scrollPos: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['intervals', 'timezone']),
|
||||
...mapGetters('user', ['companyData']),
|
||||
},
|
||||
mounted() {
|
||||
this.draw = SVG();
|
||||
|
||||
this.onResize();
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('mousedown', this.onClick);
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('mousedown', this.onClick);
|
||||
window.removeEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
methods: {
|
||||
formatDuration: formatDurationString,
|
||||
showPopup() {
|
||||
this.modal = {
|
||||
show: true,
|
||||
project: { id: this.clickPopup.event.project_id, name: this.clickPopup.event.project_name },
|
||||
user: this.clickPopup.event,
|
||||
task: { id: this.clickPopup.event.task_id, task_name: this.clickPopup.event.task_name },
|
||||
interval: this.clickPopup.event,
|
||||
};
|
||||
},
|
||||
onHide() {
|
||||
this.modal = {
|
||||
...this.modal,
|
||||
show: false,
|
||||
};
|
||||
|
||||
this.$emit('selectedIntervals', null);
|
||||
},
|
||||
onKeyDown(e) {
|
||||
if (!this.modal.show) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
this.showPrevious();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
this.showNext();
|
||||
}
|
||||
},
|
||||
showPrevious() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
const interval = intervals[currentIndex - 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
showNext() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex < intervals.length - 1) {
|
||||
const interval = intervals[currentIndex + 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
height() {
|
||||
return this.users.length * rowHeight;
|
||||
},
|
||||
canvasWidth() {
|
||||
return this.$refs.canvas.clientWidth;
|
||||
},
|
||||
columnWidth() {
|
||||
return Math.max(minColumnWidth, this.canvasWidth() / columns);
|
||||
},
|
||||
async contentWidth() {
|
||||
await this.$nextTick();
|
||||
this.totalWidth = columns * this.columnWidth();
|
||||
return this.totalWidth;
|
||||
},
|
||||
onDown(e) {
|
||||
this.$refs.canvas.addEventListener('pointermove', this.onMove);
|
||||
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
|
||||
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
|
||||
},
|
||||
|
||||
async maxScrollX() {
|
||||
return (await this.contentWidth()) - this.canvasWidth();
|
||||
},
|
||||
async scrollCanvas(movementX, setScroll = true) {
|
||||
const canvas = this.$refs.canvas;
|
||||
const clientWidth = canvas.clientWidth;
|
||||
const entireWidth = await this.contentWidth();
|
||||
const height = this.height();
|
||||
const newScrollPos = this.scrollPos - movementX;
|
||||
if (newScrollPos <= 0) {
|
||||
this.scrollPos = 0;
|
||||
} else if (newScrollPos >= entireWidth - clientWidth) {
|
||||
this.scrollPos = entireWidth - clientWidth;
|
||||
} else {
|
||||
this.scrollPos = newScrollPos;
|
||||
}
|
||||
setScroll ? await this.setScroll() : null;
|
||||
this.draw.viewbox(this.scrollPos, 20, clientWidth, height);
|
||||
},
|
||||
async onMove(e) {
|
||||
this.$refs.canvas.setPointerCapture(e.pointerId);
|
||||
await this.scrollCanvas(e.movementX);
|
||||
},
|
||||
onUp(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onCancel(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onScroll(e) {
|
||||
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
|
||||
},
|
||||
async setScroll(x = null) {
|
||||
await this.$nextTick();
|
||||
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
|
||||
},
|
||||
async drawGrid() {
|
||||
if (typeof this.draw === 'undefined') return;
|
||||
this.draw.clear();
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const height = this.height();
|
||||
const columnWidth = this.columnWidth();
|
||||
const draw = this.draw;
|
||||
draw.addTo(canvasContainer).size(width, height + titleHeight + subtitleHeight);
|
||||
if (height <= 0) {
|
||||
return;
|
||||
}
|
||||
this.draw.viewbox(0, 20, width, height);
|
||||
// Background
|
||||
const rectBackground = draw
|
||||
.rect(await this.contentWidth(), height - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.fill('#FAFAFA')
|
||||
.stroke({ color: '#DFE5ED', width: 1 })
|
||||
.on('mousedown', () => this.$emit('outsideClick'));
|
||||
draw.add(rectBackground);
|
||||
for (let column = 0; column < columns; ++column) {
|
||||
const date = moment().startOf('day').add(column, 'hours');
|
||||
let left = this.columnWidth() * column;
|
||||
// Column headers - hours
|
||||
draw.text(date.format('h'))
|
||||
.move(left + columnWidth / 2, 0)
|
||||
.size(columnWidth, titleHeight)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 15,
|
||||
fill: '#151941',
|
||||
});
|
||||
// Column headers - am/pm
|
||||
draw.text(date.format('A'))
|
||||
.move(left + columnWidth / 2, titleHeight - 5)
|
||||
.size(columnWidth, subtitleHeight)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 10,
|
||||
'font-weight': '600',
|
||||
fill: '#B1B1BE',
|
||||
});
|
||||
|
||||
// // Vertical grid lines
|
||||
if (column > 0) {
|
||||
draw.line(0, titleHeight + subtitleHeight, 0, height + titleHeight + subtitleHeight)
|
||||
.move(left, titleHeight + subtitleHeight)
|
||||
.stroke({ color: '#DFE5ED', width: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const maxLeftOffset = width - popupWidth + 2 * canvasPadding;
|
||||
const clipPath = draw
|
||||
.rect(await this.contentWidth(), height - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.attr({
|
||||
absolutePositioned: true,
|
||||
});
|
||||
const squaresGroup = draw.group().clipWith(clipPath);
|
||||
for (const user of this.users) {
|
||||
const row = this.users.indexOf(user);
|
||||
const top = row * rowHeight + titleHeight + subtitleHeight;
|
||||
|
||||
// Horizontal grid lines
|
||||
if (row > 0) {
|
||||
draw.line(0, 0, await this.contentWidth(), 0)
|
||||
.move(0, top)
|
||||
.stroke({ color: '#DFE5ED', width: 1 });
|
||||
}
|
||||
|
||||
// Intervals
|
||||
if (Object.prototype.hasOwnProperty.call(this.intervals, user.id)) {
|
||||
this.intervals[user.id].forEach(event => {
|
||||
const leftOffset =
|
||||
moment
|
||||
.tz(event.start_at, this.companyData.timezone)
|
||||
.tz(this.timezone)
|
||||
.diff(moment.tz(this.start, this.timezone).startOf('day'), 'hours', true) % 24;
|
||||
const widthIntrevals =
|
||||
((Math.max(event.duration, 60) + 120) * this.columnWidth()) / 60 / 60;
|
||||
const rectInterval = draw
|
||||
.rect(widthIntrevals, rowHeight / 2)
|
||||
.move(Math.floor(leftOffset * this.columnWidth()), top + rowHeight / 4)
|
||||
.radius(2)
|
||||
.stroke({ color: 'transparent', width: 0 })
|
||||
.attr({
|
||||
cursor: 'pointer',
|
||||
hoverCursor: 'pointer',
|
||||
fill: event.is_manual == '1' ? '#c4b52d' : '#2DC48D',
|
||||
});
|
||||
|
||||
rectInterval.on('mouseover', e => {
|
||||
const popupY = rectInterval.bbox().y - rectInterval.bbox().height;
|
||||
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
|
||||
const rectMiddleX = rectInterval.rbox().cx - defaultCornerOffset / 2;
|
||||
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
|
||||
const left =
|
||||
rectMiddleX > canvasRight
|
||||
? canvasRight - defaultCornerOffset / 2
|
||||
: rectMiddleX < minLeft
|
||||
? minLeft - defaultCornerOffset / 2
|
||||
: rectMiddleX;
|
||||
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
|
||||
const popupX = left > maxRight ? maxRight : left < minLeft ? minLeft : left;
|
||||
const arrowX = defaultCornerOffset + left - popupX;
|
||||
this.hoverPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
event,
|
||||
borderX: arrowX,
|
||||
};
|
||||
});
|
||||
rectInterval.on('mouseout', e => {
|
||||
this.hoverPopup = {
|
||||
...this.hoverPopup,
|
||||
show: false,
|
||||
};
|
||||
});
|
||||
rectInterval.on('mousedown', e => {
|
||||
this.$emit('selectedIntervals', event);
|
||||
const popupY = rectInterval.bbox().y - rectInterval.bbox().height;
|
||||
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
|
||||
const rectMiddleX = rectInterval.rbox().cx - defaultCornerOffset / 2;
|
||||
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
|
||||
const left =
|
||||
rectMiddleX > canvasRight
|
||||
? canvasRight - defaultCornerOffset / 2
|
||||
: rectMiddleX < minLeft
|
||||
? minLeft - defaultCornerOffset / 2
|
||||
: rectMiddleX;
|
||||
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
|
||||
const popupX = left > maxRight ? maxRight : left < minLeft ? minLeft : left;
|
||||
const arrowX = defaultCornerOffset + left - popupX;
|
||||
this.clickPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
event,
|
||||
borderX: arrowX,
|
||||
};
|
||||
e.stopPropagation();
|
||||
});
|
||||
squaresGroup.add(rectInterval);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onResize: function () {
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const height = this.height();
|
||||
this.draw.size(width, height);
|
||||
this.setScroll(0);
|
||||
this.drawGrid();
|
||||
},
|
||||
onClick(e) {
|
||||
if (e.button !== 0 || (e.target && e.target.closest('.popup'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clickPopup = {
|
||||
...this.clickPopup,
|
||||
show: false,
|
||||
};
|
||||
},
|
||||
async onRemove() {
|
||||
try {
|
||||
await intervalService.deleteItem(this.modal.interval.id);
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.delete.success.title'),
|
||||
message: this.$t('notification.screenshot.delete.success.message'),
|
||||
});
|
||||
this.onHide();
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.delete.error.title'),
|
||||
message: this.$t('notification.screenshot.delete.error.message'),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
start() {
|
||||
this.setScroll(0);
|
||||
},
|
||||
users() {
|
||||
this.onResize();
|
||||
},
|
||||
intervals() {
|
||||
this.drawGrid();
|
||||
},
|
||||
timezone() {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup {
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
|
||||
display: block;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 270px;
|
||||
z-index: 3;
|
||||
& .corner {
|
||||
border-left: 15px solid transparent;
|
||||
border-right: 15px solid transparent;
|
||||
border-top: 10px solid #ffffff;
|
||||
bottom: -10px;
|
||||
content: ' ';
|
||||
display: block;
|
||||
height: 0;
|
||||
left: 15px;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
.canvas {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
touch-action: pan-y;
|
||||
cursor: move;
|
||||
&::v-deep canvas {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.scrollbar-top {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1.5rem;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.scrollbar-top {
|
||||
& > div {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #2e2ef9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.canvas {
|
||||
.scrollbar-top {
|
||||
top: -1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="team_sidebar">
|
||||
<div class="row team_sidebar__heading">
|
||||
<div class="col-12">
|
||||
<span
|
||||
:class="{ 'team_sidebar__heading-active': this.sort === 'user' }"
|
||||
class="team_sidebar__heading-toggle"
|
||||
@click="selectColumn('user')"
|
||||
>{{ $t('dashboard.user') }}
|
||||
<template v-if="this.sort === 'user'">
|
||||
<i v-if="this.sortDir === 'asc'" class="icon icon-chevron-down"></i>
|
||||
<i v-else class="icon icon-chevron-up"></i>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-12 flex-end">
|
||||
<span
|
||||
:class="{ 'team_sidebar__heading-active': this.sort === 'worked' }"
|
||||
class="team_sidebar__heading-toggle"
|
||||
@click="selectColumn('worked')"
|
||||
>{{ $t('dashboard.worked') }}
|
||||
<template v-if="this.sort === 'worked'">
|
||||
<i v-if="this.sortDir === 'desc'" class="icon icon-chevron-down"></i>
|
||||
<i v-else class="icon icon-chevron-up"></i>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(user, key) in users" :key="key" class="row team_sidebar__user_wrapper">
|
||||
<div class="col-12 row team_sidebar__user_row">
|
||||
<UserAvatar :user="user" />
|
||||
<div class="team_sidebar__user_info col-24">
|
||||
<div class="team_sidebar__user_name">{{ user.full_name }}</div>
|
||||
<div class="team_sidebar__user_task">
|
||||
<router-link
|
||||
v-if="user.last_interval"
|
||||
:to="`/tasks/view/${user.last_interval.task_id}`"
|
||||
:title="user.last_interval.task_name"
|
||||
target="_blank"
|
||||
>
|
||||
{{ user.last_interval.project_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 flex-end team_sidebar__user_worked">
|
||||
{{ formatDurationString(user.worked) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import { mapGetters } from 'vuex';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
export default {
|
||||
name: 'TeamSidebar',
|
||||
components: { UserAvatar },
|
||||
props: {
|
||||
sort: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
sortDir: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['intervals']),
|
||||
},
|
||||
methods: {
|
||||
formatDurationString,
|
||||
selectColumn(column) {
|
||||
this.$emit('sort', column);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.team_sidebar {
|
||||
&__heading {
|
||||
font-weight: 600;
|
||||
color: #b1b1be;
|
||||
padding-right: 9px;
|
||||
|
||||
&-active {
|
||||
color: #59566e;
|
||||
padding-right: 14px;
|
||||
}
|
||||
&-toggle {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -3px;
|
||||
transform: translateY(-46%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__user {
|
||||
&_name {
|
||||
font-size: 10pt;
|
||||
font-weight: 500;
|
||||
color: #151941;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&_row {
|
||||
height: 65px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&_worked {
|
||||
color: #59566e;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&_task {
|
||||
font-size: 9pt;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&_info {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 780px) {
|
||||
.team_sidebar {
|
||||
&__heading {
|
||||
display: grid;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-rows: repeat(2, calc(39px / 2));
|
||||
font-size: 0.8rem;
|
||||
& > div {
|
||||
max-width: 100%;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
&__user_wrapper {
|
||||
height: 65px;
|
||||
display: grid;
|
||||
grid-template-rows: 3fr 1fr;
|
||||
grid-template-columns: 100%;
|
||||
}
|
||||
&__user_task {
|
||||
display: none;
|
||||
}
|
||||
&__user_worked {
|
||||
max-width: 100%;
|
||||
align-self: flex-end;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
&__user_row {
|
||||
max-width: 80%;
|
||||
height: auto;
|
||||
align-self: end;
|
||||
}
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div ref="canvas" class="canvas" @pointerdown="onDown">
|
||||
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
|
||||
<div :style="{ width: `${totalWidth}px` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { SVG } from '@svgdotjs/svg.js';
|
||||
|
||||
const defaultColorConfig = [
|
||||
{
|
||||
start: 0,
|
||||
end: 0.75,
|
||||
color: '#ffb6c2',
|
||||
},
|
||||
{
|
||||
start: 0.76,
|
||||
end: 1,
|
||||
color: '#93ecda',
|
||||
},
|
||||
{
|
||||
start: 1,
|
||||
end: 0,
|
||||
color: '#3cd7b6',
|
||||
isOverTime: true,
|
||||
},
|
||||
];
|
||||
|
||||
const titleHeight = 20;
|
||||
const subtitleHeight = 20;
|
||||
const rowHeight = 65;
|
||||
const minColumnWidth = 85;
|
||||
export default {
|
||||
name: 'TeamTableGraph',
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
timePerDay: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastPosX: 0,
|
||||
offsetX: 0,
|
||||
totalWidth: 0,
|
||||
scrollPos: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', ['companyData']),
|
||||
workingHours() {
|
||||
return 'work_time' in this.companyData && this.companyData.work_time ? this.companyData.work_time : 7;
|
||||
},
|
||||
colorRules() {
|
||||
return this.companyData.color ? this.companyData.color : defaultColorConfig;
|
||||
},
|
||||
columns() {
|
||||
const start = moment(this.start, 'YYYY-MM-DD');
|
||||
const end = moment(this.end, 'YYYY-MM-DD');
|
||||
|
||||
return end.diff(start, 'days') + 1;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.draw = SVG();
|
||||
this.onResize();
|
||||
window.addEventListener('resize', this.onResize);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
},
|
||||
methods: {
|
||||
height() {
|
||||
return this.users.length * rowHeight;
|
||||
},
|
||||
canvasWidth() {
|
||||
return this.$refs.canvas.clientWidth;
|
||||
},
|
||||
columnWidth() {
|
||||
return Math.max(minColumnWidth, this.canvasWidth() / this.columns);
|
||||
},
|
||||
async contentWidth() {
|
||||
await this.$nextTick();
|
||||
this.totalWidth = this.columns * this.columnWidth();
|
||||
return this.totalWidth;
|
||||
},
|
||||
isDateWithinRange(dateString, startDate, endDate) {
|
||||
const date = new Date(dateString);
|
||||
return date >= new Date(startDate) && date <= new Date(endDate);
|
||||
},
|
||||
getColor(progress) {
|
||||
let color = '#3cd7b6';
|
||||
|
||||
this.colorRules.forEach(el => {
|
||||
if ('isOverTime' in el && progress > el.start) {
|
||||
color = el.color;
|
||||
} else if (progress >= el.start && progress <= el.end) {
|
||||
color = el.color;
|
||||
}
|
||||
});
|
||||
|
||||
return color;
|
||||
},
|
||||
onDown(e) {
|
||||
this.$refs.canvas.addEventListener('pointermove', this.onMove);
|
||||
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
|
||||
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
|
||||
},
|
||||
async maxScrollX() {
|
||||
return (await this.contentWidth()) - this.canvasWidth();
|
||||
},
|
||||
async scrollCanvas(movementX, setScroll = true) {
|
||||
const canvas = this.$refs.canvas;
|
||||
const clientWidth = canvas.clientWidth;
|
||||
const entireWidth = await this.contentWidth();
|
||||
const height = this.height();
|
||||
const newScrollPos = this.scrollPos - movementX;
|
||||
if (newScrollPos <= 0) {
|
||||
this.scrollPos = 0;
|
||||
} else if (newScrollPos >= entireWidth - clientWidth) {
|
||||
this.scrollPos = entireWidth - clientWidth;
|
||||
} else {
|
||||
this.scrollPos = newScrollPos;
|
||||
}
|
||||
setScroll ? await this.setScroll() : null;
|
||||
this.draw.viewbox(this.scrollPos, 20, clientWidth, height);
|
||||
},
|
||||
async onMove(e) {
|
||||
this.$refs.canvas.setPointerCapture(e.pointerId);
|
||||
await this.scrollCanvas(e.movementX);
|
||||
},
|
||||
onUp(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onCancel(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onScroll(e) {
|
||||
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
|
||||
},
|
||||
async setScroll(x = null) {
|
||||
await this.$nextTick();
|
||||
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
|
||||
},
|
||||
formatDuration: formatDurationString,
|
||||
drawGrid: async function () {
|
||||
if (typeof this.draw === 'undefined') return;
|
||||
this.draw.clear();
|
||||
const draw = this.draw;
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const columnWidth = this.columnWidth();
|
||||
const height = this.height();
|
||||
if (height <= 0) {
|
||||
return;
|
||||
}
|
||||
const start = moment(this.start, 'YYYY-MM-DD');
|
||||
|
||||
const cursor = (await this.contentWidth()) > this.canvasWidth() ? 'move' : 'default';
|
||||
draw.addTo(canvasContainer).size(width, height + titleHeight + subtitleHeight);
|
||||
draw.viewbox(0, 20, width, height);
|
||||
// Background
|
||||
draw.rect((await this.contentWidth()) - 1, height - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.fill('#fafafa')
|
||||
.stroke({ color: '#dfe5ed', width: 1 })
|
||||
.attr({
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
})
|
||||
.on('mousedown', () => this.$emit('outsideClick'));
|
||||
for (let column = 0; column < this.columns; ++column) {
|
||||
const date = start.clone().locale(this.$i18n.locale).add(column, 'days');
|
||||
let left = this.columnWidth() * column;
|
||||
let halfColumnWidth = this.columnWidth() / 2;
|
||||
// Column headers - day
|
||||
draw.text(date.locale(this.$i18n.locale).format('D'))
|
||||
.move(left + halfColumnWidth, 0)
|
||||
.size(columnWidth, titleHeight)
|
||||
.font({
|
||||
family: 'Nunito, sans-serif',
|
||||
size: 15,
|
||||
fill: '#151941',
|
||||
})
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
|
||||
// Column headers - am/pm
|
||||
draw.text(date.format('dddd').toUpperCase())
|
||||
.move(left + halfColumnWidth, titleHeight - 5)
|
||||
.size(columnWidth, subtitleHeight)
|
||||
.font({
|
||||
family: 'Nunito, sans-serif',
|
||||
size: 10,
|
||||
weight: '600',
|
||||
fill: '#b1b1be',
|
||||
})
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
|
||||
// Vertical grid lines
|
||||
if (column > 0) {
|
||||
draw.line(0, titleHeight + subtitleHeight, 0, height + titleHeight + subtitleHeight)
|
||||
.move(left, titleHeight + subtitleHeight)
|
||||
.stroke({ color: '#DFE5ED', width: 1 })
|
||||
.attr({
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredData = {};
|
||||
for (let key in this.timePerDay) {
|
||||
const innerObject = this.timePerDay[key];
|
||||
const filteredInnerObject = {};
|
||||
for (let dateKey in innerObject) {
|
||||
if (this.isDateWithinRange(dateKey, this.start, this.end)) {
|
||||
filteredInnerObject[dateKey] = innerObject[dateKey];
|
||||
}
|
||||
}
|
||||
filteredData[key] = filteredInnerObject;
|
||||
}
|
||||
const clipPath = draw
|
||||
.rect((await this.contentWidth()) - 1, height - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.attr({
|
||||
absolutePositioned: true,
|
||||
});
|
||||
const squaresGroup = draw.group().clipWith(clipPath);
|
||||
for (const [row, user] of this.users.entries()) {
|
||||
const top = row * rowHeight + titleHeight + subtitleHeight;
|
||||
const userTime = filteredData[user.id];
|
||||
|
||||
if (userTime) {
|
||||
for (const day of Object.keys(userTime)) {
|
||||
const column = -start.diff(day, 'days');
|
||||
const duration = userTime[day];
|
||||
const left = (column * (await this.contentWidth())) / this.columns;
|
||||
const total = 60 * 60 * this.workingHours;
|
||||
const progress = duration / total;
|
||||
const height = Math.ceil(Math.min(progress, 1) * (rowHeight - 1));
|
||||
const color = this.getColor(progress);
|
||||
const rect = draw
|
||||
.rect(this.columnWidth(), height + 1)
|
||||
.move(left, Math.floor(top + (rowHeight - height)) - 1)
|
||||
.fill(color)
|
||||
.stroke({ width: 0 })
|
||||
.attr({
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
squaresGroup.add(rect);
|
||||
|
||||
// Time label
|
||||
draw.text(this.formatDuration(duration))
|
||||
.move(this.columnWidth() / 2 + left, top + 22)
|
||||
.size(this.columnWidth(), rowHeight)
|
||||
.font({
|
||||
family: 'Nunito, sans-serif',
|
||||
size: 15,
|
||||
weight: '600',
|
||||
fill: '#151941',
|
||||
})
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Horizontal grid lines
|
||||
if (row > 0) {
|
||||
draw.line(0, 0, await this.contentWidth(), 0)
|
||||
.move(0, top)
|
||||
.stroke({ color: '#dfe5ed', width: 1 })
|
||||
.attr({
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onResize: function () {
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const height = this.height();
|
||||
this.draw.size(width, height);
|
||||
this.setScroll(0);
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
start() {
|
||||
this.setScroll(0);
|
||||
},
|
||||
end() {
|
||||
this.setScroll(0);
|
||||
},
|
||||
users() {
|
||||
this.onResize();
|
||||
},
|
||||
timePerDay() {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.canvas {
|
||||
user-select: none;
|
||||
touch-action: pan-y;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.scrollbar-top {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1.5rem;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.scrollbar-top {
|
||||
& > div {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #2e2ef9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.canvas {
|
||||
.scrollbar-top {
|
||||
top: -1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div>
|
||||
<transition name="slide-up">
|
||||
<div v-if="intervals.length" class="time-interval-edit-panel">
|
||||
<div class="container-fluid">
|
||||
<div class="row flex-middle flex-between">
|
||||
<div class="time-interval-edit-panel__time col-4">
|
||||
{{ $t('field.selected') }}:
|
||||
<strong>{{ formattedTotalTime }}</strong>
|
||||
</div>
|
||||
<div class="time-interval-edit-panel__buttons col-12 flex flex-end">
|
||||
<at-button
|
||||
:disabled="disabledButtons"
|
||||
class="time-interval-edit-panel__btn"
|
||||
@click="openAddNewTaskModal"
|
||||
>
|
||||
{{ $t('control.add_new_task') }}
|
||||
</at-button>
|
||||
|
||||
<at-button
|
||||
:disabled="disabledButtons"
|
||||
class="time-interval-edit-panel__btn"
|
||||
@click="openChangeTaskModal"
|
||||
>
|
||||
{{ $t('control.edit_intervals') }}
|
||||
</at-button>
|
||||
|
||||
<at-button
|
||||
:disabled="disabledButtons"
|
||||
class="time-interval-edit-panel__btn"
|
||||
type="error"
|
||||
@click="deleteTimeIntervals"
|
||||
>
|
||||
<i class="icon icon-trash" />
|
||||
{{ $t('control.delete') }}
|
||||
</at-button>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<at-button class="time-interval-edit-panel__btn" @click="$emit('close')">
|
||||
{{ $t('control.cancel') }}
|
||||
</at-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="modals">
|
||||
<template v-if="showAddNewTaskModal">
|
||||
<AddNewTaskModal
|
||||
:disableButtons="disabledButtons"
|
||||
:showModal="showAddNewTaskModal"
|
||||
@cancel="onAddNewTaskModalCancel"
|
||||
@confirm="onAddNewTaskModalConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="showChangeTaskModal">
|
||||
<ChangeTaskModal
|
||||
:disableButtons="disabledButtons"
|
||||
:showModal="showChangeTaskModal"
|
||||
@cancel="onChangeTaskModalCancel"
|
||||
@confirm="onChangeTaskModalConfirm"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AddNewTaskModal from './AddNewTaskModal';
|
||||
import ChangeTaskModal from './ChangeTaskModal';
|
||||
import TasksService from '@/services/resource/task.service';
|
||||
import TimeIntervalsService from '@/services/resource/time-interval.service';
|
||||
|
||||
export default {
|
||||
name: 'TimeIntervalEdit',
|
||||
components: {
|
||||
AddNewTaskModal,
|
||||
ChangeTaskModal,
|
||||
},
|
||||
props: {
|
||||
intervals: {
|
||||
type: Array,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', ['user']),
|
||||
showAddNewTaskModal() {
|
||||
return this.modal === 'addNewTask';
|
||||
},
|
||||
showChangeTaskModal() {
|
||||
return this.modal === 'changeTask';
|
||||
},
|
||||
formattedTotalTime() {
|
||||
return moment
|
||||
.utc(this.intervals.reduce((total, curr) => total + curr.duration * 1000, 0))
|
||||
.format('HH:mm:ss');
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tasksService: new TasksService(),
|
||||
timeIntervalsService: new TimeIntervalsService(),
|
||||
modal: '',
|
||||
disabledButtons: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async saveTimeIntervals(data) {
|
||||
try {
|
||||
this.disabledButtons = true;
|
||||
|
||||
await this.timeIntervalsService.bulkEdit(data);
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.save.success.title'),
|
||||
message: this.$t('notification.screenshot.save.success.message'),
|
||||
});
|
||||
|
||||
this.$emit('edit');
|
||||
|
||||
this.modal = '';
|
||||
this.disabledButtons = false;
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.save.error.title'),
|
||||
message: this.$t('notification.screenshot.save.error.message'),
|
||||
});
|
||||
|
||||
this.disabledButtons = false;
|
||||
}
|
||||
},
|
||||
async deleteTimeIntervals() {
|
||||
try {
|
||||
this.disabledButtons = true;
|
||||
|
||||
await this.timeIntervalsService.bulkDelete({
|
||||
intervals: this.intervals.map(el => el.id),
|
||||
});
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.delete.success.title'),
|
||||
message: this.$t('notification.screenshot.delete.success.message'),
|
||||
});
|
||||
|
||||
this.$emit('remove', this.intervals);
|
||||
this.disabledButtons = false;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.delete.error.title'),
|
||||
message: this.$t('notification.screenshot.delete.error.message'),
|
||||
});
|
||||
|
||||
this.disabledButtons = false;
|
||||
}
|
||||
},
|
||||
async createTask(projectId, taskName, taskDescription) {
|
||||
try {
|
||||
this.disabledButtons = true;
|
||||
|
||||
const taskResponse = await this.tasksService.save(
|
||||
{
|
||||
project_id: projectId,
|
||||
task_name: taskName,
|
||||
description: taskDescription,
|
||||
user_id: this.user.id,
|
||||
active: true,
|
||||
priority_id: 2,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const task = taskResponse.data.res;
|
||||
const intervals = this.intervals.map(i => ({
|
||||
id: i.id,
|
||||
task_id: task.id,
|
||||
}));
|
||||
await this.timeIntervalsService.bulkEdit({ intervals });
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.save.success.title'),
|
||||
message: this.$t('notification.screenshot.save.success.message'),
|
||||
});
|
||||
|
||||
this.$emit('edit');
|
||||
|
||||
this.modal = '';
|
||||
this.disabledButtons = false;
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.save.error.title'),
|
||||
message: this.$t('notification.screenshot.save.error.message'),
|
||||
});
|
||||
|
||||
this.disabledButtons = false;
|
||||
}
|
||||
},
|
||||
openAddNewTaskModal() {
|
||||
this.modal = 'addNewTask';
|
||||
},
|
||||
openChangeTaskModal() {
|
||||
this.modal = 'changeTask';
|
||||
},
|
||||
onAddNewTaskModalConfirm({ projectId, taskName, taskDescription }) {
|
||||
this.createTask(projectId, taskName, taskDescription);
|
||||
},
|
||||
onChangeTaskModalConfirm(taskId) {
|
||||
const intervals = this.intervals.map(i => ({ id: i.id, task_id: taskId }));
|
||||
this.saveTimeIntervals({ intervals });
|
||||
},
|
||||
onAddNewTaskModalCancel() {
|
||||
this.modal = '';
|
||||
},
|
||||
onChangeTaskModalCancel() {
|
||||
this.modal = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.time-interval-edit-panel {
|
||||
border-top: 1px solid $gray-4;
|
||||
padding: 15px 0;
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
background-color: #fff;
|
||||
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
|
||||
&__buttons {
|
||||
gap: $layout-01;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 790px) {
|
||||
.time-interval-edit-panel {
|
||||
&__time {
|
||||
flex-basis: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
&__buttons {
|
||||
flex-basis: 100%;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
.modals ::v-deep .at-modal {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
background-color: $gray-4;
|
||||
width: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div ref="canvas" class="canvas"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import { SVG } from '@svgdotjs/svg.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const headerHeight = 20;
|
||||
const columns = 7;
|
||||
const rowHeight = 120;
|
||||
|
||||
export default {
|
||||
name: 'TimelineCalendarGraph',
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
timePerDay: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.draw = SVG();
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.drawGrid();
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatDuration: formatDurationString,
|
||||
drawGrid: debounce(
|
||||
function () {
|
||||
if (typeof this.draw === 'undefined') return;
|
||||
this.draw.clear();
|
||||
|
||||
const startOfMonth = moment(this.start, 'YYYY-MM-DD').startOf('month');
|
||||
const endOfMonth = moment(this.start, 'YYYY-MM-DD').endOf('month');
|
||||
const firstDay = startOfMonth.clone().startOf('isoWeek');
|
||||
const lastDay = endOfMonth.clone().endOf('isoWeek');
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const columnWidth = width / 7;
|
||||
const rows = lastDay.diff(firstDay, 'weeks') + 1;
|
||||
const draw = this.draw;
|
||||
draw.addTo(canvasContainer).size(width, headerHeight + rowHeight * 6);
|
||||
|
||||
draw.rect(width - 2, rows * rowHeight - 1)
|
||||
.move(1, headerHeight)
|
||||
.radius(20)
|
||||
.fill('#FAFAFA')
|
||||
.stroke({ color: '#dfe5ed', width: 1 });
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const date = firstDay.clone().locale(this.$i18n.locale).add(column, 'days');
|
||||
const dateFormat = window.matchMedia('(max-width: 880px)').matches ? 'ddd' : 'dddd';
|
||||
draw.text(date.format(dateFormat).toUpperCase())
|
||||
.move(column * columnWidth + columnWidth / 2, -5)
|
||||
.width(columnWidth)
|
||||
.height(headerHeight)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 10,
|
||||
'font-weight': 600,
|
||||
fill: '#2E2EF9',
|
||||
});
|
||||
}
|
||||
|
||||
this.drawCells(draw, firstDay, columnWidth, rows, width, lastDay);
|
||||
this.drawGridLines(draw, rows, width, columnWidth);
|
||||
},
|
||||
30,
|
||||
{ maxWait: 50 },
|
||||
),
|
||||
drawGridLines(draw, rows, width, columnWidth) {
|
||||
for (let row = 1; row < rows; row++) {
|
||||
draw.line(1, row * rowHeight + headerHeight, width - 1, row * rowHeight + headerHeight).stroke({
|
||||
color: '#DFE5ED',
|
||||
width: 1,
|
||||
});
|
||||
}
|
||||
|
||||
for (let column = 1; column < columns; column++) {
|
||||
draw.line(
|
||||
column * columnWidth,
|
||||
headerHeight,
|
||||
column * columnWidth,
|
||||
headerHeight + rowHeight * rows - 1,
|
||||
).stroke({
|
||||
color: '#DFE5ED',
|
||||
width: 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
drawCells(draw, firstDay, columnWidth, rows, width, lastDay) {
|
||||
const squaresGroup = draw.group();
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const date = firstDay
|
||||
.clone()
|
||||
.locale(this.$i18n.locale)
|
||||
.add(row * columns + column, 'days');
|
||||
const cellLeft = column * columnWidth;
|
||||
const cellTop = headerHeight + row * rowHeight;
|
||||
const isInSelection = date.diff(this.start) >= 0 && date.diff(this.end) <= 0;
|
||||
const { timePerDay } = this;
|
||||
|
||||
if (isInSelection) {
|
||||
const square = draw.rect(columnWidth - 2, rowHeight - 2).attr({
|
||||
fill: '#F4F4FF',
|
||||
x: cellLeft + 1,
|
||||
y: cellTop + 1,
|
||||
});
|
||||
|
||||
squaresGroup.add(square);
|
||||
|
||||
const line = draw
|
||||
.line(
|
||||
column * columnWidth,
|
||||
(row + 1) * rowHeight + headerHeight - 2,
|
||||
(column + 1) * columnWidth,
|
||||
(row + 1) * rowHeight + headerHeight - 2,
|
||||
)
|
||||
.stroke({
|
||||
color: '#2E2EF9',
|
||||
width: 3,
|
||||
});
|
||||
squaresGroup.add(line);
|
||||
}
|
||||
draw.text(date.format('D'))
|
||||
.move((column + 1) * columnWidth - 10, row * rowHeight + headerHeight + 5)
|
||||
.attr({
|
||||
'text-anchor': 'end',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 12,
|
||||
'font-weight': isInSelection ? 600 : 400,
|
||||
fill: isInSelection ? '#2E2EF9' : '#868495',
|
||||
});
|
||||
|
||||
const dateKey = date.format('YYYY-MM-DD');
|
||||
if (timePerDay[dateKey]) {
|
||||
draw.text(this.formatDuration(timePerDay[dateKey]))
|
||||
.move(cellLeft, cellTop + rowHeight - 30)
|
||||
.attr({
|
||||
'text-anchor': 'inherit',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-weight': isInSelection ? 600 : 400,
|
||||
'my-text-type': 'time',
|
||||
fill: '#59566E',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let clip = draw.clip();
|
||||
clip.add(
|
||||
draw
|
||||
.rect(width - 4, (lastDay.diff(firstDay, 'weeks') + 1) * rowHeight - 1.5)
|
||||
.move(2, headerHeight)
|
||||
.radius(20),
|
||||
);
|
||||
squaresGroup.clipWith(clip);
|
||||
},
|
||||
onResize: function () {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
start() {
|
||||
this.onResize();
|
||||
},
|
||||
end() {
|
||||
this.onResize();
|
||||
},
|
||||
timePerDay() {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.canvas ::v-deep svg {
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
text[my-text-type='time'] {
|
||||
font-size: 0.9rem;
|
||||
transform: translateX(13px);
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
text[my-text-type='time'] {
|
||||
font-size: 0.7rem;
|
||||
transform: translateX(7px);
|
||||
}
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
text[my-text-type='time'] {
|
||||
font-size: 0.6rem;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="canvas-wrapper">
|
||||
<div
|
||||
v-show="hoverPopup.show && !clickPopup.show"
|
||||
:style="{
|
||||
left: `${hoverPopup.x - 30}px`,
|
||||
bottom: `${hoverPopup.y}px`,
|
||||
}"
|
||||
class="popup"
|
||||
>
|
||||
<div v-if="hoverPopup.event">
|
||||
{{ hoverPopup.event.task_name }}
|
||||
({{ hoverPopup.event.project_name }})
|
||||
</div>
|
||||
|
||||
<div v-if="hoverPopup.event">
|
||||
{{ formatDuration(hoverPopup.event.duration) }}
|
||||
</div>
|
||||
|
||||
<a :style="{ left: `${hoverPopup.borderX}px` }" class="corner"></a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="clickPopup.show"
|
||||
:data-offset="`${clickPopup.borderX}px`"
|
||||
:style="{
|
||||
left: `${clickPopup.x - 30}px`,
|
||||
bottom: `${clickPopup.y}px`,
|
||||
}"
|
||||
class="popup"
|
||||
>
|
||||
<Screenshot
|
||||
v-if="clickPopup.event && screenshotsEnabled"
|
||||
:disableModal="true"
|
||||
:lazyImage="false"
|
||||
:project="{ id: clickPopup.event.project_id, name: clickPopup.event.project_name }"
|
||||
:interval="clickPopup.event"
|
||||
:showText="false"
|
||||
:task="{ id: clickPopup.event.task_id, name: clickPopup.event.task_name }"
|
||||
:user="clickPopup.event"
|
||||
@click="showPopup"
|
||||
/>
|
||||
|
||||
<div v-if="clickPopup.event">
|
||||
<router-link :to="`/tasks/view/${clickPopup.event.task_id}`">
|
||||
{{ clickPopup.event.task_name }}
|
||||
</router-link>
|
||||
|
||||
<router-link :to="`/projects/view/${clickPopup.event.project_id}`">
|
||||
({{ clickPopup.event.project_name }})
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<a :style="{ left: `${clickPopup.borderX}px` }" class="corner" />
|
||||
</div>
|
||||
|
||||
<ScreenshotModal
|
||||
:project="modal.project"
|
||||
:interval="modal.interval"
|
||||
:show="modal.show"
|
||||
:showNavigation="true"
|
||||
:task="modal.task"
|
||||
:user="modal.user"
|
||||
@close="onHide"
|
||||
@remove="onRemove"
|
||||
@showNext="showNext"
|
||||
@showPrevious="showPrevious"
|
||||
/>
|
||||
<div ref="canvas" class="canvas" @pointerdown="onDown">
|
||||
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
|
||||
<div :style="{ width: `${totalWidth}px` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment-timezone';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import Screenshot from '@/components/Screenshot';
|
||||
import ScreenshotModal from '@/components/ScreenshotModal';
|
||||
import IntervalService from '@/services/resource/time-interval.service';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { SVG } from '@svgdotjs/svg.js';
|
||||
|
||||
const titleHeight = 20;
|
||||
const subtitleHeight = 20;
|
||||
const timelineHeight = 80;
|
||||
const columns = 24;
|
||||
const minColumnWidth = 37;
|
||||
const popupWidth = 270;
|
||||
const canvasPadding = 20;
|
||||
const defaultCornerOffset = 15;
|
||||
|
||||
export default {
|
||||
name: 'TimelineDayGraph',
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
events: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
timezone: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Screenshot,
|
||||
ScreenshotModal,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['tasks', 'intervals']),
|
||||
...mapGetters('user', ['user', 'companyData']),
|
||||
...mapGetters('screenshots', { screenshotsEnabled: 'enabled' }),
|
||||
height() {
|
||||
return timelineHeight + titleHeight + subtitleHeight;
|
||||
},
|
||||
tasks() {
|
||||
if (!this.user) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const userIntervals = this.intervals[this.user.id];
|
||||
if (!userIntervals) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return userIntervals.intervals
|
||||
.map(interval => interval.task)
|
||||
.reduce((obj, task) => ({ ...obj, [task.id]: task }), {});
|
||||
},
|
||||
projects() {
|
||||
return Object.keys(this.tasks)
|
||||
.map(taskID => this.tasks[taskID])
|
||||
.reduce((projects, task) => ({ ...projects, [task.project_id]: task.project }), {});
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hoverPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
event: null,
|
||||
borderX: 0,
|
||||
},
|
||||
clickPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
event: null,
|
||||
borderX: 0,
|
||||
},
|
||||
intervalService: new IntervalService(),
|
||||
modal: {
|
||||
interval: null,
|
||||
project: null,
|
||||
task: null,
|
||||
show: false,
|
||||
},
|
||||
scrollPos: 0,
|
||||
totalWidth: 0,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.draw = SVG();
|
||||
this.onResize();
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('mousedown', this.onClick);
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('mousedown', this.onClick);
|
||||
window.removeEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
methods: {
|
||||
formatDuration: formatDurationString,
|
||||
showPopup() {
|
||||
this.modal = {
|
||||
show: true,
|
||||
project: { id: this.clickPopup.event.project_id, name: this.clickPopup.event.project_name },
|
||||
user: this.clickPopup.event,
|
||||
task: { id: this.clickPopup.event.task_id, name: this.clickPopup.event.task_name },
|
||||
interval: this.clickPopup.event,
|
||||
};
|
||||
},
|
||||
onHide() {
|
||||
this.modal.show = false;
|
||||
},
|
||||
onKeyDown(e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
this.showPrevious();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
this.showNext();
|
||||
}
|
||||
},
|
||||
showPrevious() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
const interval = intervals[currentIndex - 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
showNext() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex < intervals.length - 1) {
|
||||
const interval = intervals[currentIndex + 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
canvasWidth() {
|
||||
return this.$refs.canvas.clientWidth;
|
||||
},
|
||||
columnWidth() {
|
||||
return Math.max(minColumnWidth, this.canvasWidth() / columns);
|
||||
},
|
||||
async contentWidth() {
|
||||
await this.$nextTick();
|
||||
this.totalWidth = columns * this.columnWidth();
|
||||
return this.totalWidth;
|
||||
},
|
||||
onDown(e) {
|
||||
this.$refs.canvas.addEventListener('pointermove', this.onMove);
|
||||
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
|
||||
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
|
||||
},
|
||||
async scrollCanvas(movementX, setScroll = true) {
|
||||
const canvas = this.$refs.canvas;
|
||||
const clientWidth = canvas.clientWidth;
|
||||
const entireWidth = await this.contentWidth();
|
||||
const height = this.height;
|
||||
const newScrollPos = this.scrollPos - movementX;
|
||||
if (newScrollPos <= 0) {
|
||||
this.scrollPos = 0;
|
||||
} else if (newScrollPos >= entireWidth - clientWidth) {
|
||||
this.scrollPos = entireWidth - clientWidth;
|
||||
} else {
|
||||
this.scrollPos = newScrollPos;
|
||||
}
|
||||
setScroll ? await this.setScroll() : null;
|
||||
this.draw.viewbox(this.scrollPos, 0, clientWidth, height);
|
||||
},
|
||||
async onMove(e) {
|
||||
this.$refs.canvas.setPointerCapture(e.pointerId);
|
||||
await this.scrollCanvas(e.movementX);
|
||||
},
|
||||
onUp(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onCancel(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onScroll(e) {
|
||||
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
|
||||
},
|
||||
async setScroll(x = null) {
|
||||
await this.$nextTick();
|
||||
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
|
||||
},
|
||||
drawGrid: async function () {
|
||||
if (typeof this.draw === 'undefined') return;
|
||||
this.draw.clear();
|
||||
const draw = this.draw;
|
||||
// const width = draw.width();
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const columnWidth = this.columnWidth();
|
||||
draw.addTo(canvasContainer).size(width, this.height);
|
||||
this.draw.viewbox(0, 0, width, this.height);
|
||||
// Background
|
||||
this.draw
|
||||
.rect(await this.contentWidth(), timelineHeight - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.fill('#fafafa')
|
||||
.stroke({ color: '#dfe5ed', width: 1 })
|
||||
.on('mousedown', () => this.$emit('outsideClick'));
|
||||
const maxLeftOffset = width - popupWidth + 2 * canvasPadding;
|
||||
const minLeftOffset = canvasPadding / 2;
|
||||
const clipPath = draw
|
||||
.rect(await this.contentWidth(), timelineHeight - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.attr({
|
||||
absolutePositioned: true,
|
||||
});
|
||||
const squaresGroup = draw.group().clipWith(clipPath);
|
||||
for (let i = 0; i < columns; ++i) {
|
||||
const date = moment().startOf('day').add(i, 'hours');
|
||||
const left = columnWidth * i;
|
||||
|
||||
// Column header - hour
|
||||
draw.text(date.format('h'))
|
||||
.move(left + columnWidth / 2, 0)
|
||||
.addClass('text-center')
|
||||
.width(columnWidth)
|
||||
.height(titleHeight)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 15,
|
||||
fill: '#151941',
|
||||
});
|
||||
|
||||
// Column header - am/pm
|
||||
draw.text(function (add) {
|
||||
add.tspan(date.format('A')).newLine();
|
||||
})
|
||||
.move(left + columnWidth / 2, titleHeight - 5)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 10,
|
||||
'font-weight': 600,
|
||||
fill: '#b1b1be',
|
||||
});
|
||||
|
||||
// Vertical grid line
|
||||
if (i > 0) {
|
||||
const line = draw
|
||||
.line(0, 0, 0, timelineHeight)
|
||||
.move(left, titleHeight + subtitleHeight)
|
||||
.stroke({
|
||||
color: '#dfe5ed',
|
||||
width: 1,
|
||||
});
|
||||
squaresGroup.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Intervals
|
||||
this.events.forEach(event => {
|
||||
const leftOffset =
|
||||
moment
|
||||
.tz(event.start_at, this.companyData.timezone)
|
||||
.tz(this.timezone)
|
||||
.diff(moment.tz(this.start, this.timezone).startOf('day'), 'hours', true) % 24;
|
||||
|
||||
const width = ((Math.max(event.duration, 60) + 120) * columnWidth) / 60 / 60;
|
||||
|
||||
const rectInterval = draw
|
||||
.rect(width, 30)
|
||||
.move(Math.floor(leftOffset * columnWidth), titleHeight + subtitleHeight + 22)
|
||||
.radius(3)
|
||||
.attr({
|
||||
'text-anchor': 'inherit',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 15,
|
||||
'font-weight': 600,
|
||||
fill: event.is_manual == '1' ? '#c4b52d' : '#2dc48d',
|
||||
})
|
||||
.stroke({
|
||||
color: 'transparent',
|
||||
width: 0,
|
||||
})
|
||||
.css({
|
||||
cursor: 'pointer',
|
||||
'pointer-events': 'auto',
|
||||
})
|
||||
.width(width)
|
||||
.height(30);
|
||||
|
||||
rectInterval.on('mouseover', e => {
|
||||
const popupY =
|
||||
document.body.getBoundingClientRect().height - rectInterval.rbox().y + defaultCornerOffset;
|
||||
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
|
||||
const rectMiddleX = rectInterval.rbox().cx;
|
||||
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
|
||||
const left =
|
||||
rectMiddleX > canvasRight
|
||||
? canvasRight - defaultCornerOffset / 2
|
||||
: rectMiddleX < minLeft
|
||||
? minLeft
|
||||
: rectMiddleX;
|
||||
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
|
||||
const popupX =
|
||||
left > maxRight ? maxRight : left <= minLeft ? minLeft + defaultCornerOffset : left;
|
||||
const arrowX = defaultCornerOffset + left - popupX;
|
||||
this.hoverPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
event,
|
||||
borderX: arrowX,
|
||||
};
|
||||
});
|
||||
|
||||
rectInterval.on('mouseout', e => {
|
||||
this.hoverPopup = {
|
||||
...this.hoverPopup,
|
||||
show: false,
|
||||
};
|
||||
});
|
||||
|
||||
rectInterval.on('mousedown', e => {
|
||||
this.$emit('selectedIntervals', event);
|
||||
const { left: canvasLeft, right: canvasRight } = this.$refs.canvas.getBoundingClientRect();
|
||||
const rectBox = rectInterval.rbox();
|
||||
const popupY =
|
||||
document.body.getBoundingClientRect().height - rectInterval.rbox().y + defaultCornerOffset;
|
||||
const rectMiddleX = rectBox.cx;
|
||||
|
||||
// Determine initial left position within canvas bounds
|
||||
const left =
|
||||
rectMiddleX > canvasRight
|
||||
? canvasRight - defaultCornerOffset / 2
|
||||
: Math.max(rectMiddleX, canvasLeft);
|
||||
|
||||
// Calculate maximum allowed position for popup's left
|
||||
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
|
||||
const popupX = left > maxRight ? maxRight : Math.max(left, canvasLeft + defaultCornerOffset);
|
||||
|
||||
// Calculate the position for the arrow in the popup
|
||||
const arrowX = defaultCornerOffset + left - popupX;
|
||||
|
||||
this.clickPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
event,
|
||||
borderX: arrowX,
|
||||
};
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
draw.add(rectInterval);
|
||||
});
|
||||
},
|
||||
onClick(e) {
|
||||
if (
|
||||
(e.target &&
|
||||
e.target.parentElement &&
|
||||
!e.target.parentElement.classList.contains(this.draw.node.classList) &&
|
||||
!e.target.closest('.time-interval-edit-panel') &&
|
||||
!e.target.closest('.screenshot') &&
|
||||
!e.target.closest('.modal') &&
|
||||
!e.target.closest('.at-modal') &&
|
||||
!e.target.closest('.popup')) ||
|
||||
(e.target.closest('.time-interval-edit-panel') &&
|
||||
e.target.closest('.time-interval-edit-panel__btn') &&
|
||||
e.target.closest('.at-btn--error')) ||
|
||||
(e.target.closest('.modal') && e.target.closest('.modal-remove'))
|
||||
) {
|
||||
if (this.clickPopup.show) {
|
||||
this.clickPopup.show = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
onResize: function () {
|
||||
this.drawGrid();
|
||||
},
|
||||
async onRemove() {
|
||||
try {
|
||||
await this.intervalService.deleteItem(this.modal.interval.id);
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.delete.success.title'),
|
||||
message: this.$t('notification.screenshot.delete.success.message'),
|
||||
});
|
||||
this.onHide();
|
||||
this.$emit('selectedIntervals', null);
|
||||
this.$emit('remove', [this.modal.interval]);
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.delete.error.title'),
|
||||
message: this.$t('notification.screenshot.delete.error.message'),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
events() {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup {
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
|
||||
display: block;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 270px;
|
||||
z-index: 3;
|
||||
& .corner {
|
||||
border-left: 15px solid transparent;
|
||||
border-right: 15px solid transparent;
|
||||
border-top: 10px solid #ffffff;
|
||||
bottom: -10px;
|
||||
content: ' ';
|
||||
display: block;
|
||||
height: 0;
|
||||
left: 15px;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
.canvas {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
touch-action: pan-y;
|
||||
cursor: move;
|
||||
&::v-deep canvas {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
}
|
||||
.scrollbar-top {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1rem;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.scrollbar-top {
|
||||
& > div {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #2e2ef9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1110px) {
|
||||
.scrollbar-top {
|
||||
top: -0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="screenshots">
|
||||
<h3 class="screenshots__title">{{ $t('field.screenshots') }}</h3>
|
||||
<at-checkbox-group v-model="selectedIntervals">
|
||||
<div class="row">
|
||||
<div
|
||||
v-for="(interval, index) in intervals[this.user.id]"
|
||||
:key="interval.id"
|
||||
class="col-xs-8 col-4 col-xl-3 screenshots__item"
|
||||
>
|
||||
<div class="screenshot" :index="index" @click.shift.prevent.stop="onShiftClick(index)">
|
||||
<Screenshot
|
||||
:disableModal="true"
|
||||
:project="{ id: interval.project_id, name: interval.project_name }"
|
||||
:interval="interval"
|
||||
:task="interval.task"
|
||||
:user="user"
|
||||
:timezone="timezone"
|
||||
@click="showPopup(interval, $event)"
|
||||
/>
|
||||
<div @click="onCheckboxClick(index)">
|
||||
<at-checkbox class="screenshot__checkbox" :label="interval.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScreenshotModal
|
||||
:project="modal.project"
|
||||
:interval="modal.interval"
|
||||
:show="modal.show"
|
||||
:showNavigation="true"
|
||||
:task="modal.task"
|
||||
:user="modal.user"
|
||||
@close="onHide"
|
||||
@remove="onRemove"
|
||||
@showNext="showNext"
|
||||
@showPrevious="showPrevious"
|
||||
/>
|
||||
</div>
|
||||
</at-checkbox-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Screenshot from '@/components/Screenshot';
|
||||
import ScreenshotModal from '@/components/ScreenshotModal';
|
||||
import TimeIntervalService from '@/services/resource/time-interval.service';
|
||||
|
||||
export default {
|
||||
name: 'TimelineScreenshots',
|
||||
components: {
|
||||
Screenshot,
|
||||
ScreenshotModal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
intervalsService: new TimeIntervalService(),
|
||||
selectedIntervals: [],
|
||||
modal: {
|
||||
interval: null,
|
||||
project: null,
|
||||
task: null,
|
||||
show: false,
|
||||
user: null,
|
||||
},
|
||||
firstSelectedCheckboxIndex: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['tasks', 'intervals', 'timezone']),
|
||||
...mapGetters('user', ['user']),
|
||||
projects() {
|
||||
return Object.keys(this.tasks)
|
||||
.map(taskID => this.tasks[taskID])
|
||||
.reduce((projects, task) => ({ ...projects, [task.project_id]: task.project }), {});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
methods: {
|
||||
onShiftClick(index) {
|
||||
if (this.firstSelectedCheckboxIndex === null) {
|
||||
this.firstSelectedCheckboxIndex = index;
|
||||
}
|
||||
|
||||
this.selectedIntervals = this.intervals[this.user.id]
|
||||
.slice(
|
||||
Math.min(index, this.firstSelectedCheckboxIndex),
|
||||
Math.max(index, this.firstSelectedCheckboxIndex) + 1,
|
||||
)
|
||||
.map(i => i.id);
|
||||
},
|
||||
onCheckboxClick(index) {
|
||||
if (this.firstSelectedCheckboxIndex === null) {
|
||||
this.firstSelectedCheckboxIndex = index;
|
||||
}
|
||||
},
|
||||
onKeyDown(e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
this.showPrevious();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
this.showNext();
|
||||
}
|
||||
},
|
||||
showPopup(interval, e) {
|
||||
if (e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof interval !== 'object' || interval.id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal = {
|
||||
show: true,
|
||||
project: { id: interval.project_id, name: interval.project_name },
|
||||
user: interval,
|
||||
task: { id: interval.task_id, task_name: interval.task_name },
|
||||
interval,
|
||||
};
|
||||
},
|
||||
onHide() {
|
||||
this.modal.show = false;
|
||||
},
|
||||
showPrevious() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
const interval = intervals[currentIndex - 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
showNext() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex < intervals.length - 1) {
|
||||
const interval = intervals[currentIndex + 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
async onRemove(intervalID) {
|
||||
try {
|
||||
await this.intervalsService.deleteItem(intervalID);
|
||||
|
||||
this.$emit('on-remove', [this.modal.interval]);
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.delete.success.title'),
|
||||
message: this.$t('notification.screenshot.delete.success.message'),
|
||||
});
|
||||
|
||||
this.modal.show = false;
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.delete.error.title'),
|
||||
message: this.$t('notification.screenshot.delete.error.message'),
|
||||
});
|
||||
}
|
||||
},
|
||||
clearSelectedIntervals() {
|
||||
this.selectedIntervals = [];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedIntervals(intervalIds) {
|
||||
if (intervalIds.length === 0) {
|
||||
this.firstSelectedCheckboxIndex = null;
|
||||
}
|
||||
|
||||
this.$emit(
|
||||
'onSelectedIntervals',
|
||||
this.intervals[this.user.id].filter(i => intervalIds.indexOf(i.id) !== -1),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.screenshots {
|
||||
&__title {
|
||||
color: #b1b1be;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 37px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
margin-bottom: $layout-01;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
position: relative;
|
||||
margin-bottom: $layout-01;
|
||||
|
||||
&__checkbox {
|
||||
left: -5px;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&::v-deep {
|
||||
.screenshot__image {
|
||||
img {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="total-time">
|
||||
<h5>{{ $t('dashboard.total_time') }}:</h5>
|
||||
<h5>
|
||||
<Skeleton :loading="isDataLoading" width="50px">{{ totalTime }} </Skeleton>
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div v-for="project in userProjects" :key="project.id" class="project">
|
||||
<div class="project__header">
|
||||
<Skeleton :loading="isDataLoading" width="100%" height="15px">
|
||||
<div class="project__title">
|
||||
<span class="project__name" :title="project.name">
|
||||
<router-link class="task__title-link" :to="`/projects/view/${project.id}`">
|
||||
{{ project.name }}
|
||||
</router-link>
|
||||
</span>
|
||||
<span class="project__duration">
|
||||
{{ formatDurationString(project.durationAtSelectedPeriod) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- /.project-title -->
|
||||
</Skeleton>
|
||||
</div>
|
||||
<!-- /.project-header -->
|
||||
|
||||
<ul class="task-list">
|
||||
<li
|
||||
v-for="task in getVisibleTasks(project.id)"
|
||||
:key="task.id"
|
||||
class="task"
|
||||
:class="{ 'task-active': activeTask === task.id }"
|
||||
>
|
||||
<Skeleton :loading="isDataLoading" width="100%" height="15px">
|
||||
<h3 class="task__title" :title="task.name">
|
||||
<router-link class="task__title-link" :to="`/tasks/view/${task.id}`">
|
||||
{{ task.name }}
|
||||
</router-link>
|
||||
</h3>
|
||||
|
||||
<div class="task__progress">
|
||||
<at-progress
|
||||
class="task__progressbar"
|
||||
status="success"
|
||||
:stroke-width="5"
|
||||
:percent="getPercentForTaskInProject(task, project)"
|
||||
></at-progress>
|
||||
|
||||
<span class="task__duration">
|
||||
{{ formatDurationString(task.durationAtSelectedPeriod) }}
|
||||
</span>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="getAllTasks(project.id).length > 3">
|
||||
<at-button
|
||||
v-if="!isExpanded(project.id)"
|
||||
class="project__expand"
|
||||
type="text"
|
||||
@click.prevent="expand(project.id)"
|
||||
>
|
||||
{{ $t('projects.show-more') }}
|
||||
</at-button>
|
||||
|
||||
<at-button v-else class="project__shrink" type="text" @click.prevent="shrink(project.id)">
|
||||
{{ $t('projects.show-less') }}
|
||||
</at-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import { Skeleton } from 'vue-loading-skeleton';
|
||||
|
||||
export default {
|
||||
name: 'TimelineSidebar',
|
||||
components: {
|
||||
Skeleton,
|
||||
},
|
||||
props: {
|
||||
activeTask: {
|
||||
type: Number,
|
||||
},
|
||||
isDataLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
startDate: {
|
||||
type: String,
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expandedProjects: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['timePerProject']),
|
||||
...mapGetters('user', ['user']),
|
||||
userProjects() {
|
||||
if (!this.user || !this.user.id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.timePerProject[this.user.id]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.values(this.timePerProject[this.user.id]);
|
||||
},
|
||||
totalTime() {
|
||||
const sum = (totalTime, project) => (totalTime += project.durationAtSelectedPeriod);
|
||||
return formatDurationString(this.userProjects.reduce(sum, 0));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isExpanded(projectID) {
|
||||
return this.expandedProjects.indexOf(+projectID) !== -1;
|
||||
},
|
||||
expand(projectID) {
|
||||
this.expandedProjects.push(+projectID);
|
||||
},
|
||||
shrink(projectID) {
|
||||
this.expandedProjects = this.expandedProjects.filter(proj => +proj !== +projectID);
|
||||
},
|
||||
getAllTasks(projectID) {
|
||||
return Object.values(this.timePerProject[this.user.id][projectID].tasks);
|
||||
},
|
||||
getVisibleTasks(projectID) {
|
||||
const tasks = this.getAllTasks(projectID);
|
||||
return this.isExpanded(projectID) ? tasks : tasks.slice(0, 3);
|
||||
},
|
||||
getPercentForTaskInProject(task, project) {
|
||||
return (100 * task.durationAtSelectedPeriod) / project.durationAtSelectedPeriod;
|
||||
},
|
||||
formatDurationString,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.total-time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
padding: 0 20px;
|
||||
margin-bottom: $spacing-05;
|
||||
}
|
||||
|
||||
.project {
|
||||
&__header {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
color: #151941;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
float: right;
|
||||
margin-left: 0.5em;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&__expand,
|
||||
&__shrink {
|
||||
display: block;
|
||||
color: #b1b1be;
|
||||
padding: 0;
|
||||
margin: 5px 0 0 20px;
|
||||
|
||||
&::v-deep .at-btn__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.task-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.task {
|
||||
color: #b1b1be;
|
||||
padding: 5px 20px;
|
||||
|
||||
&::v-deep {
|
||||
.at-progress-bar {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.at-progress-bar__wraper {
|
||||
background: #e0dfed;
|
||||
}
|
||||
|
||||
.at-progress--success .at-progress-bar__inner {
|
||||
background: #2dc38d;
|
||||
}
|
||||
|
||||
.at-progress__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__title-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&__active {
|
||||
background: #f4f4ff;
|
||||
color: #151941;
|
||||
border-left: 3px solid #2e2ef9;
|
||||
|
||||
&::v-deep {
|
||||
.at-progress-bar__wraper {
|
||||
background: #b1b1be;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__progress {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__progressbar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
margin-left: 1em;
|
||||
color: #59566e;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
resources/frontend/core/modules/Dashboard/locales/en.json
Normal file
24
resources/frontend/core/modules/Dashboard/locales/en.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard"
|
||||
},
|
||||
"dashboard": {
|
||||
"timeline": "Timeline",
|
||||
"team": "Team",
|
||||
"user": "User",
|
||||
"worked": "Worked",
|
||||
"total_time": "Total time"
|
||||
},
|
||||
"field": {
|
||||
"task_name": "Name",
|
||||
"task_description": "Description"
|
||||
},
|
||||
"control": {
|
||||
"add_new_task": "Add new task",
|
||||
"edit_intervals": "Change task"
|
||||
},
|
||||
"projects": {
|
||||
"show-more": "Show more",
|
||||
"show-less": "Show less"
|
||||
}
|
||||
}
|
||||
24
resources/frontend/core/modules/Dashboard/locales/ru.json
Normal file
24
resources/frontend/core/modules/Dashboard/locales/ru.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"navigation": {
|
||||
"dashboard": "Обзор"
|
||||
},
|
||||
"dashboard": {
|
||||
"timeline": "Личный",
|
||||
"team": "Командный",
|
||||
"user": "Пользователь",
|
||||
"worked": "Отработано",
|
||||
"total_time": "Общее время"
|
||||
},
|
||||
"field": {
|
||||
"task_name": "Название",
|
||||
"task_description": "Описание"
|
||||
},
|
||||
"control": {
|
||||
"add_new_task": "Добавить новую задачу",
|
||||
"edit_intervals": "Изменить задачу"
|
||||
},
|
||||
"projects": {
|
||||
"show-more": "Показать ещё",
|
||||
"show-less": "Показать меньше"
|
||||
}
|
||||
}
|
||||
70
resources/frontend/core/modules/Dashboard/module.init.js
Normal file
70
resources/frontend/core/modules/Dashboard/module.init.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import Vue from 'vue';
|
||||
import storeModule from './storeModule';
|
||||
import './policies';
|
||||
|
||||
export const ModuleConfig = {
|
||||
routerPrefix: 'dashboard',
|
||||
loadOrder: 20,
|
||||
moduleName: 'Dashboard',
|
||||
};
|
||||
|
||||
export function init(context) {
|
||||
context.addRoute({
|
||||
path: '/dashboard',
|
||||
alias: '/',
|
||||
name: 'dashboard',
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard.vue'),
|
||||
meta: {
|
||||
auth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (
|
||||
Vue.prototype.$can('viewTeamTab', 'dashboard') &&
|
||||
(!localStorage.getItem('dashboard.tab') || localStorage.getItem('dashboard.tab') === 'team')
|
||||
) {
|
||||
return next({ name: 'dashboard.team' });
|
||||
}
|
||||
|
||||
return next({ name: 'dashboard.timeline' });
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'timeline',
|
||||
alias: '/timeline',
|
||||
name: 'dashboard.timeline',
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard/Timeline.vue'),
|
||||
meta: {
|
||||
auth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'team',
|
||||
alias: '/team',
|
||||
name: 'dashboard.team',
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard/Team.vue'),
|
||||
meta: {
|
||||
checkPermission: () => Vue.prototype.$can('viewTeamTab', 'dashboard'),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
context.addNavbarEntry({
|
||||
label: 'navigation.dashboard',
|
||||
to: {
|
||||
path: '/dashboard',
|
||||
},
|
||||
});
|
||||
|
||||
context.addLocalizationData({
|
||||
en: require('./locales/en'),
|
||||
ru: require('./locales/ru'),
|
||||
});
|
||||
|
||||
context.registerVuexModule(storeModule);
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { hasRole } from '@/utils/user';
|
||||
|
||||
export default class DashboardPolicy {
|
||||
static viewTeamTab(user) {
|
||||
return user.can_view_team_tab;
|
||||
}
|
||||
|
||||
static viewManualTime(user) {
|
||||
return hasRole(user, 'admin') || !!user.manual_time;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { store } from '@/store';
|
||||
import DashboardPolicy from './dashboard.policy';
|
||||
|
||||
store.dispatch('policies/registerPolicies', {
|
||||
dashboard: DashboardPolicy,
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import axios from 'axios';
|
||||
import ReportService from '@/services/report.service';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class DashboardService extends ReportService {
|
||||
constructor(context, taskService, userService) {
|
||||
super();
|
||||
this.context = context;
|
||||
|
||||
this.taskService = taskService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
downloadReport(startAt, endAt, users, projects, userTimezone, format, sortCol, sortDir) {
|
||||
return axios.post(
|
||||
'report/dashboard/download',
|
||||
{
|
||||
start_at: startAt,
|
||||
end_at: endAt,
|
||||
users,
|
||||
projects,
|
||||
user_timezone: userTimezone,
|
||||
sort_column: sortCol,
|
||||
sort_direction: sortDir,
|
||||
},
|
||||
{
|
||||
headers: { Accept: format },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getReport(startAt, endAt, users, projects, userTimezone) {
|
||||
return axios.post('report/dashboard', {
|
||||
users,
|
||||
projects,
|
||||
start_at: startAt,
|
||||
end_at: endAt,
|
||||
user_timezone: userTimezone,
|
||||
});
|
||||
}
|
||||
|
||||
unloadIntervals() {
|
||||
this.context.commit('setIntervals', []);
|
||||
}
|
||||
|
||||
load(userIDs, projectIDs, startAt, endAt, userTimezone) {
|
||||
this.getReport(startAt, endAt, userIDs, projectIDs, userTimezone)
|
||||
.then(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data.data;
|
||||
|
||||
this.context.commit('setIntervals', data);
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueProjectIDs = new Set();
|
||||
const uniqueTaskIDs = new Set();
|
||||
Object.keys(data).forEach(userID => {
|
||||
const userIntervals = data[userID];
|
||||
userIntervals.forEach(interval => {
|
||||
uniqueProjectIDs.add(interval.project_id);
|
||||
uniqueTaskIDs.add(interval.task_id);
|
||||
});
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
|
||||
const taskIDs = [...uniqueTaskIDs];
|
||||
if (taskIDs.length) {
|
||||
promises.push(this.loadTasks(taskIDs));
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then(() => {
|
||||
return this.loadUsers();
|
||||
})
|
||||
.then(() =>
|
||||
this.context.commit(
|
||||
'setUsers',
|
||||
this.context.state.users.map(u => {
|
||||
if (Object.prototype.hasOwnProperty.call(this.context.state.intervals, u.id)) {
|
||||
const lastInterval = this.context.state.intervals[u.id].slice(-1)[0];
|
||||
|
||||
if (
|
||||
Math.abs(
|
||||
moment(lastInterval.end_at).diff(
|
||||
moment().subtract(u.screenshot_interval || 1, 'minutes'),
|
||||
'seconds',
|
||||
),
|
||||
) < 10
|
||||
) {
|
||||
return {
|
||||
...u,
|
||||
last_interval: lastInterval,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ...u, last_interval: null };
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(e => {
|
||||
if (!axios.isCancel(e)) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadUsers() {
|
||||
return this.userService
|
||||
.getAll({ headers: { 'X-Paginate': 'false' } })
|
||||
.then(response => {
|
||||
this.context.commit('setUsers', response);
|
||||
return response;
|
||||
})
|
||||
.catch(e => {
|
||||
if (!axios.isCancel(e)) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<AxiosResponse<T>>}
|
||||
* @param taskIDs
|
||||
* @param action
|
||||
*/
|
||||
loadTasks(taskIDs) {
|
||||
return this.taskService
|
||||
.getWithFilters({
|
||||
id: ['=', taskIDs],
|
||||
with: 'project',
|
||||
})
|
||||
.then(response => {
|
||||
if (typeof response !== 'undefined') {
|
||||
const { data } = response;
|
||||
const tasks = data.data.reduce((tasks, task) => {
|
||||
tasks[task.id] = task;
|
||||
return tasks;
|
||||
}, {});
|
||||
|
||||
this.context.commit('setTasks', tasks);
|
||||
|
||||
return tasks;
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (!axios.isCancel(e)) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendInvites(emails) {
|
||||
return axios.post(`register/create`, emails);
|
||||
}
|
||||
}
|
||||
153
resources/frontend/core/modules/Dashboard/storeModule.js
Normal file
153
resources/frontend/core/modules/Dashboard/storeModule.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import moment from 'moment-timezone';
|
||||
import TasksService from '@/services/resource/task.service';
|
||||
import UserService from '@/services/resource/user.service';
|
||||
import DashboardService from '_internal/Dashboard/services/dashboard.service';
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
|
||||
const state = {
|
||||
service: null,
|
||||
intervals: {},
|
||||
tasks: {},
|
||||
users: [],
|
||||
timezone: moment.tz.guess(),
|
||||
};
|
||||
|
||||
const getters = {
|
||||
service: state => state.service,
|
||||
intervals: state => state.intervals,
|
||||
tasks: state => state.tasks,
|
||||
users: state => state.users,
|
||||
timePerProject: (state, getters) => {
|
||||
return Object.keys(getters.intervals).reduce((result, userID) => {
|
||||
const userEvents = getters.intervals[userID];
|
||||
if (!userEvents) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const projects = userEvents.reduce((projects, event) => {
|
||||
if (!projects[event.project_id]) {
|
||||
projects[event.project_id] = {
|
||||
id: event.project_id,
|
||||
name: event.project_name,
|
||||
duration: event.duration,
|
||||
tasks: {},
|
||||
durationAtSelectedPeriod: event.durationAtSelectedPeriod,
|
||||
};
|
||||
} else {
|
||||
projects[event.project_id].duration += event.duration;
|
||||
projects[event.project_id].durationAtSelectedPeriod += event.durationAtSelectedPeriod;
|
||||
}
|
||||
|
||||
if (!projects[event.project_id].tasks[event.task_id]) {
|
||||
projects[event.project_id].tasks[event.task_id] = {
|
||||
id: event.task_id,
|
||||
name: event.task_name,
|
||||
duration: event.duration,
|
||||
durationAtSelectedPeriod: event.durationAtSelectedPeriod,
|
||||
};
|
||||
} else {
|
||||
projects[event.project_id].tasks[event.task_id].duration += event.duration;
|
||||
projects[event.project_id].tasks[event.task_id].durationAtSelectedPeriod +=
|
||||
event.durationAtSelectedPeriod;
|
||||
}
|
||||
|
||||
return projects;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...result,
|
||||
[userID]: projects,
|
||||
};
|
||||
}, {});
|
||||
},
|
||||
timePerDay: (state, getters) => {
|
||||
return Object.keys(getters.intervals).reduce((result, userID) => {
|
||||
const userEvents = getters.intervals[userID];
|
||||
if (!userEvents) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const userTimePerDay = userEvents.reduce((result, event) => {
|
||||
return _.mergeWith({}, result, event.durationByDay, _.add);
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...result,
|
||||
[userID]: userTimePerDay,
|
||||
};
|
||||
}, {});
|
||||
},
|
||||
timezone: state => state.timezone,
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
setService(state, service) {
|
||||
state.service = service;
|
||||
},
|
||||
setIntervals(state, intervals) {
|
||||
state.intervals = intervals;
|
||||
},
|
||||
addInterval(state, interval) {
|
||||
if (Array.isArray(state.intervals)) {
|
||||
state.intervals = {};
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
|
||||
Vue.set(state.intervals, interval.user_id, []);
|
||||
}
|
||||
|
||||
state.intervals[interval.user_id].push(interval);
|
||||
},
|
||||
updateInterval(state, interval) {
|
||||
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = state.intervals[interval.user_id].findIndex(item => +item.id === +interval.id);
|
||||
if (index !== -1) {
|
||||
state.intervals[interval.user_id].splice(index, 1, interval);
|
||||
}
|
||||
},
|
||||
removeInterval(state, interval) {
|
||||
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = state.intervals[interval.user_id].findIndex(item => +item.id === +interval.id);
|
||||
if (index !== -1) {
|
||||
state.intervals[interval.user_id].splice(index, 1);
|
||||
}
|
||||
},
|
||||
removeIntervalById(state, id) {
|
||||
for (const userId in state.intervals) {
|
||||
const index = state.intervals[userId].findIndex(item => +item.id === +id);
|
||||
if (index !== -1) {
|
||||
state.intervals[userId].splice(index, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
setTasks(state, tasks) {
|
||||
state.tasks = tasks;
|
||||
},
|
||||
setUsers(state, users) {
|
||||
state.users = users;
|
||||
},
|
||||
setTimezone(state, timezone) {
|
||||
state.timezone = timezone;
|
||||
},
|
||||
};
|
||||
|
||||
const actions = {
|
||||
init(context) {
|
||||
context.commit('setService', new DashboardService(context, new TasksService(), new UserService()));
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
state,
|
||||
getters,
|
||||
mutations,
|
||||
actions,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard__routes">
|
||||
<h1 class="dashboard__link">
|
||||
<router-link :to="{ name: 'dashboard.timeline' }">{{ $t('dashboard.timeline') }}</router-link>
|
||||
</h1>
|
||||
<h1 v-if="$can('viewTeamTab', 'dashboard')" class="dashboard__link">
|
||||
<router-link :to="{ name: 'dashboard.team' }">{{ $t('dashboard.team') }}</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="dashboard__content-wrapper">
|
||||
<router-view :key="$route.fullPath" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Index',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard {
|
||||
&__routes {
|
||||
margin-bottom: 1em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__link {
|
||||
margin-right: $layout-03;
|
||||
font-size: 1.8rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: initial;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #b1b1be;
|
||||
}
|
||||
|
||||
.router-link-active {
|
||||
color: #2e2ef9;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="team">
|
||||
<div class="controls-row flex-between">
|
||||
<div class="flex">
|
||||
<Calendar
|
||||
:sessionStorageKey="sessionStorageKey"
|
||||
class="controls-row__item"
|
||||
@change="onCalendarChange"
|
||||
/>
|
||||
|
||||
<UserSelect class="controls-row__item" @change="onUsersChange" />
|
||||
|
||||
<ProjectSelect class="controls-row__item" @change="onProjectsChange" />
|
||||
|
||||
<TimezonePicker :value="timezone" class="controls-row__item" @onTimezoneChange="onTimezoneChange" />
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<router-link
|
||||
v-if="$can('viewManualTime', 'dashboard')"
|
||||
class="controls-row__item"
|
||||
to="/time-intervals/new"
|
||||
>
|
||||
<at-button class="controls-row__btn" icon="icon-edit">{{ $t('control.add_time') }}</at-button>
|
||||
</router-link>
|
||||
|
||||
<ExportDropdown
|
||||
class="export controls-row__item controls-row__btn"
|
||||
position="left"
|
||||
trigger="hover"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="at-container">
|
||||
<div class="at-container__inner">
|
||||
<div class="row">
|
||||
<div class="col-8 col-lg-6">
|
||||
<TeamSidebar
|
||||
:sort="sort"
|
||||
:sortDir="sortDir"
|
||||
:users="graphUsers"
|
||||
class="sidebar"
|
||||
@sort="onSort"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-16 col-lg-18">
|
||||
<TeamDayGraph
|
||||
v-if="type === 'day'"
|
||||
:users="graphUsers"
|
||||
:start="start"
|
||||
class="graph"
|
||||
@selectedIntervals="onSelectedIntervals"
|
||||
/>
|
||||
<TeamTableGraph
|
||||
v-else
|
||||
:end="end"
|
||||
:start="start"
|
||||
:timePerDay="timePerDay"
|
||||
:users="graphUsers"
|
||||
class="graph"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TimeIntervalEdit
|
||||
v-if="selectedIntervals.length"
|
||||
:intervals="selectedIntervals"
|
||||
@close="clearIntervals"
|
||||
@edit="load"
|
||||
@remove="onBulkRemove"
|
||||
/>
|
||||
</div>
|
||||
<preloader v-if="isDataLoading" :is-transparent="true" class="team__loader" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Calendar from '@/components/Calendar';
|
||||
import ExportDropdown from '@/components/ExportDropdown';
|
||||
import Preloader from '@/components/Preloader';
|
||||
import ProjectSelect from '@/components/ProjectSelect';
|
||||
import TimezonePicker from '@/components/TimezonePicker';
|
||||
import UserSelect from '@/components/UserSelect';
|
||||
import ProjectService from '@/services/resource/project.service';
|
||||
import { getDateToday, getEndOfDayInTimezone, getStartOfDayInTimezone } from '@/utils/time';
|
||||
import DashboardReportService from '_internal/Dashboard/services/dashboard.service';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import throttle from 'lodash/throttle';
|
||||
import moment from 'moment';
|
||||
import { mapGetters, mapMutations } from 'vuex';
|
||||
import TeamDayGraph from '../../components/TeamDayGraph';
|
||||
import TeamSidebar from '../../components/TeamSidebar';
|
||||
import TeamTableGraph from '../../components/TeamTableGraph';
|
||||
import TimeIntervalEdit from '../../components/TimeIntervalEdit';
|
||||
|
||||
const updateInterval = 60 * 1000;
|
||||
|
||||
export default {
|
||||
name: 'Team',
|
||||
components: {
|
||||
Calendar,
|
||||
UserSelect,
|
||||
ProjectSelect,
|
||||
TeamSidebar,
|
||||
TeamDayGraph,
|
||||
TeamTableGraph,
|
||||
TimezonePicker,
|
||||
ExportDropdown,
|
||||
TimeIntervalEdit,
|
||||
Preloader,
|
||||
},
|
||||
data() {
|
||||
const today = this.getDateToday();
|
||||
const sessionStorageKey = 'amazingcat.session.storage.team';
|
||||
|
||||
return {
|
||||
type: 'day',
|
||||
start: today,
|
||||
end: today,
|
||||
userIDs: [],
|
||||
projectIDs: [],
|
||||
sort: localStorage.getItem('team.sort') || 'user',
|
||||
sortDir: localStorage.getItem('team.sort-dir') || 'asc',
|
||||
projectService: new ProjectService(),
|
||||
reportService: new DashboardReportService(),
|
||||
showExportModal: false,
|
||||
selectedIntervals: [],
|
||||
sessionStorageKey: sessionStorageKey,
|
||||
isDataLoading: false,
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
localStorage['dashboard.tab'] = 'team';
|
||||
|
||||
await this.load();
|
||||
this.updateHandle = setInterval(() => {
|
||||
if (!this.updatedWithWebsockets) {
|
||||
this.load(false);
|
||||
}
|
||||
|
||||
this.updatedWithWebsockets = false;
|
||||
}, updateInterval);
|
||||
this.updatedWithWebsockets = false;
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.updateHandle);
|
||||
this.service.unloadIntervals();
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['intervals', 'timePerDay', 'users', 'timezone', 'service']),
|
||||
...mapGetters('user', ['user']),
|
||||
graphUsers() {
|
||||
return this.users
|
||||
.filter(user => this.userIDs.includes(user.id))
|
||||
.map(user => ({ ...user, worked: this.getWorked(user.id) }))
|
||||
.sort((a, b) => {
|
||||
let order = 0;
|
||||
if (this.sort === 'user') {
|
||||
const aName = a.full_name.toUpperCase();
|
||||
const bName = b.full_name.toUpperCase();
|
||||
order = aName.localeCompare(bName);
|
||||
} else if (this.sort === 'worked') {
|
||||
const aWorked = a.worked || 0;
|
||||
const bWorked = b.worked || 0;
|
||||
order = aWorked - bWorked;
|
||||
}
|
||||
|
||||
return this.sortDir === 'asc' ? order : -order;
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDateToday,
|
||||
getStartOfDayInTimezone,
|
||||
getEndOfDayInTimezone,
|
||||
...mapMutations({
|
||||
setTimezone: 'dashboard/setTimezone',
|
||||
removeInterval: 'dashboard/removeInterval',
|
||||
addInterval: 'dashboard/addInterval',
|
||||
updateInterval: 'dashboard/updateInterval',
|
||||
removeIntervalById: 'dashboard/removeIntervalById',
|
||||
}),
|
||||
load: throttle(async function (withLoadingIndicator = true) {
|
||||
this.isDataLoading = withLoadingIndicator;
|
||||
if (!this.userIDs.length || !this.projectIDs.length) {
|
||||
this.isDataLoading = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const startAt = this.getStartOfDayInTimezone(this.start, this.timezone);
|
||||
const endAt = this.getEndOfDayInTimezone(this.end, this.timezone);
|
||||
|
||||
await this.service.load(this.userIDs, this.projectIDs, startAt, endAt, this.timezone);
|
||||
|
||||
this.isDataLoading = false;
|
||||
}, 1000),
|
||||
onCalendarChange({ type, start, end }) {
|
||||
this.type = type;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
|
||||
this.service.unloadIntervals();
|
||||
|
||||
this.load();
|
||||
},
|
||||
onUsersChange(userIDs) {
|
||||
this.userIDs = [...userIDs];
|
||||
|
||||
this.load();
|
||||
},
|
||||
onProjectsChange(projectIDs) {
|
||||
this.projectIDs = [...projectIDs];
|
||||
|
||||
this.load();
|
||||
},
|
||||
onTimezoneChange(timezone) {
|
||||
this.setTimezone(timezone);
|
||||
},
|
||||
onSort(column) {
|
||||
if (column === this.sort) {
|
||||
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sort = column;
|
||||
// Sort users ascending and time descending by default
|
||||
this.sortDir = column === 'user' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
localStorage['team.sort'] = this.sort;
|
||||
localStorage['team.sort-dir'] = this.sortDir;
|
||||
},
|
||||
async onExport(format) {
|
||||
const { data } = await this.reportService.downloadReport(
|
||||
this.getStartOfDayInTimezone(this.start, this.timezone),
|
||||
this.getEndOfDayInTimezone(this.end, this.timezone),
|
||||
this.userIDs,
|
||||
this.projectIDs,
|
||||
this.timezone,
|
||||
format,
|
||||
this.sort,
|
||||
this.sortDir,
|
||||
);
|
||||
|
||||
window.open(data.data.url, '_blank');
|
||||
},
|
||||
onSelectedIntervals(event) {
|
||||
this.selectedIntervals = event ? [event] : [];
|
||||
},
|
||||
onBulkRemove(intervals) {
|
||||
const totalIntervals = cloneDeep(this.intervals);
|
||||
intervals.forEach(interval => {
|
||||
const userIntervals = cloneDeep(totalIntervals[interval.user_id]).filter(
|
||||
userInterval => interval.id !== userInterval.id,
|
||||
);
|
||||
const deletedDuration = moment(interval.end_at).diff(interval.start_at, 'seconds');
|
||||
userIntervals.duration -= deletedDuration;
|
||||
|
||||
totalIntervals[interval.user_id] = userIntervals;
|
||||
});
|
||||
this.$store.commit('dashboard/setIntervals', totalIntervals);
|
||||
|
||||
this.clearIntervals();
|
||||
},
|
||||
clearIntervals() {
|
||||
this.selectedIntervals = [];
|
||||
},
|
||||
|
||||
// for send invites to new users
|
||||
async getModalInvite() {
|
||||
let modal;
|
||||
try {
|
||||
modal = await this.$Modal.prompt({
|
||||
title: this.$t('invite.label'),
|
||||
content: this.$t('invite.content'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modal.value) {
|
||||
this.$Message.error(this.$t('invite.message.error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const emails = modal.value.split(',');
|
||||
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const regex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
|
||||
const validation = {
|
||||
isError: false,
|
||||
emails: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
let email = emails[i].replace(' ', '');
|
||||
if (regex.exec(email) == null) {
|
||||
validation.isError = true;
|
||||
validation.emails.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
if (!validation.isError) {
|
||||
this.reportService.sendInvites({ emails }).then(({ data }) => {
|
||||
this.$Message.success('Success');
|
||||
});
|
||||
} else {
|
||||
this.$Message.error(this.$t('invite.message.valid') + validation.emails);
|
||||
}
|
||||
},
|
||||
getWorked(userId) {
|
||||
return Object.prototype.hasOwnProperty.call(this.intervals, userId)
|
||||
? this.intervals[userId].reduce((acc, el) => acc + el.durationAtSelectedPeriod, 0)
|
||||
: 0;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const channel = this.$echo.private(`intervals.${this.user.id}`);
|
||||
channel.listen(`.intervals.create`, data => {
|
||||
const startAt = moment.tz(data.model.start_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
|
||||
const endAt = moment.tz(data.model.end_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
|
||||
if (startAt > this.end || endAt < this.start) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addInterval(data.model);
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
|
||||
channel.listen(`.intervals.edit`, data => {
|
||||
this.updateInterval(data.model);
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
|
||||
channel.listen(`.intervals.destroy`, data => {
|
||||
if (typeof data.model === 'number') {
|
||||
this.removeIntervalById(data.model);
|
||||
} else {
|
||||
this.removeInterval(data.model);
|
||||
}
|
||||
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
timezone() {
|
||||
this.service.unloadIntervals();
|
||||
this.load();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.at-container {
|
||||
&__inner {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.team__loader {
|
||||
z-index: 0;
|
||||
border-radius: 20px;
|
||||
|
||||
&::v-deep {
|
||||
align-items: baseline;
|
||||
|
||||
.lds-ellipsis {
|
||||
position: sticky;
|
||||
top: 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-type {
|
||||
margin-left: 10px;
|
||||
border-radius: 5px;
|
||||
|
||||
.at-btn:first-child {
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.at-btn:last-child {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
border: 1px solid #eeeef5;
|
||||
color: #b1b1be;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
height: 40px;
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
background: #2e2ef9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.controls-row {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
padding-right: 1px; // fix horizontal scroll caused by download btn padding
|
||||
&__item {
|
||||
margin: 0;
|
||||
margin-bottom: $spacing-03;
|
||||
}
|
||||
.calendar {
|
||||
&::v-deep .input {
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
& > div:first-child {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 250px);
|
||||
width: 100%;
|
||||
column-gap: $spacing-03;
|
||||
}
|
||||
& > div:last-child {
|
||||
align-self: flex-end;
|
||||
column-gap: $spacing-03;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.at-container {
|
||||
&__inner {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 40px;
|
||||
|
||||
&::v-deep .at-btn__text {
|
||||
color: #2e2ef9;
|
||||
font-size: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
.button-invite {
|
||||
color: #618fea;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<div class="at-container sidebar">
|
||||
<TimelineSidebar
|
||||
:active-task="activeTask"
|
||||
:isDataLoading="isDataLoading"
|
||||
:startDate="start"
|
||||
:endDate="end"
|
||||
/>
|
||||
</div>
|
||||
<div class="controls-row flex-between">
|
||||
<div class="flex">
|
||||
<Calendar
|
||||
class="controls-row__item"
|
||||
:range="false"
|
||||
:sessionStorageKey="sessionStorageKey"
|
||||
@change="onCalendarChange"
|
||||
/>
|
||||
<TimezonePicker class="controls-row__item" :value="timezone" @onTimezoneChange="onTimezoneChange" />
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<router-link
|
||||
v-if="$can('viewManualTime', 'dashboard')"
|
||||
to="/time-intervals/new"
|
||||
class="controls-row__item"
|
||||
>
|
||||
<at-button class="controls-row__btn" icon="icon-edit">
|
||||
{{ $t('control.add_time') }}
|
||||
</at-button>
|
||||
</router-link>
|
||||
|
||||
<ExportDropdown
|
||||
class="export-btn dropdown controls-row__btn controls-row__item"
|
||||
position="left-top"
|
||||
trigger="hover"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="at-container intervals">
|
||||
<TimelineDayGraph
|
||||
v-if="type === 'day'"
|
||||
class="graph"
|
||||
:start="start"
|
||||
:end="end"
|
||||
:events="userEvents"
|
||||
:timezone="timezone"
|
||||
@selectedIntervals="onIntervalsSelect"
|
||||
@remove="onBulkRemove"
|
||||
/>
|
||||
<TimelineCalendarGraph v-else class="graph" :start="start" :end="end" :timePerDay="userTimePerDay" />
|
||||
|
||||
<TimelineScreenshots
|
||||
v-if="type === 'day' && intervals && Object.keys(intervals).length"
|
||||
ref="timelineScreenshots"
|
||||
@on-remove="recalculateStatistic"
|
||||
@onSelectedIntervals="setSelectedIntervals"
|
||||
/>
|
||||
<preloader v-if="isDataLoading" class="timeline__loader" :is-transparent="true" />
|
||||
|
||||
<TimeIntervalEdit
|
||||
:intervals="selectedIntervals"
|
||||
@remove="onBulkRemove"
|
||||
@edit="loadData"
|
||||
@close="clearIntervals"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { mapGetters, mapMutations } from 'vuex';
|
||||
import Calendar from '@/components/Calendar';
|
||||
import TimelineSidebar from '../../components/TimelineSidebar';
|
||||
import TimelineDayGraph from '../../components/TimelineDayGraph';
|
||||
import TimelineCalendarGraph from '../../components/TimelineCalendarGraph';
|
||||
import TimelineScreenshots from '../../components/TimelineScreenshots';
|
||||
import TimezonePicker from '@/components/TimezonePicker';
|
||||
import DashboardService from '_internal/Dashboard/services/dashboard.service';
|
||||
import { getDateToday } from '@/utils/time';
|
||||
import { getStartOfDayInTimezone, getEndOfDayInTimezone } from '@/utils/time';
|
||||
import ExportDropdown from '@/components/ExportDropdown';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import TimeIntervalEdit from '../../components/TimeIntervalEdit';
|
||||
import Preloader from '@/components/Preloader';
|
||||
|
||||
const updateInterval = 60 * 1000;
|
||||
|
||||
const dashboardService = new DashboardService();
|
||||
|
||||
export default {
|
||||
name: 'Timeline',
|
||||
components: {
|
||||
Calendar,
|
||||
TimelineSidebar,
|
||||
TimelineDayGraph,
|
||||
TimelineCalendarGraph,
|
||||
TimelineScreenshots,
|
||||
TimezonePicker,
|
||||
ExportDropdown,
|
||||
TimeIntervalEdit,
|
||||
Preloader,
|
||||
},
|
||||
data() {
|
||||
const today = this.getDateToday();
|
||||
const sessionStorageKey = 'amazingcat.session.storage.timeline';
|
||||
|
||||
return {
|
||||
type: 'day',
|
||||
start: today,
|
||||
end: today,
|
||||
datepickerDateStart: '',
|
||||
datepickerDateEnd: '',
|
||||
activeTask: +localStorage.getItem('timeline.active-task') || 0,
|
||||
showExportModal: false,
|
||||
selectedIntervals: [],
|
||||
sessionStorageKey: sessionStorageKey,
|
||||
isDataLoading: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
localStorage['dashboard.tab'] = 'timeline';
|
||||
this.loadData();
|
||||
this.updateHandle = setInterval(() => {
|
||||
if (!this.updatedWithWebsockets) {
|
||||
this.loadData(false);
|
||||
}
|
||||
|
||||
this.updatedWithWebsockets = false;
|
||||
}, updateInterval);
|
||||
this.updatedWithWebsockets = false;
|
||||
},
|
||||
mounted() {
|
||||
const channel = this.$echo.private(`intervals.${this.user.id}`);
|
||||
channel.listen(`.intervals.create`, data => {
|
||||
const startAt = moment.tz(data.model.start_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
|
||||
const endAt = moment.tz(data.model.end_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
|
||||
if (startAt > this.end || endAt < this.start) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addInterval(data.model);
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
|
||||
channel.listen(`.intervals.edit`, data => {
|
||||
this.updateInterval(data.model);
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
|
||||
channel.listen(`.intervals.destroy`, data => {
|
||||
if (typeof data.model === 'number') {
|
||||
this.removeIntervalById(data.model);
|
||||
} else {
|
||||
this.removeInterval(data.model);
|
||||
}
|
||||
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.updateHandle);
|
||||
this.service.unloadIntervals();
|
||||
|
||||
this.$echo.leave(`intervals.${this.user.id}`);
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['service', 'intervals', 'timePerDay', 'timePerProject', 'timezone']),
|
||||
...mapGetters('user', ['user']),
|
||||
userEvents() {
|
||||
if (!this.user || !this.user.id || !this.intervals[this.user.id]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.intervals[this.user.id];
|
||||
},
|
||||
userTimePerDay() {
|
||||
if (!this.user || !this.user.id || !this.timePerDay[this.user.id]) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return this.timePerDay[this.user.id];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDateToday,
|
||||
getStartOfDayInTimezone,
|
||||
getEndOfDayInTimezone,
|
||||
...mapMutations({
|
||||
setTimezone: 'dashboard/setTimezone',
|
||||
removeInterval: 'dashboard/removeInterval',
|
||||
addInterval: 'dashboard/addInterval',
|
||||
updateInterval: 'dashboard/updateInterval',
|
||||
removeIntervalById: 'dashboard/removeIntervalById',
|
||||
}),
|
||||
loadData: debounce(async function (withLoadingIndicator = true) {
|
||||
this.isDataLoading = withLoadingIndicator;
|
||||
|
||||
if (!this.user || !this.user.id) {
|
||||
this.isDataLoading = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const userIDs = [this.user.id];
|
||||
|
||||
const startAt = this.getStartOfDayInTimezone(this.start, this.timezone);
|
||||
const endAt = this.getEndOfDayInTimezone(this.end, this.timezone);
|
||||
|
||||
await this.service.load(userIDs, null, startAt, endAt, this.timezone);
|
||||
|
||||
this.isDataLoading = false;
|
||||
}, 350),
|
||||
onCalendarChange({ type, start, end }) {
|
||||
this.type = type;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
|
||||
this.service.unloadIntervals();
|
||||
|
||||
this.loadData();
|
||||
},
|
||||
onIntervalsSelect(event) {
|
||||
this.selectedIntervals = event ? [event] : [];
|
||||
},
|
||||
async onExport(format) {
|
||||
const { data } = await dashboardService.downloadReport(
|
||||
this.getStartOfDayInTimezone(this.start, this.timezone),
|
||||
this.getEndOfDayInTimezone(this.end, this.timezone),
|
||||
[this.user.id],
|
||||
this.projectIDs,
|
||||
this.timezone,
|
||||
format,
|
||||
);
|
||||
|
||||
window.open(data.data.url, '_blank');
|
||||
},
|
||||
onBulkRemove(intervals) {
|
||||
const totalIntervals = cloneDeep(this.intervals);
|
||||
intervals.forEach(interval => {
|
||||
const userIntervals = cloneDeep(totalIntervals[interval.user_id]).filter(
|
||||
userInterval => interval.id !== userInterval.id,
|
||||
);
|
||||
const deletedDuration = moment(interval.end_at).diff(interval.start_at, 'seconds');
|
||||
userIntervals.duration -= deletedDuration;
|
||||
|
||||
totalIntervals[interval.user_id] = userIntervals;
|
||||
});
|
||||
this.$store.commit('dashboard/setIntervals', totalIntervals);
|
||||
|
||||
this.clearIntervals();
|
||||
},
|
||||
onTimezoneChange(timezone) {
|
||||
this.setTimezone(timezone);
|
||||
},
|
||||
recalculateStatistic(intervals) {
|
||||
this.onBulkRemove(intervals);
|
||||
},
|
||||
setSelectedIntervals(intervalIds) {
|
||||
this.selectedIntervals = intervalIds;
|
||||
},
|
||||
clearIntervals() {
|
||||
if (this.$refs.timelineScreenshots) {
|
||||
this.$refs.timelineScreenshots.clearSelectedIntervals();
|
||||
}
|
||||
this.selectedIntervals = [];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
user() {
|
||||
this.loadData();
|
||||
},
|
||||
timezone() {
|
||||
this.service.unloadIntervals();
|
||||
this.loadData();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.at-container::v-deep {
|
||||
.modal-screenshot {
|
||||
a {
|
||||
max-height: inherit;
|
||||
|
||||
img {
|
||||
max-height: inherit;
|
||||
object-fit: fill;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.at-container {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr 1fr;
|
||||
column-gap: 0.5rem;
|
||||
&__loader {
|
||||
z-index: 0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-type {
|
||||
margin-left: 10px;
|
||||
border-radius: 5px;
|
||||
|
||||
.at-btn:first-child {
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.at-btn:last-child {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
border: 1px solid #eeeef5;
|
||||
color: #b1b1be;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
height: 40px;
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
background: #2e2ef9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 30px 0;
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 3;
|
||||
max-height: fit-content;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
grid-column: 2 / 4;
|
||||
padding-right: 1px; // fix horizontal scroll caused by download btn padding
|
||||
}
|
||||
|
||||
.intervals {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
@media (max-width: 1300px) {
|
||||
.timeline {
|
||||
grid-template-columns: 250px 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1110px) {
|
||||
.canvas {
|
||||
padding-top: 0.3rem;
|
||||
}
|
||||
.at-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.sidebar {
|
||||
padding: 15px 0;
|
||||
}
|
||||
.controls-row {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
//padding-right: 1px; // fix horizontal scroll caused by download btn padding
|
||||
&__item {
|
||||
margin: 0;
|
||||
margin-bottom: $spacing-03;
|
||||
}
|
||||
.calendar {
|
||||
&::v-deep .input {
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
& > div:first-child {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 250px);
|
||||
width: 100%;
|
||||
column-gap: $spacing-03;
|
||||
}
|
||||
& > div:last-child {
|
||||
align-self: flex-end;
|
||||
column-gap: $spacing-03;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 790px) {
|
||||
.intervals {
|
||||
grid-column: 1/4;
|
||||
}
|
||||
.controls-row {
|
||||
& > div:last-child {
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.controls-row {
|
||||
grid-column: 1/4;
|
||||
grid-row: 1;
|
||||
& > div:last-child {
|
||||
align-self: end;
|
||||
}
|
||||
}
|
||||
.sidebar {
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.graph {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user