first commit

This commit is contained in:
Noor E Ilahi
2026-01-09 12:54:53 +05:30
commit 7ccf44f7da
1070 changed files with 113036 additions and 0 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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"
}
}

View 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": "Показать меньше"
}
}

View 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;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
import { store } from '@/store';
import DashboardPolicy from './dashboard.policy';
store.dispatch('policies/registerPolicies', {
dashboard: DashboardPolicy,
});

View File

@@ -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);
}
}

View 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,
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>