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,138 @@
<template>
<div ref="container"></div>
</template>
<script>
import { Svg, SVG } from '@svgdotjs/svg.js';
import throttle from 'lodash/throttle';
const daysOfWeek = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const cellWidth = 100 / daysOfWeek.length;
const cellHeight = 32;
export default {
props: {
tasksByDay: {
type: Array,
required: true,
},
},
created() {
window.addEventListener('resize', this.resize);
},
mounted() {
const { container } = this.$refs;
this.svg = SVG().addTo(container);
this.resize();
this.draw();
},
destroyed() {
window.removeEventListener('resize', this.resize);
},
watch: {
tasksByDay() {
this.resize();
this.draw();
},
},
methods: {
resize: throttle(function () {
const { container } = this.$refs;
const weeks = Math.ceil(this.tasksByDay.length / 7);
const rows = 1 + 2 * weeks;
const width = container.clientWidth;
const height = rows * cellHeight;
this.svg.viewbox(0, 0, width, height);
}, 100),
draw: throttle(function () {
const borderColor = '#eeeef5';
const blockColor = '#fff';
const textColor = 'rgb(63, 83, 110)';
/** @type {Svg} */
const svg = this.svg;
svg.clear();
const group = svg.group();
group.clipWith(svg.rect('100%', '100%').rx(20).ry(20));
let horizontalOffset = 0;
let verticalOffset = 0;
const drawHeader = () => {
for (const day of daysOfWeek) {
const rect = group
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
.fill(blockColor)
.stroke(borderColor);
group
.text(add => add.tspan(this.$t(`calendar.days.${day}`)))
.font({ anchor: 'middle', size: 16 })
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
.fill(textColor)
.clipWith(rect.clone());
horizontalOffset += cellWidth;
}
verticalOffset += cellHeight;
};
const drawDays = () => {
horizontalOffset = 0;
const days = this.tasksByDay;
for (let i = 0; i < days.length; i++) {
const { date, day, tasks } = days[i];
const onClick = () => this.$emit('show-tasks-modal', { date, tasks });
const rect = group
.rect(`${cellWidth.toFixed(2)}%`, 2 * cellHeight)
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
.fill(blockColor)
.stroke(borderColor)
.on('click', onClick);
const text = group
.text(add => add.tspan(day.toString()))
.font({ anchor: 'middle', size: 16 })
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
.fill(textColor)
.clipWith(rect.clone())
.on('click', onClick);
if (tasks.length > 0) {
rect.attr('cursor', 'pointer');
text.attr('cursor', 'pointer');
group
.circle(10)
.attr('cx', `${(horizontalOffset + cellWidth / 2).toFixed(2)}%`)
.attr('cy', verticalOffset + cellHeight * 1.5)
.fill('rgb(177, 177, 190)')
.on('click', onClick)
.attr('cursor', 'pointer');
}
if (i % daysOfWeek.length === daysOfWeek.length - 1) {
horizontalOffset = 0;
verticalOffset += 2 * cellHeight;
} else {
horizontalOffset += cellWidth;
}
}
};
drawHeader();
drawDays();
}, 100),
},
};
</script>

View File

@@ -0,0 +1,404 @@
<template>
<div class="calendar">
<div ref="container" class="calendar__svg"></div>
<div
v-show="hoverPopup.show"
:style="{
left: `${hoverPopup.x}px`,
top: `${hoverPopup.y}px`,
}"
class="calendar__popup popup"
>
<template v-if="hoverPopup.task">
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.name') }}</span>
<span class="popup__value">{{ hoverPopup.task.task_name }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.status') }}</span>
<span class="popup__value">{{ hoverPopup.task.status.name }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.priority') }}</span>
<span class="popup__value">{{ hoverPopup.task.priority.name }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.estimate') }}</span>
<span class="popup__value">{{ formatDuration(hoverPopup.task.estimate) }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.total_spent_time') }}</span>
<span class="popup__value">{{ formatDuration(hoverPopup.task.total_spent_time) }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.start_date') }}</span>
<span class="popup__value">{{ formatDate(hoverPopup.task.start_date) }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.due_date') }}</span>
<span class="popup__value">{{ formatDate(hoverPopup.task.due_date) }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.forecast_completion_date') }}</span>
<span class="popup__value">{{ formatDate(hoverPopup.task.forecast_completion_date) }}</span>
</p>
</template>
</div>
</div>
</template>
<script>
import { Svg, SVG } from '@svgdotjs/svg.js';
import { formatDurationString } from '@/utils/time';
import throttle from 'lodash/throttle';
const msInDay = 24 * 60 * 60 * 1000;
const daysOfWeek = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const months = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
const cellWidth = 100 / daysOfWeek.length;
const cellHeight = 32;
const maxTasks = 5;
export default {
props: {
tasksByWeek: {
type: Array,
required: true,
},
showAll: {
type: Boolean,
default: true,
},
},
data() {
return {
hoverPopup: {
show: false,
x: 0,
y: 0,
task: null,
},
};
},
created() {
window.addEventListener('resize', this.resize);
},
mounted() {
const { container } = this.$refs;
this.svg = SVG().addTo(container);
this.resize();
this.draw();
},
destroyed() {
window.removeEventListener('resize', this.resize);
},
watch: {
tasksByWeek() {
this.resize();
this.draw();
},
showAll() {
this.resize();
this.draw();
},
},
methods: {
formatDuration(value) {
return value !== null ? formatDurationString(value) : '—';
},
formatDate(value) {
return value !== null ? value : '—';
},
resize: throttle(function () {
const { container } = this.$refs;
const weeks = this.tasksByWeek.length;
const tasks = this.tasksByWeek.reduce(
(acc, item) => acc + (this.showAll ? item.tasks.length : Math.min(maxTasks, item.tasks.length)),
0,
);
const rows = 1 + weeks + tasks;
const width = container.clientWidth;
const height = rows * cellHeight;
this.svg.viewbox(0, 0, width, height);
}, 100),
draw: throttle(function () {
const backgroundColor = '#fafafa';
const borderColor = '#eeeef5';
const blockColor = '#fff';
const textColor = 'rgb(63, 83, 110)';
/** @type {Svg} */
const svg = this.svg;
svg.clear();
const group = svg.group();
group.clipWith(svg.rect('100%', '100%').rx(20).ry(20));
let horizontalOffset = 0;
let verticalOffset = 0;
const drawHeader = () => {
for (const day of daysOfWeek) {
const rect = group
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
.fill(blockColor)
.stroke(borderColor);
group
.text(add => add.tspan(this.$t(`calendar.days.${day}`)))
.font({ anchor: 'middle', size: 16 })
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
.fill(textColor)
.clipWith(rect.clone());
horizontalOffset += cellWidth;
}
verticalOffset += cellHeight;
};
/**
* @param {string[]} days
*/
const drawDaysRow = days => {
horizontalOffset = 0;
for (let i = 0; i < days.length; i++) {
const { month, day } = days[i];
const rect = group
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
.fill(i < 5 ? blockColor : backgroundColor)
.stroke(borderColor);
const dayText =
day === 1 ? `${this.$t(`calendar.months.${months[month - 1]}`)} ${day}` : day.toString();
group
.text(add => add.tspan(dayText).dmove(-8, 0))
.font({ anchor: 'end', size: 16 })
.amove(`${(horizontalOffset + cellWidth).toFixed(2)}%`, verticalOffset + cellHeight / 2)
.fill(textColor)
.clipWith(rect.clone());
horizontalOffset += cellWidth;
}
group.line(0, verticalOffset, '100%', verticalOffset).stroke({ width: 1, color: '#C5D9E8' });
group
.line(0, verticalOffset + cellHeight - 1, '100%', verticalOffset + cellHeight - 1)
.stroke({ width: 1, color: '#C5D9E8' });
verticalOffset += cellHeight;
};
/**
* @param {Object} task
* @param {number} startWeekDay
* @param {number} endWeekDay
*/
const drawTaskRow = (task, startWeekDay, endWeekDay) => {
const width = cellWidth * (endWeekDay - startWeekDay + 1);
horizontalOffset = cellWidth * startWeekDay;
group.rect(`100%`, cellHeight).move(0, verticalOffset).fill(backgroundColor).stroke(borderColor);
const taskHorizontalPadding = 0.2;
const taskVerticalPadding = 3;
const popupWidth = 420;
const popupHeight = 220;
const onClick = () => {
this.$router.push(`/tasks/view/${task.id}`);
};
const onMouseOver = event => {
const rectBBox = rect.bbox();
const popupX =
event.clientX < this.$refs.container.clientWidth - popupWidth
? event.clientX
: event.clientX - popupWidth - 40;
const popupY =
rectBBox.y + this.$refs.container.getBoundingClientRect().y <
window.innerHeight - popupHeight
? rectBBox.y
: rectBBox.y - popupHeight;
this.hoverPopup = {
show: true,
x: popupX,
y: popupY,
task,
};
};
const onMouseOut = event => {
this.hoverPopup = {
...this.hoverPopup,
show: false,
};
};
const rect = group
.rect(`${width - 2 * taskHorizontalPadding}%`, cellHeight - 2 * taskVerticalPadding)
.move(`${horizontalOffset + taskHorizontalPadding}%`, verticalOffset + taskVerticalPadding)
.fill(blockColor)
.stroke(borderColor)
.on('mouseover', event => {
onMouseOver(event);
event.target.style.cursor = 'pointer';
})
.on('mouseout', onMouseOut)
.on('click', onClick);
let pxOffset = 0;
if (new Date(task.due_date).getTime() + msInDay < new Date().getTime()) {
pxOffset += 2 * taskVerticalPadding;
group
.rect(cellHeight - 4 * taskVerticalPadding, cellHeight - 4 * taskVerticalPadding)
.move(
`${horizontalOffset + taskHorizontalPadding}%`,
verticalOffset + 2 * taskVerticalPadding,
)
.transform({ translateX: pxOffset })
.fill('#FF5569')
.stroke(borderColor)
.rx(4)
.ry(4)
.on('mouseover', event => {
onMouseOver(event);
event.target.style.cursor = 'pointer';
})
.on('mouseout', onMouseOut)
.on('click', onClick);
pxOffset += cellHeight - 4 * taskVerticalPadding;
}
if (task.estimate !== null && Number(task.total_spent_time) > Number(task.estimate)) {
pxOffset += 2 * taskVerticalPadding;
group
.rect(cellHeight - 4 * taskVerticalPadding, cellHeight - 4 * taskVerticalPadding)
.move(
`${horizontalOffset + taskHorizontalPadding}%`,
verticalOffset + 2 * taskVerticalPadding,
)
.transform({ translateX: pxOffset })
.fill('#FFC82C')
.stroke(borderColor)
.rx(4)
.ry(4)
.on('mouseover', event => {
onMouseOver(event);
event.target.style.cursor = 'pointer';
})
.on('mouseout', onMouseOut)
.on('click', onClick);
pxOffset += cellHeight - 4 * taskVerticalPadding;
}
group
.text(add => add.tspan(task.task_name).dmove(8, 0))
.font({ anchor: 'start', size: 16 })
.amove(`${horizontalOffset + taskHorizontalPadding}%`, verticalOffset + cellHeight / 2)
.transform({ translateX: pxOffset })
.fill(textColor)
.clipWith(rect.clone())
.on('mouseover', event => {
onMouseOver(event);
event.target.style.cursor = 'pointer';
})
.on('mouseout', onMouseOut)
.on('click', onClick);
verticalOffset += cellHeight;
};
drawHeader();
for (const { days, tasks } of this.tasksByWeek) {
drawDaysRow(days);
for (const { task, start_week_day, end_week_day } of this.showAll
? tasks
: tasks.slice(0, maxTasks)) {
drawTaskRow(task, start_week_day, end_week_day);
}
}
}, 100),
},
};
</script>
<style lang="scss" scoped>
.calendar {
display: flex;
align-items: center;
justify-content: center;
position: relative;
&__svg {
width: 100%;
height: 100%;
}
&__popup {
background: #ffffff;
border-radius: 20px;
border: 0;
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
position: absolute;
display: block;
padding: 10px;
width: 100%;
max-width: 420px;
pointer-events: none;
z-index: 1;
}
}
.popup {
&__row {
display: flex;
justify-content: space-between;
}
&__value {
font-weight: bold;
text-align: right;
}
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<div v-if="visible" class="tasks-modal">
<div class="tasks-modal__header">
<h4 class="tasks-modal__title">{{ $t('calendar.tasks', { date: formattedDate }) }}</h4>
<at-button @click="close"><i class="icon icon-x"></i></at-button>
</div>
<ul class="tasks-modal__list">
<li v-for="task of tasks" :key="task.id" class="tasks-modal__item">
<router-link class="tasks-modal__link" :to="`/tasks/view/${task.id}`">
{{ task.task_name }}
</router-link>
</li>
</ul>
</div>
</template>
<script>
import moment from 'moment';
const MODAL_VISIBLE_CLASS = 'modal-visible';
export default {
props: {
date: {
type: String,
required: true,
},
tasks: {
type: Array,
required: true,
},
},
watch: {
visible(value) {
if (value) {
document.body.classList.add(MODAL_VISIBLE_CLASS);
} else {
document.body.classList.remove(MODAL_VISIBLE_CLASS);
}
},
},
beforeDestroy() {
document.body.classList.remove(MODAL_VISIBLE_CLASS);
},
computed: {
visible() {
return this.tasks.length > 0;
},
formattedDate() {
return moment(this.date).format('LL');
},
},
methods: {
close() {
this.$emit('close');
},
},
};
</script>
<style lang="scss" scoped>
.tasks-modal {
background: #fff;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
padding: 0.75em 24px;
z-index: 10;
&__header {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
&__title {
flex: 1;
text-align: center;
}
}
</style>
<style lang="scss">
body.modal-visible {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,43 @@
{
"navigation": {
"calendar": "Calendar"
},
"calendar": {
"days": {
"monday": "Mon",
"tuesday": "Tue",
"wednesday": "Wed",
"thursday": "Thu",
"friday": "Fri",
"saturday": "Sat",
"sunday": "Sun"
},
"months": {
"january": "Jan.",
"february": "Feb.",
"april": "Apr.",
"march": "Mar.",
"may": "May.",
"june": "Jun.",
"july": "Jul.",
"august": "Aug.",
"september": "Sep.",
"october": "Oct.",
"november": "Nov.",
"december": "Dec."
},
"task": {
"name": "Name",
"status": "Status",
"priority": "Priority",
"estimate": "Estimate",
"total_spent_time": "Total spent time",
"start_date": "Start date",
"due_date": "Due date",
"forecast_completion_date": "Forecast completion date"
},
"tasks": "Tasks {date}",
"show_all": "Show all",
"show_first": "Show first 5 tasks"
}
}

View File

@@ -0,0 +1,43 @@
{
"navigation": {
"calendar": "Календарь"
},
"calendar": {
"days": {
"monday": "Пн.",
"tuesday": "Вт.",
"wednesday": "Ср.",
"thursday": "Чт.",
"friday": "Пт.",
"saturday": "Сб.",
"sunday": "Вс."
},
"months": {
"january": "Янв.",
"february": "Февр.",
"april": "Апр.",
"march": "Март",
"may": "Май",
"june": "Июнь",
"july": "Июль",
"august": "Авг.",
"september": "Сент.",
"october": "Окт.",
"november": "Нояб.",
"december": "Дек."
},
"task": {
"name": "Название",
"status": "Статус",
"priority": "Приоритет",
"estimate": "Оценка времени",
"total_spent_time": "Всего потрачено времени",
"start_date": "Дата начала",
"due_date": "Дата завершения",
"forecast_completion_date": "Прогнозируемая дата завершения"
},
"tasks": "Задачи {date}",
"show_all": "Показать всё",
"show_first": "Показать первые 5 задач"
}
}

View File

@@ -0,0 +1,25 @@
export const ModuleConfig = {
routerPrefix: 'calendar',
loadOrder: 30,
moduleName: 'Calendar',
};
export function init(context) {
context.addRoute({
path: '/calendar',
name: context.getModuleRouteName() + '.index',
component: () => import(/* webpackChunkName: "calendar" */ './views/Calendar.vue'),
});
context.addNavbarEntry({
label: 'navigation.calendar',
to: { path: '/calendar' },
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,12 @@
import axios from '@/config/app';
export default class CalendarService {
/**
* @param {string|Date} startAt
* @param {string|Date} endAt
* @param {null|number|number[]} projectId
*/
get(startAt, endAt, projectId = null) {
return axios.post('tasks/calendar', { start_at: startAt, end_at: endAt, project_id: projectId });
}
}

View File

@@ -0,0 +1,165 @@
<template>
<div class="calendar">
<h1 class="page-title">{{ $t('navigation.calendar') }}</h1>
<div class="controls-row">
<DatePicker
class="controls-row__item"
:day="false"
:week="false"
:range="false"
initialTab="month"
@change="onDateChange"
/>
<ProjectSelect class="controls-row__item" @change="onProjectsChange" />
<at-button class="controls-row__item show-all" @click="onShowAllClick">{{
showAll ? $t('calendar.show_first') : $t('calendar.show_all')
}}</at-button>
</div>
<div class="at-container">
<calendar-view
class="svg-container svg-container__desktop"
:tasks-by-week="tasksByWeek"
:show-all="showAll"
/>
<calendar-mobile-view
class="svg-container svg-container__mobile"
:tasks-by-day="tasksByDay"
@show-tasks-modal="showTasksModal"
/>
<tasks-modal :date="modal.date" :tasks="modal.tasks" @close="hideTasksModal" />
</div>
</div>
</template>
<script>
import moment from 'moment';
import DatePicker from '@/components/Calendar';
import ProjectSelect from '@/components/ProjectSelect';
import CalendarMobileView from '../components/CalendarMobileView.vue';
import CalendarView from '../components/CalendarView.vue';
import TasksModal from '../components/TasksModal.vue';
import CalendarService from '../services/calendar.service';
import { debounce } from 'lodash';
const ISO8601_DATE_FORMAT = 'YYYY-MM-DD';
export default {
components: {
CalendarMobileView,
CalendarView,
DatePicker,
TasksModal,
ProjectSelect,
},
data() {
return {
start: moment().startOf('month').format(ISO8601_DATE_FORMAT),
end: moment().endOf('month').format(ISO8601_DATE_FORMAT),
projects: [],
tasksByDay: [],
tasksByWeek: [],
modal: {
date: moment().format(ISO8601_DATE_FORMAT),
tasks: [],
},
showAll: false,
};
},
created() {
this.service = new CalendarService();
this.load = debounce(this.load, 300);
this.load();
},
methods: {
async load() {
const response = await this.service.get(this.start, this.end, this.projects);
const { tasks, tasks_by_day, tasks_by_week } = response.data.data;
this.tasksByDay = tasks_by_day.map(day => ({
...day,
tasks: day.task_ids.map(task_id => tasks[task_id]),
}));
this.tasksByWeek = tasks_by_week.map(week => ({
...week,
tasks: week.tasks.map(item => ({
...item,
task: tasks[item.task_id],
})),
}));
},
onDateChange({ start, end }) {
this.start = moment(start).startOf('month').format(ISO8601_DATE_FORMAT);
this.end = moment(end).endOf('month').format(ISO8601_DATE_FORMAT);
this.load();
},
onProjectsChange(projects) {
this.projects = projects;
this.load();
},
showTasksModal({ date, tasks }) {
this.modal.date = date;
this.modal.tasks = tasks;
},
hideTasksModal() {
this.modal.tasks = [];
},
onShowAllClick() {
this.showAll = !this.showAll;
},
},
};
</script>
<style lang="scss" scoped>
.show-all {
display: none;
@media screen and (min-width: 768px) {
display: block;
}
}
.svg-container {
align-items: center;
justify-content: center;
&__mobile {
display: flex;
@media screen and (min-width: 768px) {
display: none;
}
}
&__desktop {
display: none;
@media screen and (min-width: 768px) {
display: flex;
}
}
&::v-deep svg {
width: 100%;
height: 100%;
}
&::v-deep text {
dominant-baseline: central;
}
}
.controls-row {
flex-flow: row wrap;
}
</style>

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>

View File

@@ -0,0 +1,27 @@
{
"navigation" : {
"gantt" : "Gantt"
},
"gantt" : {
"dimensions" : {
"index" : "Index",
"id" : "Id",
"task_name" : "Name",
"estimate" : "Time Estimate",
"total_spent_time": "Total Spent Time",
"start_date" : "Start date",
"due_date" : "Due date",
"name" : "Name",
"first_task_id" : "First Task",
"last_task_id" : "Last Task",
"status": "Status",
"priority": "Priority"
}
},
"field" : {
},
"control" : {
},
"projects" : {
}
}

View File

@@ -0,0 +1,28 @@
{
"navigation": {
"gantt": "Гант"
},
"gantt": {
"dimensions" : {
"index" : "Index",
"id" : "Id",
"task_name" : "Название",
"estimate" : "Оценка времени",
"total_spent_time": "Всего потрачено времени",
"start_date" : "Дата начала",
"due_date" : "Дата завершения",
"name" : "Название",
"first_task_id" : "Первая задача",
"last_task_id" : "Последняя задача",
"status": "Статус",
"priority": "Приоритет"
}
},
"field": {
},
"control": {
},
"projects": {
}
}

View File

@@ -0,0 +1,20 @@
export const ModuleConfig = {
routerPrefix: 'gantt',
loadOrder: 20,
moduleName: 'Gantt',
};
export function init(context) {
context.addRoute({
path: '/gantt/:id',
name: context.getModuleRouteName() + '.index',
component: () => import(/* webpackChunkName: "gantt" */ './views/Gantt.vue'),
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,875 @@
<template>
<div class="gantt">
<div class="row flex-end">
<at-button size="large" @click="$router.go(-1)">{{ $t('control.back') }}</at-button>
</div>
<v-chart ref="gantt" class="gantt__chart" />
<preloader v-if="isDataLoading" :is-transparent="true" class="gantt__loader" />
</div>
</template>
<script>
import i18n from '@/i18n';
const HEIGHT_RATIO = 0.6;
const ROW_HEIGHT = 20;
const rawDimensions = [
'index',
'id',
'task_name',
'priority_id',
'status_id',
'estimate',
'start_date',
'due_date',
'project_phase_id',
'project_id',
'total_spent_time',
'total_offset',
'status',
'priority',
];
const i18nDimensions = rawDimensions.map(t => i18n.t(`gantt.dimensions.${t}`));
const dimensionIndex = Object.fromEntries(rawDimensions.map((el, i) => [el, i]));
const dimensionsMap = new Map(rawDimensions.map((el, i) => [i, el]));
const rawPhaseDimensions = ['id', 'name', 'start_date', 'due_date', 'first_task_id', 'last_task_id'];
const i18nPhaseDimensions = rawPhaseDimensions.map(t => i18n.t(`gantt.dimensions.${t}`));
const phaseDimensionIndex = Object.fromEntries(rawPhaseDimensions.map((el, i) => [el, i]));
import { use, format as echartsFormat, graphic as echartsGraphic } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
import { CustomChart } from 'echarts/charts';
import {
LegendComponent,
TooltipComponent,
ToolboxComponent,
TitleComponent,
DataZoomComponent,
GridComponent,
} from 'echarts/components';
import VChart, { THEME_KEY } from 'vue-echarts';
import debounce from 'lodash/debounce';
import Preloader from '@/components/Preloader.vue';
import GanttService from '@/services/resource/gantt.service';
import { formatDurationString, getStartDate } from '@/utils/time';
import moment from 'moment-timezone';
import { mapGetters } from 'vuex';
use([
CanvasRenderer,
PieChart,
TitleComponent,
TooltipComponent,
LegendComponent,
TooltipComponent,
ToolboxComponent,
TitleComponent,
DataZoomComponent,
GridComponent,
CustomChart,
CanvasRenderer,
]);
const grid = {
show: true,
top: 70,
bottom: 20,
left: 100,
right: 20,
backgroundColor: '#fff',
borderWidth: 0,
};
export default {
name: 'Index',
components: {
Preloader,
VChart,
},
provide: {
// [THEME_KEY]: 'dark',
},
data() {
return {
isDataLoading: false,
service: new GanttService(),
option: {},
tasksRelationsMap: [],
totalRows: 0,
};
},
async created() {
await this.load();
},
mounted() {
window.addEventListener('resize', this.onResize);
this.websocketEnterChannel(this.user.id, {
updateAll: data => {
const id = this.$route.params[this.service.getIdParam()];
if (+id === +data.model.id) {
this.prepareAndSetData(data.model);
}
},
});
this.$refs.gantt.chart.on('click', { element: 'got_to_task_btn' }, params => {
this.$router.push({
name: 'Tasks.crud.tasks.view',
params: { id: params.data[dimensionIndex['id']] },
});
});
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize);
this.websocketLeaveChannel(this.user.id);
},
computed: {
...mapGetters('user', ['user']),
},
methods: {
getYAxisZoomPercentage() {
const chartHeight = this.$refs.gantt.chart.getHeight();
const canDraw = chartHeight / (ROW_HEIGHT * this.totalRows * 2); // multiply by 2 so rows not squashed together
return canDraw * 100;
},
onResize: debounce(
function () {
this.$refs.gantt.chart.resize();
this.$refs.gantt.chart.setOption({
dataZoom: { id: 'sliderY', start: 0, end: this.getYAxisZoomPercentage() },
});
},
50,
{
maxWait: 100,
},
),
load: debounce(async function () {
this.isDataLoading = true;
const ganttData = (await this.service.getGanttData(this.$route.params.id)).data.data;
this.prepareAndSetData(ganttData);
this.isDataLoading = false;
}, 100),
prepareAndSetData(ganttData) {
this.totalRows = ganttData.tasks.length;
const phasesMap = ganttData.phases
.filter(p => p.start_date && p.due_date)
.reduce((acc, phase) => {
phase.tasks = {
byStartDate: {},
byDueDate: {},
};
acc[phase.id] = phase;
return acc;
}, {});
const preparedRowsMap = {};
const preparedRows = ganttData.tasks.map((item, index) => {
const row = [index + 1].concat(...Object.values(item));
preparedRowsMap[item.id] = row;
if (phasesMap[item.project_phase_id]) {
const phaseTasks = phasesMap[item.project_phase_id].tasks;
if (phaseTasks.byStartDate[item.start_date]) {
phaseTasks.byStartDate[item.start_date].push(row);
} else {
phaseTasks.byStartDate[item.start_date] = [row];
}
if (phaseTasks.byDueDate[item.due_date]) {
phaseTasks.byDueDate[item.due_date].push(row);
} else {
phaseTasks.byDueDate[item.due_date] = [row];
}
}
return preparedRowsMap[item.id];
});
this.tasksRelationsMap = ganttData.tasks_relations.reduce((obj, relation) => {
const child = preparedRowsMap[relation.child_id];
if (Array.isArray(obj[relation.parent_id]) && child) {
obj[relation.parent_id].push(child);
} else {
obj[relation.parent_id] = [child];
}
return obj;
}, {});
const option = {
animation: false,
toolbox: {
left: 20,
top: 0,
itemSize: 20,
},
title: {
text: `${ganttData.name}`,
left: '4',
textAlign: 'left',
},
dataZoom: [
{
type: 'slider',
xAxisIndex: 0,
filterMode: 'none',
height: 20,
bottom: 0,
start: 0,
end: 100,
handleIcon:
'path://M10.7,11.9H9.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7V23h6.6V24.4z M13.3,19.6H6.7v-1.4h6.6V19.6z',
handleSize: '80%',
showDetail: true,
labelFormatter: getStartDate,
},
{
type: 'inside',
id: 'insideX',
xAxisIndex: 0,
filterMode: 'none',
start: 0,
end: 50,
zoomOnMouseWheel: false,
moveOnMouseMove: true,
},
{
type: 'slider',
id: 'sliderY',
filterMode: 'none',
yAxisIndex: 0,
width: 10,
right: 10,
top: 70,
bottom: 20,
start: 0,
end: this.getYAxisZoomPercentage(this.totalRows),
handleSize: 0,
showDetail: false,
},
{
type: 'inside',
id: 'insideY',
yAxisIndex: 0,
filterMode: 'none',
// startValue: 0,
// endValue: 10,
zoomOnMouseWheel: 'shift',
moveOnMouseMove: true,
moveOnMouseWheel: true,
},
],
grid,
xAxis: {
type: 'time',
position: 'top',
splitLine: {
lineStyle: {
color: ['#E9EDFF'],
},
},
axisLine: {
show: false,
},
axisTick: {
lineStyle: {
color: '#929ABA',
},
},
axisLabel: {
color: '#929ABA',
inside: false,
align: 'center',
},
},
yAxis: {
axisTick: {
show: false,
},
splitLine: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
show: false,
},
inverse: true,
// axisPointer: {
// show: true,
// type: 'line',
// data: [
// [40, -10],
// [-30, -5],
// [-76.5, 20],
// [-63.5, 40],
// [-22.1, 50],
// ]
// },
max: this.totalRows + 1,
},
tooltip: {
textStyle: {},
formatter: function (params) {
const getRow = (key, value) => `
<div style="display: inline-flex; width: 100%; justify-content: space-between; column-gap: 1rem; text-overflow: ellipsis;">
${key} <span style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden;" ><b>${value}</b></span>
</div>`;
const getWrapper = dimensionsToShow => `
<div style="display:flex; flex-direction: column; max-width: 280px;">
${dimensionsToShow.map(([title, value]) => getRow(title, value)).join('')}
</div>
`;
const prepareValues = accessor => {
const key = params.dimensionNames[Array.isArray(accessor) ? accessor[0] : accessor];
let value = Array.isArray(accessor)
? accessor[1](params.value[accessor[0]])
: params.value[accessor];
return [key, value];
};
if (params.seriesId === 'tasksData' || params.seriesId === 'tasksLabels') {
return getWrapper([
prepareValues(dimensionIndex.task_name),
prepareValues([dimensionIndex.status, v => v.name]),
prepareValues([dimensionIndex.priority, v => v.name]),
prepareValues([
dimensionIndex.estimate,
v => (v == null ? '—' : formatDurationString(v)),
]),
prepareValues([
dimensionIndex.total_spent_time,
v => (v == null ? '—' : formatDurationString(v)),
]),
prepareValues(dimensionIndex.start_date),
prepareValues(dimensionIndex.due_date),
]);
}
if (params.seriesId === 'phasesData') {
return getWrapper([
prepareValues(phaseDimensionIndex.name),
prepareValues(phaseDimensionIndex.start_date),
prepareValues(phaseDimensionIndex.due_date),
]);
}
return `${params.dataIndex}`;
},
},
series: [
{
id: 'tasksData',
type: 'custom',
renderItem: this.renderGanttItem,
dimensions: i18nDimensions,
encode: {
x: [dimensionIndex.start_date, dimensionIndex.due_date],
y: dimensionIndex.index,
},
data: preparedRows,
},
{
id: 'tasksLabels',
type: 'custom',
renderItem: this.renderAxisLabelItem,
dimensions: i18nDimensions,
encode: {
x: -1,
y: 0,
},
data: preparedRows,
},
{
id: 'phasesData',
type: 'custom',
dimensions: i18nPhaseDimensions,
renderItem: this.renderPhaseItem,
encode: {
x: [2, 3],
y: 4,
},
data: Object.values(phasesMap).map(phase => {
const startTaskIdx = phase.tasks.byStartDate[phase.start_date].reduce(
(minIndex, row) => Math.min(minIndex, row[dimensionIndex.index]),
Infinity,
);
const dueTaskId = phase.tasks.byDueDate[phase.due_date].reduce(
(maxIndex, row) => Math.max(maxIndex, row[dimensionIndex.index]),
null,
);
return [
phase.id,
phase.name,
phase.start_date,
phase.due_date,
startTaskIdx,
dueTaskId,
];
}),
},
],
};
const firstTaskDate = preparedRows[0] ? preparedRows[0][dimensionIndex.start_date] : null;
const lastTaskDate = preparedRows[preparedRows.length - 1]
? preparedRows[preparedRows.length - 1][dimensionIndex.start_date]
: null;
const today = moment();
if (
firstTaskDate &&
lastTaskDate &&
!today.isBefore(moment(firstTaskDate)) &&
!today.isAfter(moment(lastTaskDate))
) {
option.series.push({
id: 'currentDayLine',
type: 'custom',
encode: {
x: 0,
y: -1,
},
data: [getStartDate(today)],
renderItem: (params, api) => {
const todayCoord = api.coord([api.value(0), 0])[0];
const chartHeight = api.getHeight() - grid.bottom - grid.top;
const gridTop = params.coordSys.y;
const gridBottom = gridTop + chartHeight;
return {
type: 'line',
ignore: todayCoord < grid.left || todayCoord > api.getWidth() - grid.right,
shape: {
x1: todayCoord,
y1: gridTop,
x2: todayCoord,
y2: gridBottom,
},
style: {
stroke: 'rgba(255,0,0,0.3)',
lineWidth: 2,
},
silent: true,
};
},
});
}
const oldZoom = this.$refs.gantt.chart.getOption()?.dataZoom;
this.$refs.gantt.chart.setOption(option);
if (oldZoom) {
this.$refs.gantt.chart.setOption({
dataZoom: oldZoom,
});
}
},
renderGanttItem(params, api) {
let categoryIndex = api.value(dimensionIndex.index);
let startDate = api.coord([api.value(dimensionIndex.start_date), categoryIndex]);
let endDate = api.coord([api.value(dimensionIndex.due_date), categoryIndex]);
let barLength = endDate[0] - startDate[0];
// Get the height corresponds to length 1 on y axis.
let barHeight = api.size([0, 1])[1] * HEIGHT_RATIO;
barHeight = ROW_HEIGHT;
let x = startDate[0];
let y = startDate[1] - barHeight;
let barText = api.value(dimensionIndex.task_name);
let barTextWidth = echartsFormat.getTextRect(barText).width;
let rectNormal = this.clipRectByRect(params, {
x: x,
y: y,
width: barLength,
height: barHeight,
});
let estimate = +api.value(dimensionIndex.estimate);
estimate = isNaN(estimate) ? 0 : estimate;
let totalSpentTime = +api.value(dimensionIndex.total_spent_time);
totalSpentTime = isNaN(totalSpentTime) ? 0 : totalSpentTime;
let totalOffset = +api.value(dimensionIndex.total_offset);
totalOffset = isNaN(totalOffset) ? 0 : totalOffset;
const timeWithOffset = totalSpentTime + totalOffset;
let taskProgressLine = 0;
const multiplier = estimate > 0 ? timeWithOffset / estimate : 0;
if (estimate != null && estimate >= 0) {
taskProgressLine = barLength * multiplier;
}
let rectProgress = this.clipRectByRect(params, {
x: x,
y: y + barHeight * 0.15,
width: taskProgressLine > barLength ? barLength : taskProgressLine, // fill bar length
height: barHeight * 0.7,
});
let taskId = api.value(dimensionIndex.id);
const canvasWidth = api.getWidth() - grid.right;
const canvasHeight = api.getHeight() - grid.bottom;
let childrenLines = [];
this.tasksRelationsMap[taskId]?.forEach((childRowData, index) => {
let childStartDate = api.coord([
childRowData[dimensionIndex.start_date],
childRowData[dimensionIndex.index],
]);
let childY = childStartDate[1] - barHeight / 2;
// Start point at the end of the parent task
let startPoint = [endDate[0], endDate[1] - barHeight / 2];
if (startPoint[0] <= grid.left) {
startPoint[0] = grid.left;
startPoint[1] = childY; // if parent outside grid, don't draw line to the top
} else if (startPoint[0] >= canvasWidth) {
startPoint[0] = canvasWidth;
}
if (startPoint[1] <= grid.top) {
startPoint[1] = grid.top;
} else if (startPoint[1] >= canvasHeight) {
startPoint[1] = canvasHeight;
}
// Intermediate point, vertically aligned with the parent task end, but at the child task's y-level
let intermediatePoint = [endDate[0], childY];
if (intermediatePoint[0] <= grid.left) {
intermediatePoint[0] = grid.left;
} else if (intermediatePoint[0] >= canvasWidth) {
intermediatePoint[0] = canvasWidth;
}
if (intermediatePoint[1] <= grid.top) {
intermediatePoint[1] = grid.top;
} else if (intermediatePoint[1] >= canvasHeight) {
intermediatePoint[1] = canvasHeight;
}
// End point at the start of the child task
let endPoint = [childStartDate[0], childY];
if (endPoint[0] <= grid.left) {
endPoint[0] = grid.left;
} else if (endPoint[0] >= canvasWidth) {
endPoint[0] = canvasWidth;
}
if (endPoint[1] <= grid.top) {
endPoint[1] = grid.top;
} else if (endPoint[1] >= canvasHeight) {
endPoint[1] = canvasHeight;
endPoint[0] = endDate[0]; // if child outside grid, don't draw line to the right
}
const ignore =
endPoint[0] === grid.left ||
startPoint[0] === canvasWidth ||
endPoint[1] === grid.top ||
startPoint[1] === canvasHeight;
const childOrParentAreOutside =
startPoint[0] === grid.left ||
startPoint[1] === grid.top ||
endPoint[0] === canvasWidth ||
endPoint[1] === canvasHeight;
childrenLines.push({
type: 'polyline',
ignore: ignore,
silent: true,
shape: {
points: [startPoint, intermediatePoint, endPoint],
},
style: {
fill: 'transparent',
stroke: childOrParentAreOutside ? '#aaa' : '#333', // Line color
lineWidth: 1, // Line width
lineDash: childOrParentAreOutside ? [20, 3, 3, 3, 3, 3, 3, 3] : 'solid',
},
});
});
const rectTextShape = {
x: x + barLength + 5,
y: y + barHeight / 2,
width: barLength,
height: barHeight,
};
const textStyle = {
textFill: '#333',
width: 150,
height: barHeight,
text: barText,
textAlign: 'left',
textVerticalAlign: 'top',
lineHeight: 1,
fontSize: 12,
overflow: 'truncate',
elipsis: '...',
};
const progressPercentage = Number(multiplier).toLocaleString(this.$i18n.locale, {
style: 'percent',
minimumFractionDigits: 2,
});
const progressText =
multiplier === 0
? ''
: echartsFormat.truncateText(
`${progressPercentage}`,
rectNormal?.width ?? 0,
api.font({ fontSize: 12 }),
);
return {
type: 'group',
children: [
{
type: 'rect',
ignore: !rectNormal,
shape: rectNormal,
style: api.style({
fill: 'rgba(56,134,208,1)',
rectBorderWidth: 10,
text: progressText,
fontSize: 12,
}),
},
{
type: 'rect',
ignore: !rectProgress,
shape: rectProgress,
style: {
fill: 'rgba(0,55,111,.6)',
},
},
{
type: 'text',
ignore:
rectTextShape.x <= grid.left ||
rectTextShape.x > canvasWidth ||
rectTextShape.y <= grid.top + ROW_HEIGHT / 4 ||
rectTextShape.y >= canvasHeight - ROW_HEIGHT / 4,
clipPath: {
type: 'rect',
shape: {
x: 0,
y: 0 - ROW_HEIGHT / 2,
width: textStyle.width,
height: ROW_HEIGHT,
},
},
style: textStyle,
position: [rectTextShape.x, rectTextShape.y],
},
...childrenLines,
],
};
},
renderPhaseItem(params, api) {
let start = api.coord([
api.value(phaseDimensionIndex.start_date),
api.value(phaseDimensionIndex.first_task_id),
]);
let end = api.coord([
api.value(phaseDimensionIndex.due_date),
api.value(phaseDimensionIndex.last_task_id),
]);
const phaseHeight = ROW_HEIGHT / 3;
// Calculate the Y position for the phase, maybe above all tasks
let topY = start[1] - ROW_HEIGHT - phaseHeight - 5; // Determine how far above tasks you want to draw phases
if (topY <= grid.top) {
topY = grid.top;
}
// when phase approach its last task set y to task y
if (end[1] - ROW_HEIGHT - phaseHeight - 5 <= topY) {
topY = end[1] - ROW_HEIGHT - phaseHeight - 5;
}
let bottomY = topY + ROW_HEIGHT + phaseHeight + 5; // Determine the bottom Y based on the tasks' Y positions
if (bottomY >= api.getHeight() - grid.bottom) {
bottomY = api.getHeight() - grid.bottom;
}
// Phase rectangle
let rectShape = this.clipRectByRect(params, {
x: start[0],
y: topY,
width: end[0] - start[0],
height: phaseHeight, // Define the height of the phase rectangle
});
if (rectShape) {
rectShape.r = [5, 5, 0, 0];
}
const phaseName = echartsFormat.truncateText(
api.value(phaseDimensionIndex.name),
rectShape?.width ?? 0,
api.font({ fontSize: 14 }),
);
let rect = {
type: 'rect',
shape: rectShape,
ignore: !rectShape,
style: api.style({
fill: 'rgba(255,149,0,0.5)',
text: phaseName,
textStroke: 'rgb(181,106,0)',
}),
};
const lineWidth = 1;
let y1 = topY + phaseHeight;
if (y1 <= grid.top) {
y1 = grid.top;
}
// start vertical line
let startLine = {
type: 'line',
ignore:
bottomY <= grid.top ||
y1 >= api.getHeight() - grid.bottom ||
start[0] + lineWidth / 2 <= grid.left ||
start[0] >= api.getWidth() - grid.right,
shape: {
x1: start[0] + lineWidth / 2,
y1,
x2: start[0] + lineWidth / 2,
y2: bottomY,
},
style: api.style({
stroke: 'rgba(255,149,0,0.5)', // Example style
lineWidth,
lineDash: [3, 3, 4],
}),
};
// End vertical line
let endLine = {
type: 'line',
ignore:
bottomY <= grid.top ||
y1 >= api.getHeight() - grid.bottom ||
end[0] - lineWidth / 2 >= api.getWidth() - grid.right ||
end[0] <= grid.left,
shape: {
x1: end[0] - lineWidth / 2,
y1,
x2: end[0] - lineWidth / 2,
y2: bottomY,
},
style: api.style({
stroke: 'rgba(255,149,0,0.5)', // Example style
lineWidth,
lineDash: [3, 3, 4],
}),
};
return {
type: 'group',
children: [rect, startLine, endLine],
};
},
renderAxisLabelItem(params, api) {
const y = api.coord([0, api.value(0)])[1];
const isOutside = y <= 70 || y > api.getHeight();
return {
type: 'group',
position: [10, y],
ignore: isOutside,
children: [
{
type: 'path',
shape: {
d: 'M 0 0 L 0 -20 C 20.3333 -20 40.6667 -20 52 -20 C 64 -20 65 -2 70 -2 L 70 0 Z',
x: 12,
y: -ROW_HEIGHT,
width: 78,
height: ROW_HEIGHT,
layout: 'cover',
},
style: {
fill: '#368c6c',
},
},
{
type: 'text',
style: {
x: 15,
y: -3,
width: 80,
text: api.value(dimensionIndex.task_name),
textVerticalAlign: 'bottom',
textAlign: 'left',
textFill: '#fff',
overflow: 'truncate',
},
},
{
type: 'group',
name: 'got_to_task_btn',
children: [
{
type: 'rect',
shape: {
x: -10,
y: -ROW_HEIGHT,
width: ROW_HEIGHT,
height: ROW_HEIGHT,
layout: 'center',
},
style: {
fill: '#5988E5',
},
},
{
type: 'path',
shape: {
d: 'M15.7285 3.88396C17.1629 2.44407 19.2609 2.41383 20.4224 3.57981C21.586 4.74798 21.5547 6.85922 20.1194 8.30009L17.6956 10.7333C17.4033 11.0268 17.4042 11.5017 17.6976 11.794C17.9911 12.0863 18.466 12.0854 18.7583 11.7919L21.1821 9.35869C23.0934 7.43998 23.3334 4.37665 21.4851 2.5212C19.6346 0.663551 16.5781 0.905664 14.6658 2.82536L9.81817 7.69182C7.90688 9.61053 7.66692 12.6739 9.51519 14.5293C9.80751 14.8228 10.2824 14.8237 10.5758 14.5314C10.8693 14.2391 10.8702 13.7642 10.5779 13.4707C9.41425 12.3026 9.44559 10.1913 10.8809 8.75042L15.7285 3.88396Z M14.4851 9.47074C14.1928 9.17728 13.7179 9.17636 13.4244 9.46868C13.131 9.76101 13.1301 10.2359 13.4224 10.5293C14.586 11.6975 14.5547 13.8087 13.1194 15.2496L8.27178 20.1161C6.83745 21.556 4.73937 21.5863 3.57791 20.4203C2.41424 19.2521 2.44559 17.1408 3.88089 15.6999L6.30473 13.2667C6.59706 12.9732 6.59614 12.4984 6.30268 12.206C6.00922 11.9137 5.53434 11.9146 5.24202 12.2081L2.81818 14.6413C0.906876 16.5601 0.666916 19.6234 2.51519 21.4789C4.36567 23.3365 7.42221 23.0944 9.33449 21.1747L14.1821 16.3082C16.0934 14.3895 16.3334 11.3262 14.4851 9.47074Z',
x: -10 * 0.8,
y: -ROW_HEIGHT * 0.9,
width: ROW_HEIGHT * 0.8,
height: ROW_HEIGHT * 0.8,
layout: 'center',
},
style: {
fill: '#ffffff',
},
},
],
},
],
};
},
clipRectByRect(params, rect) {
return echartsGraphic.clipRectByRect(rect, {
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height,
});
},
websocketLeaveChannel(userId) {
this.$echo.leave(`gantt.${userId}`);
},
websocketEnterChannel(userId, handlers) {
const channel = this.$echo.private(`gantt.${userId}`);
for (const action in handlers) {
channel.listen(`.gantt.${action}`, handlers[action]);
}
},
},
};
</script>
<style lang="scss" scoped>
.gantt {
height: calc(100vh - 75px * 2);
width: 100%;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="invite-form">
<validation-observer ref="form">
<div v-for="(user, index) in users" :key="index" class="row invite-form__group">
<validation-provider v-slot="{ errors }" :vid="`users.${index}.email`" class="col-14">
<at-input
v-model="users[index]['email']"
:placeholder="$t('field.email')"
:status="errors.length > 0 ? 'error' : ''"
>
<template slot="prepend">{{ $t('field.email') }}</template>
</at-input>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider :vid="`users.${index}.role_id`" class="col-6">
<role-select v-model="users[index]['role_id']"></role-select>
</validation-provider>
<at-button v-if="index > 0" class="col-2 invite-form__remove" @click="removeUser(index)"
><i class="icon icon-x"></i
></at-button>
</div>
</validation-observer>
<at-button type="default" size="small" class="col-4" @click="handleAdd">{{ $t('control.add') }}</at-button>
</div>
</template>
<script>
import { ValidationObserver, ValidationProvider } from 'vee-validate';
import RoleSelect from '@/components/RoleSelect';
export default {
name: 'InviteInput',
components: {
ValidationObserver,
ValidationProvider,
RoleSelect,
},
props: {
value: {
type: [Array, Object],
},
},
data() {
return {
users: [
{
email: null,
role_id: 2,
},
],
};
},
mounted() {
this.$emit('input', this.users);
},
methods: {
handleAdd() {
this.users.push({ email: null, role_id: 2 });
},
removeUser(index) {
this.users.splice(index, 1);
},
},
watch: {
users(value) {
this.$emit('input', value);
},
},
};
</script>
<style lang="scss" scoped>
.invite-form {
&__group {
margin-bottom: 1rem;
}
&__remove {
max-height: 40px;
}
}
</style>

View File

@@ -0,0 +1,9 @@
{
"invitations": {
"grid-title": "Invitations",
"crud-title": "Invitation"
},
"navigation": {
"invitations": "Invitations"
}
}

View File

@@ -0,0 +1,9 @@
{
"invitations": {
"grid-title": "Приглашения",
"crud-title": "Приглашение"
},
"navigation": {
"invitations": "Приглашения"
}
}

View File

@@ -0,0 +1,16 @@
export const ModuleConfig = {
routerPrefix: 'settings',
loadOrder: 10,
moduleName: 'Invitations',
};
export function init(context, router) {
context.addCompanySection(require('./sections/invitations').default(context, router));
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,136 @@
import Vue from 'vue';
import cloneDeep from 'lodash/cloneDeep';
import { store } from '@/store';
import i18n from '@/i18n';
import { formatDate } from '@/utils/time';
import InvitationService from '../services/invitation.service';
import InvitationForm from '../components/InvitationForm';
import Invitations from '../views/Invitations';
import { hasRole } from '@/utils/user';
export function fieldsToFillProvider() {
return [
{
label: 'field.users',
key: 'users',
required: true,
render: (h, props) => {
return h(InvitationForm, {
props: {
value: props.currentValue,
},
on: {
input(value) {
props.inputHandler(value);
},
},
});
},
},
];
}
export const config = { fieldsToFillProvider };
export default (context, router) => {
const invitationsContext = cloneDeep(context);
invitationsContext.routerPrefix = 'company/invitations';
const crud = invitationsContext.createCrud('invitations.crud-title', 'invitations', InvitationService);
const crudNewRoute = crud.new.getNewRouteName();
const navigation = { new: crudNewRoute };
crud.new.addToMetaProperties('permissions', 'invitations/create', crud.new.getRouterConfig());
crud.new.addToMetaProperties('navigation', navigation, crud.new.getRouterConfig());
crud.new.addToMetaProperties('afterSubmitCallback', () => router.go(-1), crud.new.getRouterConfig());
const grid = invitationsContext.createGrid('invitations.grid-title', 'invitations', InvitationService);
grid.addToMetaProperties('navigation', navigation, grid.getRouterConfig());
grid.addToMetaProperties('permissions', () => hasRole(store.getters['user/user'], 'admin'), grid.getRouterConfig());
const fieldsToFill = config.fieldsToFillProvider();
crud.new.addField(fieldsToFill);
grid.addColumn([
{
title: 'field.email',
key: 'email',
},
{
title: 'field.expires_at',
key: 'expires_at',
render(h, { item }) {
const expiresAt = formatDate(item.expires_at);
return h('span', [expiresAt]);
},
},
]);
grid.addFilter([
{
filterName: 'filter.fields.email',
referenceKey: 'email',
},
]);
grid.addAction([
{
title: 'invite.resend',
actionType: 'primary',
icon: 'icon-refresh-ccw',
onClick: async (router, { item }, context) => {
const invitationService = new InvitationService();
try {
await invitationService.resend(item.id);
context.fetchData();
context.$Message.success(i18n.t('message.success'));
} catch (e) {
//
}
},
},
{
title: 'control.delete',
actionType: 'error',
icon: 'icon-trash-2',
onClick: (router, { item }, context) => {
context.onDelete(item);
},
},
]);
grid.addPageControls([
{
label: 'control.create',
type: 'primary',
icon: 'icon-edit',
onClick: ({ $router }) => {
$router.push({ name: crudNewRoute });
},
},
]);
return {
accessCheck: async () => Vue.prototype.$can('viewAny', 'invitation'),
scope: 'company',
order: 20,
component: Invitations,
route: {
name: 'Invitations.crud.invitations',
path: '/company/invitations',
meta: {
label: 'navigation.invitations',
service: new InvitationService(),
},
children: [
{
...grid.getRouterConfig(),
path: '',
},
...crud.getRouterConfig(),
],
},
};
};

View File

@@ -0,0 +1,24 @@
import ResourceService from '@/services/resource.service';
import axios from 'axios';
export default class InvitationService extends ResourceService {
getAll(config = {}) {
return axios.get('invitations/list', config);
}
save(data, isNew = false) {
return axios.post(`invitations/${isNew ? 'create' : 'edit'}`, data);
}
resend(id) {
return axios.post('invitations/resend', { id });
}
deleteItem(id) {
return axios.post('invitations/remove', { id });
}
getWithFilters(filters, config = {}) {
return axios.post('invitations/list', filters, config);
}
}

View File

@@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>

View File

@@ -0,0 +1,402 @@
<template>
<div class="container">
<h1 class="page-title">{{ $t('navigation.offline_sync') }}</h1>
<div class="at-container offline-sync">
<div class="row">
<div class="col-8">
<h2 class="page-title">{{ $t('offline_sync.projects_and_tasks') }}</h2>
<validation-observer ref="form" v-slot="{}">
<validation-provider
ref="user_select"
v-slot="{ errors }"
rules="required"
:name="$t('offline_sync.user')"
mode="passive"
>
<small>{{ $t('offline_sync.user') }}</small>
<resource-select
v-model="userId"
class="input"
:service="usersService"
:class="{ 'at-select--error': errors.length > 0 }"
/>
<p>{{ errors[0] }}</p>
</validation-provider>
</validation-observer>
<at-button
class="offline-sync__upload-btn"
size="large"
icon="icon-download"
type="primary"
@click="exportTasks"
>{{ $t('offline_sync.export') }}
</at-button>
</div>
</div>
<div class="row">
<div class="col-8">
<h2 class="page-title">{{ $t('offline_sync.intervals') }}</h2>
<validation-observer ref="form" v-slot="{}">
<validation-provider
ref="intervals_file"
v-slot="{ errors }"
:rules="`required|ext:cattr|size:${12 * 1024}`"
:name="$t('offline_sync.intervals_file')"
mode="passive"
>
<at-input
ref="intervals_file_input"
class="intervals-input"
name="intervals-file"
type="file"
/>
<p>{{ errors[0] }}</p>
</validation-provider>
</validation-observer>
<at-button
class="offline-sync__upload-btn"
size="large"
icon="icon-upload"
type="primary"
@click="uploadIntervals"
>{{ $t('offline_sync.import') }}
</at-button>
</div>
</div>
<div class="row">
<div class="offline-sync__added col-24">
<h5>{{ $t('offline_sync.added_intervals') }}</h5>
<at-table :columns="intervalsColumns" :data="addedIntervals"></at-table>
</div>
</div>
<div class="row">
<div class="col-8">
<h2 class="page-title">{{ $t('offline_sync.screenshots') }}</h2>
<validation-observer ref="form" v-slot="{}">
<validation-provider
ref="screenshots_file"
v-slot="{ errors }"
:rules="`required|ext:cattr`"
:name="$t('offline_sync.screenshots_file')"
mode="passive"
>
<at-input
ref="screenshots_file_input"
class="screenshots-input"
name="screenshots-file"
type="file"
/>
<p>{{ errors[0] }}</p>
</validation-provider>
</validation-observer>
<at-button
class="offline-sync__upload-btn"
size="large"
icon="icon-upload"
type="primary"
@click="uploadScreenshots"
>{{ $t('offline_sync.import') }}
</at-button>
<div v-if="screenshotsUploadProgress != null" class="screenshots-upload-progress">
<at-progress :percent="screenshotsUploadProgress.progress" :stroke-width="15" />
<span class="screenshots-upload-progress__total">{{
screenshotsUploadProgress.humanReadable
}}</span>
<span class="screenshots-upload-progress__speed">{{ screenshotsUploadProgress.speed }}</span>
</div>
</div>
</div>
<div class="row">
<div class="offline-sync__added col-24">
<h5>{{ $t('offline_sync.added_screenshots') }}</h5>
<at-table :columns="screenshotsColumns" :data="addedScreenshots"></at-table>
</div>
</div>
</div>
</div>
</template>
<script>
import { ValidationObserver, ValidationProvider } from 'vee-validate';
import OfflineSyncService from '../services/offline-sync.service';
import { formatDurationString } from '@/utils/time';
import moment from 'moment';
import ResourceSelect from '@/components/ResourceSelect.vue';
import UsersService from '@/services/resource/user.service';
import { humanFileSize } from '@/utils/file';
const formatImportResultMessage = (h, params) => {
const getResultIcon = () => {
return h('i', {
class: {
icon: true,
'icon-x-circle': !params.item.success,
'icon-check-circle': params.item.success,
},
});
};
return typeof params.item.message === 'string'
? [h('span', [getResultIcon(h, params), params.item.message])]
: Object.entries(params.item.message).map(([key, msg]) =>
h('span', [getResultIcon(h, params), `${key}: ${msg}`]),
);
};
export default {
name: 'Page',
components: {
ResourceSelect,
ValidationObserver,
ValidationProvider,
},
data() {
return {
addedIntervals: [],
addedScreenshots: [],
service: new OfflineSyncService(),
intervalsColumns: [
{
title: this.$t('offline_sync.user'),
render: (h, params) => {
return h(
'a',
{
attrs: {
href: `mailto:${params.item.user.email}`,
target: '_blank',
},
},
params.item.user.full_name,
);
},
},
{
title: this.$t('offline_sync.task_id'),
render: (h, params) => {
return h(
'router-link',
{
props: {
to: {
name: `Tasks.crud.tasks.view`,
params: { id: params.item.task_id },
},
},
},
params.item.task_id,
);
},
},
{
title: this.$t('offline_sync.start_at'),
key: 'start_at',
},
{
title: this.$t('offline_sync.end_at'),
key: 'end_at',
},
{
title: this.$t('offline_sync.total_time'),
key: 'total_time',
},
{
title: this.$t('offline_sync.result'),
render: (h, params) => {
return h(
'div',
{ class: 'offline-sync__import-result' },
formatImportResultMessage(h, params),
);
},
},
],
screenshotsColumns: [
{
title: this.$t('offline_sync.user'),
render: (h, params) => {
return h('span', params.item?.user_id ?? '-');
},
},
{
title: this.$t('offline_sync.task_id'),
render: (h, params) => {
return h(
params.item?.task_id ? 'router-link' : 'span',
{
props: {
to: {
name: `Tasks.crud.tasks.view`,
params: { id: params.item?.task_id },
},
},
},
params.item?.task_id ?? '-',
);
},
},
{
title: this.$t('offline_sync.start_at'),
key: 'start_at',
},
{
title: this.$t('offline_sync.end_at'),
key: 'end_at',
},
{
title: this.$t('offline_sync.total_time'),
key: 'total_time',
},
{
title: this.$t('offline_sync.result'),
render: (h, params) => {
return h(
'div',
{ class: 'offline-sync__import-result' },
formatImportResultMessage(h, params),
);
},
},
],
userId: null,
usersService: new UsersService(),
screenshotsUploadProgress: null,
};
},
methods: {
async exportTasks() {
const { valid } = await this.$refs.user_select.validate(this.userId);
if (valid) {
const result = await this.service.download(this.userId);
const blob = new Blob([result]);
const aElement = document.createElement('a');
aElement.setAttribute('download', 'ProjectsAndTasks.cattr');
const href = URL.createObjectURL(blob);
aElement.href = href;
aElement.setAttribute('target', '_blank');
aElement.click();
URL.revokeObjectURL(href);
}
},
async uploadIntervals() {
const file = this.$refs.intervals_file_input.$el.querySelector('input').files[0];
const { valid } = await this.$refs.intervals_file.validate(file);
if (valid) {
const result = await this.service.uploadIntervals(file);
if (result.success) {
this.addedIntervals = result.data.map(el => {
const timeDiff = moment(el.interval['end_at']).diff(moment(el.interval['start_at'])) / 1000;
const totalTime = Math.round((timeDiff + Number.EPSILON) * 100) / 100;
return {
...el.interval,
message: el.message,
success: el.success,
total_time: formatDurationString(totalTime),
};
});
} else {
this.addedIntervals = [];
}
}
},
async uploadScreenshots() {
const file = this.$refs.screenshots_file_input.$el.querySelector('input').files[0];
const { valid } = await this.$refs.screenshots_file.validate(file);
if (valid) {
const result = await this.service.uploadScreenshots(file, this.onUploadProgress.bind(this));
if (result.success) {
this.addedScreenshots = result.data.map(el => {
const timeDiff = el.interval
? moment(el.interval['end_at']).diff(moment(el.interval['start_at'])) / 1000
: '-';
const totalTime = el.interval ? Math.round((timeDiff + Number.EPSILON) * 100) / 100 : '-';
return {
...el.interval,
message: el.message,
success: el.success,
total_time: el.interval ? formatDurationString(totalTime) : '-',
};
});
} else {
this.addedScreenshots = [];
}
}
},
onUploadProgress(progressEvent) {
this.screenshotsUploadProgress = {
progress: +(progressEvent.progress * 100).toFixed(2),
loaded: progressEvent.loaded,
total: progressEvent.total,
humanReadable: `${humanFileSize(progressEvent.loaded, true)} / ${humanFileSize(
progressEvent.total,
true,
)}`,
speed: `${progressEvent.rate ? humanFileSize(progressEvent.rate, true) : '0 kB'}/s`,
};
},
},
};
</script>
<style scoped lang="scss">
.offline-sync {
padding: 1rem 1.5rem 2.5rem;
.row {
margin-bottom: $spacing-05;
}
.intervals-input {
width: 100%;
}
&__upload-btn {
margin-top: $spacing-03;
}
&::v-deep {
.page-title {
color: $gray-1;
font-size: 24px;
margin-bottom: 0;
}
.icon {
margin-right: 0.2rem;
}
.icon-x-circle {
color: $red-1;
}
.icon-check-circle {
color: $green-1;
}
.offline-sync__import-result {
display: flex;
flex-direction: column;
}
}
&__added {
margin-top: $spacing-03;
}
.screenshots-upload-progress {
margin-top: 0.5rem;
font-size: 0.8rem;
&::v-deep .at-progress {
display: flex;
align-items: end;
&-bar {
flex-basis: 70%;
}
}
}
}
</style>

View File

@@ -0,0 +1,22 @@
{
"navigation": {
"offline_sync": "Offline Sync"
},
"offline_sync": {
"projects_and_tasks": "Projects and Tasks",
"intervals": "Intervals",
"screenshots": "Screenshots",
"intervals_file": "intervals file",
"screenshots_file": "screenshots file",
"import": "Import",
"export": "Export",
"added_intervals": "Added intervals",
"added_screenshots": "Added screenshots",
"user": "User",
"task_id": "Task",
"start_at": "Start",
"end_at": "End",
"result": "Result",
"total_time": "Duration"
}
}

View File

@@ -0,0 +1,21 @@
{
"navigation": {
"offline_sync": "Оффлайн Синхронизация"
},
"offline_sync": {
"projects_and_tasks": "Проекты и Задачи",
"intervals": "Интервалы",
"intervals_file": "файл интервалов",
"screenshots_file": "файл скриншотов",
"import": "Импортировать",
"export": "Экспортировать",
"added_intervals": "Добавленные интервалы",
"added_screenshots": "Добавленные скриншоты",
"user": "Пользователь",
"task_id": "Задача",
"start_at": "От",
"end_at": "До",
"result": "Результат",
"total_time": "Продолжительность"
}
}

View File

@@ -0,0 +1,30 @@
export const ModuleConfig = {
routerPrefix: 'offline-sync',
loadOrder: 11,
moduleName: 'OfflineSync',
};
export function init(context) {
context.addUserMenuEntry({
label: 'navigation.offline_sync',
icon: 'icon-wifi-off',
to: {
name: 'offline-sync',
},
});
context.addRoute({
path: '/offline-sync',
name: 'offline-sync',
component: () => import(/* webpackChunkName: "offline-sync" */ './components/Page.vue'),
meta: {
auth: true,
},
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,51 @@
import axios from '@/config/app';
export default class OfflineSyncService {
/**
* API endpoint URL
* @returns string
*/
getItemRequestUri() {
return `offline-sync`;
}
/**
* @param user_id
* @returns {Promise<AxiosResponse<T>>}
*/
async download(user_id) {
const { data } = await axios.get(this.getItemRequestUri() + `/download-projects-and-tasks/${user_id}`, {
responseType: 'blob',
});
return data;
}
/**
* Upload Intervals.cattr file
* @returns {Promise<void>}
* @param payload
*/
async uploadIntervals(payload) {
const formData = new FormData();
formData.append('file', payload);
const { data } = await axios.post(this.getItemRequestUri() + '/upload-intervals', formData);
return data;
}
/**
* Upload Screenshots.cattr file
* @returns {Promise<void>}
* @param payload
* @param progressCallback
*/
async uploadScreenshots(payload, progressCallback) {
const formData = new FormData();
formData.append('file', payload);
const { data } = await axios.post(this.getItemRequestUri() + '/upload-screenshots', formData, {
onUploadProgress: progressCallback,
});
return data;
}
}

View File

@@ -0,0 +1,12 @@
{
"navigation": {
"planned-time-report": "Planned Time Report"
},
"planned-time-report": {
"tasks": "Tasks",
"estimate": "Estimate",
"spent": "Spent",
"productivity": "Productivity",
"report_date": "Report created at: {0}"
}
}

View File

@@ -0,0 +1,12 @@
{
"navigation": {
"planned-time-report": "Отчет по запланированному времени"
},
"planned-time-report": {
"tasks": "Задачи",
"estimate": "План",
"spent": "Факт",
"productivity": "Продуктивность",
"report_date": "Отчёт создан: {0}"
}
}

View File

@@ -0,0 +1,31 @@
export const ModuleConfig = {
routerPrefix: 'planned-time-report',
loadOrder: 40,
moduleName: 'PlannedTimeReport',
};
export function init(context) {
context.addRoute({
path: '/report/planned-time',
name: 'report.planned-time',
component: () => import(/* webpackChunkName: "report.plannedtime" */ './views/PlannedTimeReport.vue'),
meta: {
auth: true,
},
});
context.addNavbarEntryDropDown({
label: 'navigation.planned-time-report',
section: 'navigation.dropdown.reports',
to: {
name: 'report.planned-time',
},
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,29 @@
import axios from 'axios';
import ReportService from '@/services/report.service';
export default class PlannedTimeReport extends ReportService {
/**
* @returns {Promise<AxiosResponse<T>>}
* @param projects
*/
getReport(projects) {
return axios.post('report/planned-time', {
projects,
});
}
/**
* @returns {Promise<AxiosResponse<T>>}
* @param projects
* @param format
*/
downloadReport(projects, format) {
return axios.post(
`report/planned-time/download`,
{ projects },
{
headers: { Accept: format },
},
);
}
}

View File

@@ -0,0 +1,133 @@
<template>
<div class="project-reports">
<h1 class="page-title">{{ $t('navigation.planned-time-report') }}</h1>
<div class="controls-row">
<ProjectSelect class="controls-row__item" @change="onProjectsChange" />
<div class="controls-row__item controls-row__item--left-auto">
<small v-if="reportDate">
{{ $t('planned-time-report.report_date', [reportDate]) }}
</small>
</div>
<ExportDropdown
class="export-btn dropdown controls-row__btn controls-row__item"
position="left-top"
trigger="hover"
@export="onExport"
/>
</div>
<div class="at-container">
<div class="total-time-row">
<span class="total-time-label">{{ $t('field.total_time') }}</span>
<span class="total-time-value">{{ totalTime }}</span>
</div>
<div v-if="Object.keys(projects).length && !isDataLoading">
<ProjectLine v-for="project in projects" :key="project.id" :project="project" />
</div>
<div v-else class="at-container__inner no-data">
<preloader v-if="isDataLoading" />
<span>{{ $t('message.no_data') }}</span>
</div>
</div>
</div>
</template>
<script>
import PlannedTimeReport from '../service/planned-time-report';
import ProjectLine from './PlannedTimeReport/ProjectLine';
import { formatDurationString } from '@/utils/time';
import ProjectSelect from '@/components/ProjectSelect';
import Preloader from '@/components/Preloader';
import ExportDropdown from '@/components/ExportDropdown';
import { mapGetters } from 'vuex';
import debounce from 'lodash.debounce';
const reportService = new PlannedTimeReport();
export default {
name: 'PlannedTimeReport',
components: {
ProjectLine,
ProjectSelect,
Preloader,
ExportDropdown,
},
data() {
return {
isDataLoading: false,
projects: [],
reportDate: null,
projectsList: [],
projectReportsList: {},
userIds: [],
};
},
computed: {
...mapGetters('user', ['companyData']),
totalTime() {
return formatDurationString(this.projects.reduce((acc, proj) => acc + proj.total_spent_time, 0));
},
},
methods: {
onProjectsChange(projectIDs) {
this.projectsList = projectIDs;
this.fetchData();
},
fetchData: debounce(async function () {
this.isDataLoading = true;
try {
const { data } = await reportService.getReport(this.projectsList);
this.$set(this, 'projects', data.data.reportData);
this.$set(this, 'reportDate', data.data.reportDate);
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to projects is canceled');
}
}
this.isDataLoading = false;
}, 350),
async onExport(format) {
try {
const { data } = await reportService.downloadReport(this.projectsList, format);
window.open(data.data.url, '_blank');
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.log(response ? response : 'request to reports is canceled');
}
}
},
},
};
</script>
<style lang="scss" scoped>
.at-container {
overflow: hidden;
}
.total-time-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 21px;
color: $black-900;
font-size: 2rem;
font-weight: bold;
}
.no-data {
text-align: center;
font-weight: bold;
position: relative;
}
.project-select {
width: 240px;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="project">
<div class="project__header">
<div class="row flex-between">
<h1 class="project__title">{{ project.name }}</h1>
<span class="h3">{{ formatDurationString(project.total_spent_time) }}</span>
</div>
<div class="row">
<div class="col-12">
<span class="h3">{{ $t('planned-time-report.tasks') }}</span>
</div>
<div class="col-4">
<span class="h3">{{ $t('planned-time-report.estimate') }}</span>
</div>
<div class="col-4">
<span class="h3">{{ $t('planned-time-report.spent') }}</span>
</div>
<div class="col-4">
<span class="h3">{{ $t('planned-time-report.productivity') }}</span>
</div>
</div>
</div>
<at-collapse accordion class="list__item" simple>
<at-collapse-item v-for="task in project.tasks" :key="task.id" :name="`${task.id}`" class="task list__item">
<div slot="title" class="row flex-middle">
<div class="col-12">
<span class="h4">{{ task.task_name }}</span>
<div class="task__tags">
<at-tag v-if="isOverDue(companyData.timezone, task)" color="error"
>{{ $t('tasks.due_date--overdue') }}
</at-tag>
<at-tag v-if="isOverTime(task)" color="warning"
>{{ $t('tasks.estimate--overtime') }}
</at-tag>
</div>
</div>
<div class="col-4">
<span v-if="task.estimate > 0" class="h4">{{ formatDurationString(task.estimate) }}</span>
</div>
<div class="col-4">
<span class="h4">{{ formatDurationString(task.total_spent_time) }}</span>
</div>
<div class="col-4 task__progress">
<at-progress
:percent="getPercentageForProgress(task.total_spent_time, task.estimate)"
:status="getProgressStatus(task)"
:stroke-width="20"
class="flex flex-middle"
/>
<span class="task__progress-percent"
>{{ getPercentage(task.total_spent_time, task.estimate) }}%</span
>
</div>
</div>
<div class="row workers">
<div v-for="worker in task.workers" :key="`${task.id}-${worker.id}`" class="col-24">
<div slot="title" class="row">
<div class="col-1">
<user-avatar :size="avatarSize" :user="worker.user" />
</div>
<div class="col-15">
<span class="h5">{{ worker.user.full_name }}</span>
</div>
<div class="col-4">
<span class="h4">{{ formatDurationString(worker.duration) }}</span>
</div>
<div class="col-4">
<at-progress
:percent="getPercentageForProgress(worker.duration, task.total_spent_time)"
:stroke-width="15"
status="success"
/>
</div>
</div>
</div>
</div>
</at-collapse-item>
</at-collapse>
</div>
</template>
<script>
import moment from 'moment-timezone';
import UserAvatar from '@/components/UserAvatar';
import { mapGetters } from 'vuex';
import { formatDurationString } from '@/utils/time';
export default {
name: 'ProjectLine',
components: {
UserAvatar,
},
data() {
return {
openedDates: [],
avatarSize: 35,
screenshotsPerRow: 6,
userTimezone: moment.tz.guess(),
};
},
props: {
project: {
type: Object,
required: true,
},
},
computed: {
...mapGetters('user', ['companyData']),
},
methods: {
moment,
formatDurationString,
formatDate(value) {
return moment(value).format('DD.MM.YYYY HH:mm:ss');
},
getPercentage(seconds, totalTime) {
if (!totalTime || !seconds) {
return 0;
}
return ((seconds * 100) / totalTime).toFixed(2);
},
getPercentageForProgress(seconds, totalTime) {
const percent = this.getPercentage(seconds, totalTime);
// 99.99% is used coz at-percent component will change status to success for 100%
return percent >= 100 ? 99.99 : +percent;
},
isOverDue(companyTimezone, item) {
return (
typeof companyTimezone === 'string' &&
item.due_date != null &&
moment.utc(item.due_date).tz(companyTimezone, true).isBefore(moment())
);
},
isOverTime(item) {
return item.estimate != null && item.total_spent_time > item.estimate;
},
getProgressStatus(item) {
if (this.isOverTime(item) || this.isOverDue(this.companyData.timezone, item)) {
return 'error';
}
return 'success';
},
},
};
</script>
<style lang="scss" scoped>
.project {
&__header {
display: flex;
flex-direction: column;
border-bottom: none;
padding: 14px 21px;
row-gap: 1rem;
border-bottom: 3px solid $blue-3;
}
&__title {
color: $black-900;
font-size: 2rem;
font-weight: bold;
}
.workers {
row-gap: 1rem;
margin-top: 0.5rem;
margin-bottom: 1rem;
}
}
.task {
&__tags {
display: flex;
column-gap: 0.3rem;
}
&__progress {
position: relative;
}
&__progress-percent {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1rem;
color: #fff;
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
}
}
</style>

View File

@@ -0,0 +1,12 @@
{
"navigation": {
"priorities": "Priorities"
},
"priorities": {
"grid-title": "Priorities",
"crud-title": "Priority"
},
"field": {
"color": "Color"
}
}

View File

@@ -0,0 +1,12 @@
{
"navigation": {
"priorities": "Приоритеты"
},
"priorities": {
"grid-title": "Приоритеты",
"crud-title": "Приоритет"
},
"field": {
"color": "Цвет"
}
}

View File

@@ -0,0 +1,16 @@
export const ModuleConfig = {
routerPrefix: 'settings',
loadOrder: 10,
moduleName: 'Priorities',
};
export function init(context, router) {
context.addCompanySection(require('./sections/priorities').default(context, router));
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,153 @@
import cloneDeep from 'lodash/cloneDeep';
import { store } from '@/store';
import PriorityService from '../services/priority.service';
import Priorities from '../views/Priorities';
import ColorInput from '@/components/ColorInput';
import { hasRole } from '@/utils/user';
export default (context, router) => {
const prioritiesContext = cloneDeep(context);
prioritiesContext.routerPrefix = 'company/priorities';
const crud = prioritiesContext.createCrud('priorities.crud-title', 'priorities', PriorityService);
const crudEditRoute = crud.edit.getEditRouteName();
const crudNewRoute = crud.new.getNewRouteName();
const navigation = { edit: crudEditRoute, new: crudNewRoute };
crud.new.addToMetaProperties('permissions', 'priorities/create', crud.new.getRouterConfig());
crud.new.addToMetaProperties('navigation', navigation, crud.new.getRouterConfig());
crud.new.addToMetaProperties('afterSubmitCallback', () => router.go(-1), crud.new.getRouterConfig());
crud.edit.addToMetaProperties('permissions', 'priorities/edit', crud.edit.getRouterConfig());
const grid = prioritiesContext.createGrid('priorities.grid-title', 'priorities', PriorityService);
grid.addToMetaProperties('navigation', navigation, grid.getRouterConfig());
grid.addToMetaProperties('permissions', () => hasRole(store.getters['user/user'], 'admin'), grid.getRouterConfig());
const fieldsToFill = [
{
key: 'id',
displayable: false,
},
{
label: 'field.name',
key: 'name',
type: 'input',
required: true,
placeholder: 'field.name',
},
{
label: 'field.color',
key: 'color',
required: false,
render: (h, data) => {
return h(ColorInput, {
props: {
value: typeof data.currentValue === 'string' ? data.currentValue : 'transparent',
},
on: {
change(value) {
data.inputHandler(value);
},
},
});
},
},
];
crud.edit.addField(fieldsToFill);
crud.new.addField(fieldsToFill);
grid.addColumn([
{
title: 'field.name',
key: 'name',
},
{
title: 'field.color',
key: 'color',
render(h, { item }) {
return h(
'span',
{
style: {
display: 'flex',
alignItems: 'center',
},
},
[
h('span', {
style: {
display: 'inline-block',
background: item.color,
borderRadius: '4px',
width: '16px',
height: '16px',
margin: '0 4px 0 0',
},
}),
h('span', {}, [item.color]),
],
);
},
},
]);
grid.addAction([
{
title: 'control.edit',
icon: 'icon-edit',
onClick: (router, { item }, context) => {
context.onEdit(item);
},
renderCondition: ({ $can }, item) => {
return $can('update', 'priority', item);
},
},
{
title: 'control.delete',
actionType: 'error',
icon: 'icon-trash-2',
onClick: async (router, { item }, context) => {
context.onDelete(item);
},
renderCondition: ({ $can }, item) => {
return $can('delete', 'priority', item);
},
},
]);
grid.addPageControls([
{
label: 'control.create',
type: 'primary',
icon: 'icon-edit',
onClick: ({ $router }) => {
$router.push({ name: crudNewRoute });
},
},
]);
return {
accessCheck: async () => hasRole(store.getters['user/user'], 'admin'),
scope: 'company',
order: 20,
component: Priorities,
route: {
name: 'Priorities.crud.priorities',
path: '/company/priorities',
meta: {
label: 'navigation.priorities',
service: new PriorityService(),
},
children: [
{
...grid.getRouterConfig(),
path: '',
},
...crud.getRouterConfig(),
],
},
};
};

View File

@@ -0,0 +1,28 @@
import axios from '@/config/app';
import ResourceService from '@/services/resource.service';
export default class PriorityService extends ResourceService {
getAll(config = {}) {
return axios.get('priorities/list', config);
}
getItemRequestUri(id) {
return `priorities/show?id=${id}`;
}
getItem(id, filters = {}) {
return axios.get(this.getItemRequestUri(id));
}
save(data, isNew = false) {
return axios.post(`priorities/${isNew ? 'create' : 'edit'}`, data);
}
deleteItem(id) {
return axios.post('priorities/remove', { id });
}
getWithFilters(filters, config = {}) {
return axios.post('priorities/list', filters, config);
}
}

View File

@@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>

View File

@@ -0,0 +1,142 @@
<template>
<div class="groups">
<at-collapse simple accordion @on-change="event => (opened = event)">
<at-collapse-item
v-for="(group, index) in groups"
:key="index"
:name="String(index)"
:disabled="group.projects_count === 0"
>
<div slot="title">
<div class="groups__header">
<h5 :class="{ groups__disabled: group.projects_count === 0 }" class="groups__title">
<span
v-if="group.depth > 0 && group.breadCrumbs == null"
:class="{ groups__disabled: group.projects_count === 0 }"
class="groups__depth"
>
{{ group.depth | getSpaceByDepth }}
</span>
<span v-if="group.breadCrumbs" class="groups__bread-crumbs">
<span
v-for="(breadCrumb, index) in group.breadCrumbs"
:key="index"
class="groups__bread-crumbs__item"
@click.stop="$emit('getTargetClickGroupAndChildren', breadCrumb.id)"
>
{{ breadCrumb.name }} /
</span>
</span>
<span>{{ `${group.name} (${group.projects_count})` }}</span>
</h5>
<span @click.stop>
<router-link
v-if="$can('update', 'projectGroup')"
class="groups__header__link"
:to="{ name: 'ProjectGroups.crud.groups.edit', params: { id: group.id } }"
target="_blank"
rel="opener"
>
<i class="icon icon-external-link" />
</router-link>
</span>
</div>
</div>
<div v-if="group.projects_count > 0 && isOpen(index)" class="groups__projects-wrapper">
<GroupProjects :group-id="group.id" class="groups__projects" @reloadData="$emit('reloadData')" />
</div>
</at-collapse-item>
</at-collapse>
</div>
</template>
<script>
import GroupProjects from '../components/GroupProjects';
export default {
name: 'GroupCollapsable',
components: { GroupProjects },
data() {
return {
opened: [],
};
},
props: {
groups: {
type: Array,
required: true,
},
},
methods: {
isOpen(index) {
return this.opened[0] === String(index);
},
reloadData() {
this.$emit('reloadData');
},
},
filters: {
getSpaceByDepth(value) {
return ''.padStart(value, '-');
},
},
};
</script>
<style lang="scss" scoped>
.groups {
&__title {
display: inline-block;
}
.icon-external-link {
font-size: 20px;
}
&__disabled {
opacity: 0.3;
}
&__depth {
padding-right: 0.3em;
letter-spacing: 0.1em;
opacity: 0.3;
font-weight: 300;
}
&__header {
display: flex;
&__link {
margin-left: 5px;
}
}
&__bread-crumbs {
color: #0075b2;
}
&::v-deep {
.at-collapse {
&__item--active {
background-color: #fff;
.groups__title {
color: $blue-2;
}
}
&__header {
display: flex;
align-items: center;
padding: 15px;
}
&__content {
padding: 10px;
}
&__icon.icon-chevron-right {
position: static;
display: block;
color: black;
margin-right: 10px;
}
}
}
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<div class="projects">
<at-input
v-model="query"
type="text"
:placeholder="$t('message.project_search_input_placeholder')"
class="projects__search col-6"
@input="onSearch"
>
<template v-slot:prepend>
<i class="icon icon-search" />
</template>
</at-input>
<div class="at-container">
<div ref="tableWrapper" class="table">
<at-table ref="table" size="large" :columns="columns" :data="projects" />
</div>
</div>
<at-pagination :total="projectsTotal" :current="page" :page-size="limit" @page-change="loadPage" />
</div>
</template>
<script>
import ProjectService from '@/services/resource/project.service';
import TeamAvatars from '@/components/TeamAvatars';
import i18n from '@/i18n';
import debounce from 'lodash.debounce';
const service = new ProjectService();
export default {
name: 'GroupProjects',
props: {
groupId: {
type: Number,
required: true,
},
},
data() {
return {
projects: [],
projectsTotal: 0,
limit: 15,
query: '',
page: 1,
};
},
async created() {
this.search = debounce(this.search, 350);
await this.search();
},
methods: {
async loadPage(page) {
this.page = page;
this.resetOptions();
await this.loadOptions();
},
onSearch() {
this.search();
},
async search() {
this.totalPages = 0;
this.resetOptions();
await this.$nextTick();
await this.loadOptions();
await this.$nextTick();
},
async loadOptions() {
const filters = {
where: {
group: ['in', [this.groupId]],
},
with: ['users', 'tasks', 'can'],
withCount: ['tasks'],
search: {
query: this.query,
fields: ['name'],
},
page: this.page,
};
return service.getWithFilters(filters).then(({ data, pagination = data.pagination }) => {
this.projectsTotal = pagination.total;
this.currentPage = pagination.currentPage;
data.data.forEach(option => this.projects.push(option));
});
},
resetOptions() {
this.projects = [];
},
},
computed: {
columns() {
const columns = [
{
title: this.$t('field.project'),
key: 'name',
},
{
title: this.$t('field.members'),
key: 'users',
render: (h, { item }) => {
return h(TeamAvatars, {
props: {
users: item.users || [],
},
});
},
},
{
title: this.$t('field.amount_of_tasks'),
key: 'tasks',
render: (h, { item }) => {
const amountOfTasks = item.tasks_count || 0;
return h(
'span',
i18n.tc('projects.amount_of_tasks', amountOfTasks, {
count: amountOfTasks,
}),
);
},
},
];
const actions = [
{
title: 'control.view',
icon: 'icon-eye',
onClick: (router, { item }) => {
this.$router.push({ name: 'Projects.crud.projects.view', params: { id: item.id } });
},
renderCondition({ $store }) {
return true;
},
},
{
title: 'projects.members',
icon: 'icon-users',
onClick: (router, { item }) => {
this.$router.push({ name: 'Projects.members', params: { id: item.id } });
},
renderCondition({ $can }, item) {
return $can('updateMembers', 'project', item);
},
},
{
title: 'control.edit',
icon: 'icon-edit',
onClick: (router, { item }, context) => {
this.$router.push({ name: 'Projects.crud.projects.edit', params: { id: item.id } });
},
renderCondition: ({ $can }, item) => {
return $can('update', 'project', item);
},
},
{
title: 'control.delete',
actionType: 'error', // AT-UI action type,
icon: 'icon-trash-2',
onClick: async (router, { item }, context) => {
const isConfirm = await this.$CustomModal({
title: this.$t('notification.record.delete.confirmation.title'),
content: this.$t('notification.record.delete.confirmation.message'),
okText: this.$t('control.delete'),
cancelText: this.$t('control.cancel'),
showClose: false,
styles: {
'border-radius': '10px',
'text-align': 'center',
footer: {
'text-align': 'center',
},
header: {
padding: '16px 35px 4px 35px',
color: 'red',
},
body: {
padding: '16px 35px 4px 35px',
},
},
width: 320,
type: 'trash',
typeButton: 'error',
});
if (isConfirm !== 'confirm') {
return;
}
await service.deleteItem(item.id);
this.$Notify({
type: 'success',
title: this.$t('notification.record.delete.success.title'),
message: this.$t('notification.record.delete.success.message'),
});
await this.search();
this.$emit('reloadData');
},
renderCondition: ({ $can }, item) => {
return $can('delete', 'project', item);
},
},
];
columns.push({
title: this.$t('field.actions'),
render: (h, params) => {
let cell = h(
'div',
{
class: 'actions-column',
},
actions.map(item => {
if (
typeof item.renderCondition !== 'undefined'
? item.renderCondition(this, params.item)
: true
) {
return h(
'AtButton',
{
props: {
type: item.actionType || 'primary', // AT-ui button display type
icon: item.icon || undefined, // Prepend icon to button
},
on: {
click: () => {
item.onClick(this.$router, params, this);
},
},
class: 'action-button',
style: {
margin: '0 10px 0 0',
},
},
this.$t(item.title),
);
}
}),
);
return cell;
},
});
return columns;
},
},
};
</script>
<style lang="scss" scoped>
.projects {
&__search {
margin-bottom: $spacing-03;
}
.at-container {
margin-bottom: 1rem;
.table {
&::v-deep .at-table {
&__cell {
width: 100%;
overflow-x: hidden;
padding-top: $spacing-05;
padding-bottom: $spacing-05;
border-bottom: 2px solid $blue-3;
position: relative;
z-index: 0;
&:last-child {
max-width: unset;
}
}
.actions-column {
display: flex;
flex-flow: row nowrap;
}
.action-button {
margin-right: 1em;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
{
"groups": {
"grid-title": "Groups",
"crud-title": "Group"
},
"navigation": {
"project-groups": "By groups"
},
"message": {
"loading_projects": "Loading projects",
"project_search_input_placeholder": "type to find project",
"group_search_input_placeholder": "type to find project"
},
"field": {
"project": "Project",
"members": "Members",
"amount_of_tasks": "Amount of tasks",
"parent_group": "Parent group",
"loading_groups": "Loading groups"
}
}

View File

@@ -0,0 +1,21 @@
{
"groups": {
"grid-title": "Группы",
"crud-title": "Группа"
},
"navigation": {
"project-groups": "По группам"
},
"message": {
"loading_projects": "Загрузка проектов",
"project_search_input_placeholder": "введите, чтобы найти проект",
"group_search_input_placeholder": "введите, чтобы найти группу"
},
"field": {
"project": "Проект",
"members": "Участники",
"amount_of_tasks": "Кол-во задач",
"parent_group": "Родительская группа",
"loading_groups": "Загружаем группы"
}
}

View File

@@ -0,0 +1,132 @@
import ProjectGroupsService from '@/services/resource/project-groups.service';
import GroupSelect from '@/components/GroupSelect';
import i18n from '@/i18n';
import Vue from 'vue';
export const ModuleConfig = {
routerPrefix: 'project-groups',
loadOrder: 20,
moduleName: 'ProjectGroups',
};
export function init(context) {
const crud = context.createCrud('groups.crud-title', 'groups', ProjectGroupsService);
const crudEditRoute = crud.edit.getEditRouteName();
const crudNewRoute = crud.new.getNewRouteName();
const navigation = { edit: crudEditRoute, new: crudNewRoute };
crud.new.addToMetaProperties('permissions', 'groups/create', crud.new.getRouterConfig());
crud.new.addToMetaProperties('navigation', navigation, crud.new.getRouterConfig());
crud.edit.addToMetaProperties('permissions', 'groups/edit', crud.edit.getRouterConfig());
const fieldsToFill = [
{
key: 'id',
displayable: false,
},
{
label: 'field.name',
key: 'name',
type: 'text',
placeholder: 'field.name',
required: true,
},
{
label: 'field.parent_group',
key: 'parent_id',
render: (h, data) => {
return h(GroupSelect, {
props: { value: data.values.group_parent },
on: {
input(value) {
Vue.set(data.values, 'group_parent', value);
data.values.parent_id = value?.id ?? null;
},
},
});
},
required: false,
},
];
crud.new.addField(fieldsToFill);
crud.edit.addField(fieldsToFill);
context.addRoute(crud.getRouterConfig());
crud.edit.addPageControlsToBottom([
{
title: 'control.delete',
type: 'error',
icon: 'icon-trash-2',
onClick: async ({ service, $router }, item) => {
const isConfirm = await Vue.prototype.$CustomModal({
title: i18n.t('notification.record.delete.confirmation.title'),
content: i18n.t('notification.record.delete.confirmation.message'),
okText: i18n.t('control.delete'),
cancelText: i18n.t('control.cancel'),
showClose: false,
styles: {
'border-radius': '10px',
'text-align': 'center',
footer: {
'text-align': 'center',
},
header: {
padding: '16px 35px 4px 35px',
color: 'red',
},
body: {
padding: '16px 35px 4px 35px',
},
},
width: 320,
type: 'trash',
typeButton: 'error',
});
if (isConfirm !== 'confirm') {
return;
}
await service.deleteItem(item);
Vue.prototype.$Notify({
type: 'success',
title: i18n.t('notification.record.delete.success.title'),
message: i18n.t('notification.record.delete.success.message'),
});
$router.push({ name: context.getModuleRouteName() });
},
renderCondition: ({ $can }) => {
return $can('delete', 'projectGroup');
},
},
]);
context.addRoute({
path: `/${context.routerPrefix}`,
name: context.getModuleRouteName(),
component: () => import(/* webpackChunkName: "project-groups" */ './views/ProjectGroups.vue'),
meta: {
auth: true,
},
});
context.addNavbarEntryDropDown({
label: 'navigation.project-groups',
section: 'navigation.dropdown.projects',
to: {
name: context.getModuleRouteName(),
},
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,217 @@
<template>
<div class="project-groups">
<h1 class="page-title">{{ $t('groups.grid-title') }}</h1>
<div class="project-groups__search-container">
<at-input
v-model="query"
type="text"
:placeholder="$t('message.group_search_input_placeholder')"
class="project-groups__search-container__search col-6"
@input="onSearch"
>
<template slot="prepend">
<i class="icon icon-search" />
</template>
</at-input>
<div v-if="isGroupSelected" class="project-groups__selected-group">
{{ groups[0].name }}
<at-button
icon="icon-x"
circle
size="small"
class="project-groups__selected-group__clear"
@click="onSearch"
></at-button>
</div>
</div>
<div class="at-container">
<div v-if="Object.keys(groups).length && !isDataLoading">
<GroupCollapsable
:groups="groups"
@getTargetClickGroupAndChildren="getTargetClickGroupAndChildren"
@reloadData="onSearch"
/>
<div v-show="hasNextPage" ref="load" class="option__infinite-loader">
{{ $t('field.loading_groups') }} <i class="icon icon-loader" />
</div>
</div>
<div v-else class="at-container__inner no-data">
<preloader v-if="isDataLoading" />
<span>{{ $t('message.no_data') }}</span>
</div>
</div>
</div>
</template>
<script>
import ProjectGroupsService from '@/services/resource/project-groups.service';
import GroupCollapsable from '../components/GroupCollapsable';
import Preloader from '@/components/Preloader';
import debounce from 'lodash.debounce';
const service = new ProjectGroupsService();
export default {
name: 'ProjectGroups',
components: {
Preloader,
GroupCollapsable,
},
data() {
return {
groups: [],
isDataLoading: false,
groupsTotal: 0,
limit: 10,
totalPages: 0,
currentPage: 0,
query: '',
isGroupSelected: false,
};
},
async created() {
this.search = debounce(this.search, 350);
this.requestTimestamp = Date.now();
this.search(this.requestTimestamp);
},
mounted() {
this.observer = new IntersectionObserver(this.infiniteScroll);
},
computed: {
hasNextPage() {
return this.currentPage < this.totalPages;
},
},
methods: {
async infiniteScroll([{ isIntersecting, target }]) {
if (isIntersecting) {
const requestTimestamp = +target.dataset.requestTimestamp;
if (requestTimestamp === this.requestTimestamp) {
await this.loadOptions(requestTimestamp);
await this.$nextTick();
this.observer.disconnect();
this.observe(requestTimestamp);
}
}
},
onSearch() {
this.isGroupSelected = false;
this.requestTimestamp = Date.now();
this.search(this.requestTimestamp);
},
async search(requestTimestamp) {
this.observer.disconnect();
this.totalPages = 0;
this.currentPage = 0;
this.resetOptions();
await this.$nextTick();
await this.loadOptions(requestTimestamp);
await this.$nextTick();
this.observe(requestTimestamp);
},
observe(requestTimestamp) {
if (this.$refs.load) {
this.$refs.load.dataset.requestTimestamp = requestTimestamp;
this.observer.observe(this.$refs.load);
}
},
async loadOptions() {
const filters = {
search: { query: this.query, fields: ['name'] },
with: [],
page: this.currentPage + 1,
limit: this.limit,
};
if (this.query !== '') {
filters.with.push('groupParentsWithProjectsCount');
}
return service.getWithFilters(filters).then(({ data, pagination }) => {
this.groupsTotal = pagination.total;
if (this.query === '') {
this.totalPages = pagination.totalPages;
this.currentPage = pagination.currentPage;
data.forEach(option => this.groups.push(option));
} else {
this.totalPages = pagination.totalPages;
this.currentPage = pagination.currentPage;
data.forEach(option => {
let breadCrumbs = [];
option.group_parents_with_projects_count.forEach(el => {
breadCrumbs.push({
name: `${el.name} (${el.projects_count})`,
id: el.id,
});
});
option.breadCrumbs = breadCrumbs;
this.groups.push(option);
});
}
});
},
resetOptions() {
this.groups = [];
},
getTargetClickGroupAndChildren(id) {
this.query = '';
service
.getWithFilters({
where: { id },
with: ['descendantsWithDepthAndProjectsCount'],
})
.then(({ data, pagination }) => {
this.totalPages = pagination.totalPages;
this.currentPage = pagination.currentPage;
this.resetOptions();
this.groups.push(data[0]);
data[0].descendants_with_depth_and_projects_count.forEach(element => {
this.groups.push(element);
});
this.isGroupSelected = true;
});
},
},
};
</script>
<style lang="scss" scoped>
.no-data {
text-align: center;
font-weight: bold;
position: relative;
}
.project-groups {
&__search-container {
display: flex;
align-items: center;
margin-bottom: $spacing-03;
}
&__selected-group {
background: #ddd;
border-radius: 90px/100px;
padding: 5px 20px;
margin-left: 15px;
align-items: center;
&__clear {
margin-left: 10px;
&:hover {
background: rgba(97, 144, 232, 0.6);
}
}
}
&::v-deep {
.at-container {
overflow: hidden;
margin-bottom: 1rem;
}
}
}
</style>

View File

@@ -0,0 +1,8 @@
{
"navigation": {
"project-report": "Project report"
},
"project-report": {
"report_timezone": "Report timezone: {0}"
}
}

View File

@@ -0,0 +1,8 @@
{
"navigation": {
"project-report": "Отчет по проектам"
},
"project-report": {
"report_timezone": "Отчет составлен в часовом поясе: {0}"
}
}

View File

@@ -0,0 +1,31 @@
export const ModuleConfig = {
routerPrefix: 'project-report',
loadOrder: 40,
moduleName: 'ProjectReport',
};
export function init(context) {
context.addRoute({
path: '/report/projects',
name: 'report.projects',
component: () => import(/* webpackChunkName: "project-report" */ './views/ProjectReport.vue'),
meta: {
auth: true,
},
});
context.addNavbarEntryDropDown({
label: 'navigation.project-report',
section: 'navigation.dropdown.reports',
to: {
name: 'report.projects',
},
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,41 @@
import axios from 'axios';
import ReportService from '@/services/report.service';
export default class ProjectReportService extends ReportService {
/**
* @returns {Promise<AxiosResponse<T>>}
* @param startAt
* @param endAt
* @param users
* @param projects
*/
getReport(startAt, endAt, users, projects) {
return axios.post('report/project', {
start_at: startAt,
end_at: endAt,
users,
projects,
});
}
/**
* @returns {Promise<AxiosResponse<T>>}
* @param startAt
* @param endAt
* @param users
* @param projects
* @param format
*/
downloadReport(startAt, endAt, users, projects, format) {
const params = {
start_at: startAt,
end_at: endAt,
users,
projects,
};
return axios.post(`report/project/download`, params, {
headers: { Accept: format },
});
}
}

View File

@@ -0,0 +1,180 @@
<template>
<div class="project-reports">
<h1 class="page-title">{{ $t('navigation.project-report') }}</h1>
<div class="controls-row">
<Calendar class="controls-row__item" :sessionStorageKey="sessionStorageKey" @change="onCalendarChange" />
<UserSelect class="controls-row__item" @change="onUsersSelect" />
<ProjectSelect class="controls-row__item" @change="onProjectsChange" />
<div class="controls-row__item controls-row__item--left-auto">
<small v-if="companyData.timezone">
{{ $t('project-report.report_timezone', [companyData.timezone]) }}
</small>
</div>
<ExportDropdown
class="export-btn dropdown controls-row__btn controls-row__item"
position="left-top"
trigger="hover"
@export="onExport"
/>
</div>
<div class="at-container">
<div class="total-time-row">
<span class="total-time-label">{{ $t('field.total_time') }}</span>
<span class="total-time-value">{{ totalTime }}</span>
</div>
<div v-if="Object.keys(projects).length && !isDataLoading">
<ProjectLine
v-for="project in projects"
:key="project.id"
:project="project"
:start="datepickerDateStart"
:end="datepickerDateEnd"
/>
</div>
<div v-else class="at-container__inner no-data">
<preloader v-if="isDataLoading" />
<span>{{ $t('message.no_data') }}</span>
</div>
</div>
</div>
</template>
<script>
import Calendar from '@/components/Calendar';
import UserSelect from '@/components/UserSelect';
import ProjectReportService from '_internal/ProjectReport/services/project-report.service';
import ProjectLine from './ProjectReport/ProjectLine';
import {
getDateToday,
getStartDate,
getEndDate,
formatDurationString,
getStartOfDayInTimezone,
getEndOfDayInTimezone,
} from '@/utils/time';
import ProjectSelect from '@/components/ProjectSelect';
import Preloader from '@/components/Preloader';
import ExportDropdown from '@/components/ExportDropdown';
import { mapGetters } from 'vuex';
import debounce from 'lodash.debounce';
const reportService = new ProjectReportService();
export default {
name: 'ProjectReport',
components: {
UserSelect,
Calendar,
ProjectLine,
ProjectSelect,
Preloader,
ExportDropdown,
},
data() {
const today = getDateToday();
const sessionStorageKey = 'amazingcat.session.storage.project_report';
return {
isDataLoading: false,
projects: [],
projectsList: [],
projectReportsList: {},
datepickerDateStart: getStartDate(today),
datepickerDateEnd: getEndDate(today),
reportTimezone: null,
userIds: [],
sessionStorageKey: sessionStorageKey,
};
},
computed: {
...mapGetters('user', ['companyData']),
totalTime() {
return formatDurationString(this.projects.reduce((acc, cur) => acc + cur.time, 0));
},
},
methods: {
onUsersSelect(uids) {
this.userIds = uids;
this.fetchData();
},
onProjectsChange(projectIDs) {
this.projectsList = projectIDs;
this.fetchData();
},
onCalendarChange({ start, end }) {
this.datepickerDateStart = getStartDate(start);
this.datepickerDateEnd = getStartDate(end);
this.fetchData();
},
fetchData: debounce(async function () {
this.isDataLoading = true;
try {
const { data } = await reportService.getReport(
getStartOfDayInTimezone(this.datepickerDateStart, this.companyData.timezone),
getEndOfDayInTimezone(this.datepickerDateEnd, this.companyData.timezone),
this.userIds,
this.projectsList,
);
this.$set(this, 'projects', data.data);
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to projects is canceled');
}
}
this.isDataLoading = false;
}, 350),
async onExport(format) {
try {
const { data } = await reportService.downloadReport(
getStartOfDayInTimezone(this.datepickerDateStart, this.companyData.timezone),
getEndOfDayInTimezone(this.datepickerDateEnd, this.companyData.timezone),
this.userIds,
this.projectsList,
format,
);
window.open(data.data.url, '_blank');
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.log(response ? response : 'request to reports is canceled');
}
}
},
},
};
</script>
<style lang="scss" scoped>
.at-container {
overflow: hidden;
}
.total-time-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 21px;
color: $black-900;
font-size: 2rem;
font-weight: bold;
}
.no-data {
text-align: center;
font-weight: bold;
position: relative;
}
.project-select {
width: 240px;
}
</style>

View File

@@ -0,0 +1,333 @@
<template>
<div class="project">
<div class="project__header">
<h1 class="project__title">{{ project.name }}</h1>
<span class="h3">{{ formatDurationString(project.time) }}</span>
</div>
<at-collapse simple class="list__item">
<at-collapse-item v-for="user in project.users" :key="user.id" class="list__item">
<div slot="title" class="row flex-middle">
<div class="col-3 col-xs-2 col-md-1">
<user-avatar :user="user" :size="avatarSize" />
</div>
<div class="col-8 col-md-10 col-lg-11">
<span class="h5">{{ user.full_name }}</span>
</div>
<div class="col-4 col-md-3 col-lg-2">
<span class="h4">{{ formatDurationString(user.time) }}</span>
</div>
<div class="col-10">
<at-progress
status="success"
:stroke-width="15"
:percent="getUserPercentage(user.time, project.time)"
/>
</div>
</div>
<at-collapse simple accordion @on-change="handleCollapseTask(user, $event)">
<at-collapse-item v-for="task in user.tasks" :key="`tasks-${task.id}`" :name="`${task.id}`">
<div slot="title" class="row">
<div class="col-10 col-md-11 col-lg-12">
<span class="h4">{{ task.task_name }}</span>
</div>
<div class="col-4 col-md-3 col-lg-2">
<span class="h4">{{ formatDurationString(task.time) }}</span>
</div>
<div class="col-10">
<at-progress
status="success"
:stroke-width="15"
:percent="getUserPercentage(task.time, user.time)"
/>
</div>
</div>
<at-collapse
v-if="screenshotsEnabled"
class="project__screenshots screenshots"
accordion
@on-change="handleCollapseDate"
>
<span class="screenshots__title">{{ $t('field.screenshots') }}</span>
<at-collapse-item
v-for="(interval, key) of task.intervals"
:key="key"
:name="`${task.id}-${key}`"
>
<div slot="title" class="row">
<div class="col-12">
<span class="h5 screenshots__date">
{{ moment(interval.date).locale($i18n.locale).format('MMMM DD, YYYY') }}
</span>
</div>
<div class="col-12">
<span class="h5">{{ formatDurationString(interval.time) }}</span>
</div>
</div>
<template v-if="isDateOpened(`${task.id}-${key}`)">
<template v-for="(hourScreens, idx) in interval.items">
<div :key="`screen-${task.id}-${key}-${idx}`" class="row">
<div
v-for="(interval, index) in getHourRow(hourScreens)"
:key="index"
class="col-12 col-md-6 col-lg-4"
>
<Screenshot
v-if="interval"
:key="index"
class="screenshots__item"
:interval="interval"
:user="user"
:task="task"
:disableModal="true"
:showNavigation="true"
:showTask="false"
@click="onShow(interval.items, interval, user, task, project)"
/>
<div
v-else
:key="index"
class="screenshots__item screenshots__placeholder"
/>
</div>
</div>
</template>
</template>
</at-collapse-item>
</at-collapse>
<div v-else>
<template v-for="(interval, key) of task.intervals">
<div :key="key" class="row">
<div class="col-12">
<span class="h5 screenshots__date">
{{ moment(interval.date).locale($i18n.locale).format('MMMM DD, YYYY') }}
</span>
</div>
<div class="col-12">
<span class="h5">{{ formatDurationString(interval.time) }}</span>
</div>
</div>
</template>
</div>
</at-collapse-item>
</at-collapse>
</at-collapse-item>
</at-collapse>
<ScreenshotModal
:show="modal.show"
:interval="modal.interval"
:project="modal.project"
:task="modal.task"
:user="modal.user"
:showNavigation="true"
:canRemove="false"
@close="onHide"
@showPrevious="onShowPrevious"
@showNext="onShowNext"
/>
</div>
</template>
<script>
import moment from 'moment';
import { mapGetters } from 'vuex';
import Screenshot from '@/components/Screenshot';
import ScreenshotModal from '@/components/ScreenshotModal';
import UserAvatar from '@/components/UserAvatar';
import { formatDurationString } from '@/utils/time';
export default {
name: 'ProjectLine',
components: {
Screenshot,
ScreenshotModal,
UserAvatar,
},
data() {
return {
modal: {
show: false,
intervals: {},
interval: null,
project: null,
user: null,
task: null,
},
openedDates: [],
avatarSize: 35,
taskDurations: {},
screenshotsPerRow: 6,
};
},
computed: {
...mapGetters('screenshots', { screenshotsEnabled: 'enabled' }),
},
props: {
project: {
type: Object,
required: true,
},
start: {
type: String,
},
end: {
type: String,
},
},
mounted() {
window.addEventListener('keydown', this.onKeyDown);
},
beforeDestroy() {
window.removeEventListener('keydown', this.onKeyDown);
},
methods: {
moment,
formatDurationString,
onShow(intervals, interval, user, task, project) {
this.modal = {
...this.modal,
show: true,
intervals,
interval,
user,
task,
project,
};
},
onHide() {
this.modal.show = false;
},
onKeyDown(e) {
if (e.key === 'ArrowLeft') {
e.preventDefault();
this.onShowPrevious();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
this.onShowNext();
}
},
onShowPrevious() {
const currentIndex = this.modal.intervals.findIndex(i => +i.id === +this.modal.interval.id);
if (currentIndex === -1 || currentIndex === 0) {
return;
}
this.modal = {
...this.modal,
show: true,
interval: this.modal.intervals[currentIndex - 1],
};
},
onShowNext() {
const currentIndex = this.modal.intervals.findIndex(i => +i.id === +this.modal.interval.id);
if (currentIndex === -1 || currentIndex === this.modal.intervals.length - 1) {
return;
}
this.modal = {
...this.modal,
show: true,
interval: this.modal.intervals[currentIndex + 1],
};
},
isDateOpened(collapseId) {
return this.openedDates.findIndex(p => p === collapseId) > -1;
},
handleCollapseDate(data) {
this.openedDates = data;
},
handleCollapseTask(user, taskID) {
if (typeof taskID === 'object') {
taskID = taskID[0];
}
const key = `${user.id}:${taskID}`;
this.$set(this.taskDurations, key, user.tasks);
},
formatDate(value) {
return moment(value).format('DD.MM.YYYY HH:mm:ss');
},
getUserPercentage(seconds, totalTime) {
if (!totalTime) {
return 0;
}
return Math.floor((seconds * 100) / totalTime);
},
getHourRow(screenshots) {
return new Array(this.screenshotsPerRow).fill(null).map((el, i) => screenshots[i] || el);
},
},
};
</script>
<style lang="scss" scoped>
.project {
&__header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: none;
padding: 14px 21px;
border-bottom: 3px solid $blue-3;
}
&__title {
color: $black-900;
font-size: 2rem;
font-weight: bold;
}
&__screenshots {
margin-bottom: $spacing-05;
}
}
.screenshots {
padding-top: $spacing-03;
&__title {
font-size: 15px;
color: $gray-3;
font-weight: bold;
}
&__date {
padding-left: 20px;
}
&__item {
margin-bottom: $spacing-04;
}
&__placeholder {
width: 100%;
height: 150px;
border: 2px dashed $gray-3;
}
&::v-deep {
.at-collapse__header {
padding: 14px 0;
}
img {
object-fit: cover;
height: 150px;
}
.at-collapse__icon {
top: 20px;
left: 0;
color: black;
}
.at-collapse__icon.icon-chevron-right {
display: block;
}
}
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="phases">
<at-table :columns="columns" :data="rows"></at-table>
<at-button v-if="this.showControls" class="phases__add-btn" type="primary" @click="addPhase">{{
$t('field.add_phase')
}}</at-button>
</div>
</template>
<script>
export default {
name: 'Phases',
props: {
phases: {
type: Array,
required: true,
},
showControls: {
type: Boolean,
default: true,
},
},
data() {
const columns = [
{
title: this.$t('field.name'),
render: (h, params) => {
return this.showControls
? h('AtInput', {
props: {
type: 'text',
value: params.item.name,
},
on: {
input: (value, b) => {
this.rows[params.item.index]['name'] = value;
this.$emit('change', this.rows);
},
},
})
: h('span', params.item.name);
},
},
];
if (this.showControls) {
columns.push({
title: '',
width: '40',
render: (h, params) => {
return h('AtButton', {
props: {
type: 'error',
icon: 'icon-trash-2',
size: 'small',
},
on: {
click: async () => {
if (this.modalIsOpen) {
return;
}
this.modalIsOpen = true;
const isConfirm = await this.$CustomModal({
title: this.$t('notification.record.delete.confirmation.title'),
content: this.$t('notification.record.delete.confirmation.message'),
okText: this.$t('control.delete'),
cancelText: this.$t('control.cancel'),
showClose: false,
styles: {
'border-radius': '10px',
'text-align': 'center',
footer: {
'text-align': 'center',
},
header: {
padding: '16px 35px 4px 35px',
color: 'red',
},
body: {
padding: '16px 35px 4px 35px',
},
},
width: 320,
type: 'trash',
typeButton: 'error',
});
this.modalIsOpen = false;
if (isConfirm === 'confirm') {
this.rows.splice(params.item.index, 1);
this.$emit('change', this.rows);
}
},
},
});
},
});
} else {
columns.push({
title: this.$tc('field.amount_of_tasks'),
render: (h, params) => {
return h(
'span',
this.$tc('projects.amount_of_tasks', params.item?.tasks_count ?? 0, {
count: params.item?.tasks_count,
}),
);
},
});
}
return {
modalIsOpen: false,
columns,
rows: this.phases,
};
},
methods: {
addPhase() {
this.rows.push({
name: '',
});
this.$emit('change', this.rows);
},
},
watch: {
phases: function (val) {
this.rows = val;
},
},
};
</script>
<style scoped lang="scss">
.phases {
&::v-deep {
.at-input__original {
border: none;
width: 100%;
}
}
&__add-btn {
margin-top: $spacing-03;
}
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<div>
<at-input v-model="search" class="search-input" :placeholder="$t('control.search')">
<template slot="prepend">
<i class="icon icon-search" />
</template>
</at-input>
<ul class="user-list">
<preloader v-if="loading" class="user-list__preloader" />
<template v-else>
<project-members-user
v-for="(user, index) in filteredUsers"
:key="index"
class="list-item"
:user="user"
:selected="selectedUsers.includes(user)"
:addable="addable"
@role-change="onRoleChange($event, user.id)"
@click="onClick(user)"
/>
</template>
</ul>
</div>
</template>
<script>
import ProjectMembersUser from './ProjectMembersUser.vue';
import Preloader from '@/components/Preloader.vue';
export default {
name: 'ProjectMembersSearchableList',
components: {
ProjectMembersUser,
Preloader,
},
props: {
value: {
type: Array,
default: () => [],
},
selectedUsers: {
type: Array,
default: () => [],
},
addable: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
},
data() {
return {
search: '',
};
},
computed: {
filteredUsers() {
if (this.search.length > 0) {
return this.filterList(this.search, this.value, 'full_name');
}
return this.value;
},
},
methods: {
onRoleChange(roleId, userId) {
const users = Array.from(this.value);
const userIndex = users.findIndex(user => user.id === userId);
if (userIndex === -1) {
return;
}
users[userIndex]['pivot'] = {
role_id: roleId,
};
this.$emit('input', users);
},
onClick(user) {
const users = this.selectedUsers;
const userIndex = users.findIndex(u => u.id === user.id);
if (userIndex > -1) {
users.splice(userIndex, 1);
} else {
users.push(user);
}
this.$emit('on-select', users);
},
filterList(q, list, field) {
const words = q
.split(' ')
.map(s => s.trim())
.filter(s => s.length !== 0);
const hasTrailingSpace = q.endsWith(' ');
const escapeRegExp = s => s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexString = words
.map((word, i) => {
if (i + 1 === words.length && !hasTrailingSpace) return `(?=.*\\b${escapeRegExp(word)})`;
return `(?=.*\\b${escapeRegExp(word)}\\b)`;
})
.join('');
const searchRegex = new RegExp(`${regexString}.+`, 'gi');
return list.filter(item => searchRegex.test(item[field]));
},
},
};
</script>
<style lang="scss" scoped>
.search-input {
margin-bottom: $layout-01;
}
.user-list {
border: 1px solid $border-color-base;
height: 400px;
overflow-y: auto;
border-radius: 5px;
list-style: none;
position: relative;
&__preloader {
bottom: 0;
top: 0;
right: 0;
left: 0;
}
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<li class="user-item flex flex-middle" :class="{ 'user-item--selected': selected }" @click="$emit('click', $event)">
<user-avatar class="user-item__avatar" :user="user" />
<div>{{ user.full_name }}</div>
<role-select
v-if="!addable"
v-model="roleId"
class="user-item__role-select"
:exclude-roles="['admin']"
@click.stop
>
<template v-slot:role_manager_description>
{{ $t('project-roles-description.manager') }}
</template>
<template v-slot:role_auditor_description>
{{ $t('project-roles-description.auditor') }}
</template>
<template v-slot:role_user_description>
{{ $t('project-roles-description.user') }}
</template>
</role-select>
</li>
</template>
<script>
import UserAvatar from '@/components/UserAvatar.vue';
import RoleSelect from '@/components/RoleSelect.vue';
export default {
name: 'ProjectMembersUser',
components: {
UserAvatar,
RoleSelect,
},
props: {
user: {
required: true,
type: Object,
},
selected: {
type: Boolean,
default: false,
},
addable: {
type: Boolean,
default: false,
},
},
computed: {
roleId: {
get() {
const roleId = this.user?.pivot?.role_id;
if (roleId === undefined) {
const defaultRoleId = 2;
this.$emit('role-change', defaultRoleId);
return defaultRoleId;
}
return roleId;
},
set(roleId) {
this.$emit('role-change', roleId);
},
},
},
};
</script>
<style lang="scss" scoped>
.user-item {
min-height: 57px;
padding: 0.5rem 1rem;
border-bottom: 1px solid $border-color-base;
&:hover {
background: $table-tr-bg-color-hover;
cursor: pointer;
}
&--selected {
background: darken($table-tr-bg-color-hover, 5%);
&:hover {
background: darken($table-tr-bg-color-hover, 4%);
cursor: pointer;
}
}
&__avatar {
margin-right: $spacing-05;
}
&__role-select {
max-width: 180px;
margin-left: auto;
}
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div>
<div v-for="status in statuses" :key="status.id" class="status">
<h3 class="status-title">{{ status.name }}</h3>
<div>
<at-checkbox
class="status-enable"
:checked="enabled(status.id)"
@on-change="onEnabledChange(status.id, $event)"
>
{{ $t('projects.enable_status') }}
</at-checkbox>
</div>
<div v-if="enabled(status.id)">
<at-checkbox
class="status-color-override"
:checked="overrideColor(status.id)"
@on-change="onOverrideColorChange(status.id, $event)"
>
{{ $t('projects.override_color') }}
</at-checkbox>
<ColorInput
v-if="overrideColor(status.id)"
class="status-color"
:value="color(status.id)"
@change="onColorChange(status.id, $event)"
/>
</div>
</div>
</div>
</template>
<script>
import ColorInput from '@/components/ColorInput';
import StatusService from '@/services/resource/status.service';
export default {
components: {
ColorInput,
},
props: {
value: {
required: true,
},
},
data() {
return {
statusService: new StatusService(),
statuses: [],
};
},
async created() {
this.statuses = await this.statusService.getAll();
},
methods: {
getStatusIndex(id) {
return this.value.findIndex(status => +status.id === +id);
},
getStatus(id) {
return this.value.find(status => +status.id === +id);
},
enabled(id) {
return this.getStatus(id) !== undefined;
},
overrideColor(id) {
const status = this.getStatus(id);
return status !== undefined && status.color !== null;
},
color(id) {
const status = this.getStatus(id);
return status !== undefined && status.color !== null ? status.color : 'transparent';
},
onEnabledChange(id, value) {
let newValue = value
? Array.from(new Set([...this.value, { id: +id, color: null }]))
: this.value.filter(status => +status.id !== +id);
this.$emit('change', newValue);
},
onOverrideColorChange(id, value) {
const index = this.getStatusIndex(id);
let newValue = [...this.value];
if (value) {
newValue.splice(index, 1, { id: +id, color: '#ffffff' });
} else {
newValue.splice(index, 1, { id: +id, color: null });
}
this.$emit('change', newValue);
},
onColorChange(id, value) {
const index = this.getStatusIndex(id);
let newValue = [...this.value];
newValue.splice(index, 1, { id: +id, color: value });
this.$emit('change', newValue);
},
},
};
</script>
<style lang="scss" scoped>
.status {
&:not(:last-child) {
margin-bottom: 24px;
}
&-title {
font-size: 16px;
}
&-color-override {
margin-bottom: 16px;
}
&-color {
width: 170px;
height: 40px;
overflow: hidden;
}
}
</style>

View File

@@ -0,0 +1,49 @@
{
"navigation": {
"projects": "List"
},
"projects": {
"grid-title": "Projects",
"crud-title": "Project",
"amount_of_tasks": "No tasks | 1 task | {count} tasks",
"project_members": "Project members",
"gantt": "Gantt",
"members": "Members",
"enable_status": "Enable status in project",
"override_color": "Override color",
"tasks": "Tasks",
"kanban": "Kanban",
"project_tasks": "Project tasks",
"add_task": "Add task"
},
"field": {
"default_priority": "Default priority",
"statuses": "Statuses",
"due_date": "Due date",
"history": "History",
"project_id": "Project",
"phases": "Phases",
"add_phase": "Add",
"priority_id": "Priority",
"status_id": "Status",
"comments": "Comments",
"project_phase_id": "Phase",
"group": "Group",
"no_group_selected": "No group selected",
"loading_groups": "Loading groups",
"no_groups_found": "No groups {query} found",
"fast_create_group": "Create a group {query}",
"to_create_group": "Create in a new window",
"type_to_search_for_group": "Start typing to search for a group."
},
"control": {
"task-list": "Task List",
"project-list": "Project List",
"of": "of"
},
"project-roles-description": {
"auditor": "Read access to assigned projects, create and view all the assigned project's tasks, view all the project users' activity",
"manager": "Full access to assigned projects, full access to project's tasks, view all the project users' activity",
"user": "Read access to assigned projects, view all the assigned project's tasks, view personal activity"
}
}

View File

@@ -0,0 +1,49 @@
{
"navigation": {
"projects": "Список"
},
"projects": {
"grid-title": "Проекты",
"crud-title": "Проект",
"amount_of_tasks": "Нет задач | 1 задача | {count} задач(и)",
"project_members": "Участники",
"gantt": "Гант",
"members": "Участники",
"kanban": "Канбан",
"enable_status": "Включить статус в проекте",
"override_color": "Переопределить цвет",
"tasks": "Задачи",
"project_tasks": "Задачи проекта",
"add_task": "Добавить задачу"
},
"field": {
"default_priority": "Приоритет по-умолчанию",
"statuses": "Статусы",
"due_date": "Дата завершения",
"history": "История",
"project_id": "Проект",
"phases": "Стадии",
"add_phase": "Добавить",
"priority_id": "Приоритет",
"status_id": "Статус",
"comments": "Комментарии",
"project_phase_id": "Стадия",
"group": "Группа",
"no_group_selected": "Группа не выбрана",
"loading_groups": "Загружаем группы",
"no_groups_found": "Группа {query} не найдена",
"fast_create_group": "Создать группу {query}",
"to_create_group": "Создать в новом окне",
"type_to_search_for_group": "Начните печатать, чтобы найти группу."
},
"control": {
"task-list": "Список задач",
"project-list": "Список проектов",
"of": "из"
},
"project-roles-description": {
"auditor": "Доступ на чтение к проектам, над которыми работает, создание и просмотр всех задач проекта, просмотр активности всех пользователей проекта",
"manager": "Полный доступ к проектам, над которыми работает, полный доступ к задачам проекта, просмотр активности всех пользователей проекта",
"user": "Доступ на чтение к проектам, над которыми работает, просмотр своих задач проекта, просмотр личной активности"
}
}

View File

@@ -0,0 +1,592 @@
import moment from 'moment-timezone';
import ProjectService from '@/services/resource/project.service';
import i18n from '@/i18n';
import { formatDurationString } from '@/utils/time';
import { ModuleLoaderInterceptor } from '@/moduleLoader';
import PrioritySelect from '@/components/PrioritySelect';
import ScreenshotsStateSelect from '@/components/ScreenshotsStateSelect';
import TeamAvatars from '@/components/TeamAvatars';
import { store } from '@/store';
import Statuses from './components/Statuses';
import Phases from './components/Phases.vue';
import Vue from 'vue';
import GroupSelect from '@/components/GroupSelect';
export const ModuleConfig = {
routerPrefix: 'projects',
loadOrder: 20,
moduleName: 'Projects',
};
function formatDateTime(value, timezone) {
const date = moment.tz(value, timezone || moment.tz.guess());
return date.locale(i18n.locale).format('MMMM D, YYYY — HH:mm:ss ([GMT]Z)');
}
export function init(context) {
let routes = {};
ModuleLoaderInterceptor.on('AmazingCat_CoreModule', m => {
m.routes.forEach(route => {
if (route.name.search('users.view') > 0) {
routes.usersView = route.name;
}
});
});
ModuleLoaderInterceptor.on('AmazingCat_TasksModule', m => {
m.routes.forEach(route => {
if (route.name.search('view') > 0) {
routes.tasksView = route.name;
}
});
});
const crud = context.createCrud('projects.crud-title', 'projects', ProjectService, {
with: [
'defaultPriority',
'tasks',
'workers',
'workers.task:id,task_name',
'workers.user:id,full_name',
'group:id,name',
],
withSum: [
['workers as total_spent_time', 'duration'],
['workers as total_offset', 'offset'],
],
});
const crudViewRoute = crud.view.getViewRouteName();
const crudEditRoute = crud.edit.getEditRouteName();
const crudNewRoute = crud.new.getNewRouteName();
const navigation = { view: crudViewRoute, edit: crudEditRoute, new: crudNewRoute };
crud.view.addToMetaProperties('titleCallback', ({ values }) => values.name, crud.view.getRouterConfig());
crud.view.addToMetaProperties('navigation', navigation, crud.view.getRouterConfig());
crud.new.addToMetaProperties('permissions', 'projects/create', crud.new.getRouterConfig());
crud.new.addToMetaProperties('navigation', navigation, crud.new.getRouterConfig());
crud.edit.addToMetaProperties('permissions', 'projects/edit', crud.edit.getRouterConfig());
const grid = context.createGrid('projects.grid-title', 'projects', ProjectService, {
with: ['users', 'defaultPriority', 'statuses', 'can', 'group:id,name'],
withCount: ['tasks'],
});
grid.addToMetaProperties('navigation', navigation, grid.getRouterConfig());
const fieldsToShow = [
{
label: 'field.name',
key: 'name',
},
{
label: 'field.created_at',
key: 'created_at',
render: (h, { currentValue, companyData }) => {
return h('span', formatDateTime(currentValue, companyData.timezone));
},
},
{
label: 'field.updated_at',
key: 'updated_at',
render: (h, { currentValue, companyData }) => {
return h('span', formatDateTime(currentValue, companyData.timezone));
},
},
{
label: 'field.description',
key: 'description',
},
{
label: 'field.group',
key: 'group',
render: (h, props) =>
h('span', props.currentValue !== null ? props.currentValue.name : i18n.t('field.no_group_selected')),
},
{
key: 'total_spent_time',
label: 'field.total_spent',
render: (h, props) => {
const timeWithOffset = +props.values.total_spent_time + +props.values.total_offset;
return h('span', formatDurationString(timeWithOffset > 0 ? timeWithOffset : 0));
},
},
{
label: 'field.phases',
key: 'phases',
render: (h, data) => {
return h(Phases, {
props: {
phases: Array.isArray(data.currentValue) ? data.currentValue : [],
showControls: false,
},
});
},
},
{
key: 'default_priority',
label: 'field.default_priority',
render: (h, { currentValue }) => {
if (!currentValue) {
return null;
}
if (!currentValue.color) {
return h('span', {}, [currentValue.name]);
}
return h(
'span',
{
style: {
display: 'flex',
alignItems: 'center',
},
},
[
h('span', {
style: {
display: 'inline-block',
background: currentValue.color,
borderRadius: '4px',
width: '16px',
height: '16px',
margin: '0 4px 0 0',
},
}),
h('span', {}, [currentValue.name]),
],
);
},
},
{
label: 'field.screenshots_state',
key: 'screenshots_state',
render: (h, { currentValue }) => {
return h('span', currentValue ? i18n.t('control.yes') : i18n.t('control.no'));
},
},
{
key: 'workers',
label: 'field.users',
render: (h, props) => {
const tableData = [];
const globalTimeWithOffset = +props.values.total_spent_time + +props.values.total_offset;
Object.keys(props.currentValue).forEach(k => {
const timeWithOffset = +props.currentValue[k].duration + +props.currentValue[k].offset;
props.currentValue[k].time = formatDurationString(timeWithOffset);
if (timeWithOffset > 0 && globalTimeWithOffset > 0) {
tableData.push(props.currentValue[k]);
}
});
return h('AtTable', {
props: {
columns: [
{
title: i18n.t('field.user'),
render: (h, { item }) => {
return h(
'router-link',
{
props: {
to: {
name: 'Users.crud.users.view',
params: { id: item.user_id },
},
},
},
item.user.full_name,
);
},
},
{
title: i18n.t('field.task'),
render: (h, { item }) => {
return h(
'router-link',
{
props: {
to: {
name: 'Tasks.crud.tasks.view',
params: { id: item.task_id },
},
},
},
item.task.task_name,
);
},
},
{
key: 'time',
title: i18n.t('field.time'),
render(h, { item }) {
return h('div', {
domProps: {
textContent: !item
? `0${i18n.t('time.h')} 0${i18n.t('time.m')}`
: item.time,
},
styles: {
'white-space': 'nowrap',
},
});
},
},
],
data: tableData,
pagination: true,
'page-size': 100,
},
});
},
},
];
const fieldsToFill = [
{
key: 'id',
displayable: false,
},
{
label: 'field.name',
key: 'name',
type: 'text',
placeholder: 'field.name',
required: true,
},
{
label: 'field.description',
key: 'description',
type: 'textarea',
required: true,
placeholder: 'field.description',
},
{
label: 'field.group',
key: 'group',
render(h, data) {
return h(GroupSelect, {
props: { value: data.values.group },
on: {
input(value) {
Vue.set(data.values, 'group', value);
},
},
});
},
required: false,
},
{
label: 'field.important',
tooltipValue: 'tooltip.task_important',
key: 'important',
type: 'checkbox',
default: 0,
},
{
label: 'field.phases',
key: 'phases',
render: (h, data) => {
return h(Phases, {
props: {
phases: Array.isArray(data.currentValue) ? data.currentValue : [],
},
on: {
change(value) {
data.inputHandler(value);
},
},
});
},
required: false,
},
{
label: 'field.default_priority',
key: 'default_priority_id',
render: (h, data) => {
let value = '';
if (typeof data.currentValue === 'number' || typeof data.currentValue === 'string') {
value = data.currentValue;
}
return h(PrioritySelect, {
props: {
value,
clearable: false,
},
on: {
input(value) {
data.inputHandler(value);
},
},
});
},
required: false,
},
{
label: 'field.screenshots_state',
key: 'screenshots_state',
default: 1,
render: (h, props) => {
return h(ScreenshotsStateSelect, {
props: {
value: props.values.screenshots_state,
isDisabled: store.getters['screenshots/isProjectStateLocked'],
hideIndexes: [0],
},
on: {
input(value) {
props.inputHandler(value);
},
},
});
},
},
{
label: 'field.statuses',
key: 'statuses',
render: (h, data) => {
const value = Array.isArray(data.currentValue) ? data.currentValue : [];
return h(Statuses, {
props: {
value,
},
on: {
change(value) {
data.inputHandler(value);
},
},
});
},
},
];
crud.view.addField(fieldsToShow);
crud.new.addField(fieldsToFill);
crud.edit.addField(fieldsToFill);
grid.addColumn([
{
title: 'field.project',
key: 'name',
render: (h, { item }) => {
return h(
'span',
{
class: ['projects-grid__project'],
attrs: { title: item.name },
},
item.name,
);
},
},
{
title: 'field.group',
key: 'group',
render: (h, data) => {
if (!data.item.can.update) {
return h('span', data.item.group?.name ?? '');
}
return h(GroupSelect, {
props: { value: data.item.group },
on: {
input(value) {
data.item.group = value;
new ProjectService().save({
id: data.item.id,
group: data.item.group?.id ?? null,
});
},
},
});
},
},
{
title: 'field.members',
key: 'users',
hideForMobile: true,
render: (h, { item }) => {
return h(TeamAvatars, {
props: {
users: item.users || [],
},
});
},
},
{
title: 'field.amount_of_tasks',
key: 'tasks',
render: (h, { item }) => {
const amountOfTasks = item.tasks_count || 0;
return h(
'span',
i18n.tc('projects.amount_of_tasks', amountOfTasks, {
count: amountOfTasks,
}),
);
},
},
]);
const websocketLeaveChannel = id => Vue.prototype.$echo.leave(`projects.${id}`);
const websocketEnterChannel = (id, handlers) => {
const channel = Vue.prototype.$echo.private(`projects.${id}`);
for (const action in handlers) {
channel.listen(`.projects.${action}`, handlers[action]);
}
channel.listen('.projects.edit', function () {
store.dispatch('projectGroups/resetGroups');
});
};
grid.addToMetaProperties('gridData.websocketEnterChannel', websocketEnterChannel, grid.getRouterConfig());
grid.addToMetaProperties('gridData.websocketLeaveChannel', websocketLeaveChannel, grid.getRouterConfig());
crud.view.addToMetaProperties('pageData.websocketEnterChannel', websocketEnterChannel, crud.view.getRouterConfig());
crud.view.addToMetaProperties('pageData.websocketLeaveChannel', websocketLeaveChannel, crud.view.getRouterConfig());
grid.addFilter([
{
referenceKey: 'name',
filterName: 'filter.fields.project_name',
},
]);
const tasksRouteName = context.getModuleRouteName() + '.tasks';
const assignRouteName = context.getModuleRouteName() + '.members';
context.addRoute([
{
path: `/${context.routerPrefix}/:id/tasks/kanban`,
name: tasksRouteName,
component: () => import('./views/Tasks.vue'),
meta: {
auth: true,
},
},
{
path: `/${context.routerPrefix}/:id/members`,
name: assignRouteName,
component: () => import('./views/ProjectMembers.vue'),
meta: {
auth: true,
},
},
]);
grid.addAction([
{
title: 'control.view',
icon: 'icon-eye',
onClick: (router, { item }, context) => {
context.onView(item);
},
renderCondition({ $store }) {
// User always can view assigned projects
return true;
},
},
{
title: 'projects.gantt',
icon: 'icon-crop',
onClick: (router, { item }, context) => {
router.push({ name: 'Gantt.index', params: { id: item.id } });
},
renderCondition({ $store }) {
// User always can view assigned projects
return true;
},
},
{
title: 'projects.tasks',
icon: 'icon-list',
onClick: (router, { item }) => {
router.push({ name: tasksRouteName, params: { id: item.id } });
},
renderCondition({ $can }, item) {
// User always can view project's tasks
return false;
},
},
{
title: 'projects.members',
icon: 'icon-users',
onClick: (router, { item }) => {
router.push({ name: assignRouteName, params: { id: item.id } });
},
renderCondition({ $can }, item) {
return $can('updateMembers', 'project', item);
},
},
{
title: 'projects.kanban',
icon: 'icon-bar-chart-2',
onClick: (router, { item }) => {
router.push({ name: tasksRouteName, params: { id: item.id } });
},
renderCondition({ $can }, item) {
return true;
},
},
{
title: 'control.edit',
icon: 'icon-edit',
onClick: (router, { item }, context) => {
context.onEdit(item);
},
renderCondition: ({ $can }, item) => {
return $can('update', 'project', item);
},
},
{
title: 'control.delete',
actionType: 'error', // AT-UI action type,
icon: 'icon-trash-2',
onClick: (router, { item }, context) => {
context.onDelete(item);
},
renderCondition: ({ $can }, item) => {
return $can('delete', 'project', item);
},
},
]);
grid.addPageControls([
{
label: 'control.create',
type: 'primary',
icon: 'icon-edit',
onClick: ({ $router }) => {
$router.push({ name: crudNewRoute });
},
renderCondition: ({ $can }) => {
return $can('create', 'project');
},
},
]);
context.addRoute(crud.getRouterConfig());
context.addRoute(grid.getRouterConfig());
context.addNavbarEntryDropDown({
label: 'navigation.projects',
section: 'navigation.dropdown.projects',
to: {
name: 'Projects.crud.projects',
},
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,208 @@
<template>
<div class="container">
<div class="at-container">
<div class="crud crud__content">
<div class="page-controls">
<h1 class="page-title crud__title">{{ $t('projects.project_members') }}</h1>
<div class="control-items">
<div class="control-item">
<at-button size="large" @click="$router.go(-1)">{{ $t('control.back') }}</at-button>
</div>
</div>
</div>
<div class="project-members-form">
<div class="row flex-middle flex-between">
<div class="col-md-11">
<project-members-searchable-list
v-model="addableUsers"
addable
:loading="fetching"
:selected-users="selectedUsersToAdd"
@on-select="onUserSelect"
/>
</div>
<div class="col-md-1">
<at-button
type="info"
hollow
size="small"
class="project-members-form__action-btn"
:disabled="!selectedUsersToAdd.length"
@click="addUsers"
>
<i class="icon icon-chevrons-right"></i>
</at-button>
<at-button
type="info"
hollow
size="small"
class="project-members-form__action-btn"
:disabled="!selectedUsersToRemove.length"
@click="removeUsers"
>
<i class="icon icon-chevrons-left"></i>
</at-button>
</div>
<div class="col-md-11">
<project-members-searchable-list
v-model="projectUsers"
:loading="fetching"
:selected-users="selectedUsersToRemove"
@on-select="onProjectUserSelect"
/>
</div>
</div>
<at-button size="large" type="primary" :loading="saving" :disabled="saving" @click="save()">{{
$t('control.save')
}}</at-button>
</div>
</div>
</div>
</div>
</template>
<script>
import ProjectService from '@/services/resource/project.service';
import UsersService from '@/services/resource/user.service';
import ProjectMembersSearchableList from '../components/ProjectMembersSearchableList.vue';
export default {
name: 'ProjectMembers',
components: {
ProjectMembersSearchableList,
},
data() {
return {
project: {},
projectUsers: [],
users: [],
projectService: new ProjectService(),
usersService: new UsersService(),
selectedUsersToAdd: [],
selectedUsersToRemove: [],
saving: false,
fetching: false,
};
},
async mounted() {
try {
this.fetching = true;
const project = await this.projectService.getItem(this.$route.params[this.projectService.getIdParam()]);
this.project = project.data.data;
const projectUsers = await this.projectService.getMembers(
this.$route.params[this.projectService.getIdParam()],
);
this.projectUsers = projectUsers.data.data.users;
const params = { global_scope: true };
this.users = await this.usersService.getAll({ params, headers: { 'X-Paginate': 'false' } });
} catch (e) {
//
} finally {
this.fetching = false;
}
},
methods: {
async save() {
let userRoles = [];
this.projectUsers.forEach(user =>
userRoles.push({
user_id: user.id,
role_id: user.pivot.role_id,
}),
);
const data = {
project_id: this.project.id,
user_roles: userRoles,
};
try {
this.saving = true;
await this.projectService.bulkEditMembers(data);
this.$Notify({
type: 'success',
title: this.$t('notification.save.success.title'),
message: this.$t('notification.save.success.message'),
});
} catch (e) {
//
} finally {
this.saving = false;
}
},
onUserSelect(selectedUsers) {
this.selectedUsersToAdd = selectedUsers;
},
onProjectUserSelect(selectedUsers) {
this.selectedUsersToRemove = selectedUsers;
},
addUsers() {
const users = this.users.filter(user => {
for (const selectedUser of this.selectedUsersToAdd) {
if (selectedUser.id === user.id) {
this.selectedUsersToAdd.splice(
this.selectedUsersToAdd.findIndex(user => user.id === selectedUser.id),
1,
);
return true;
}
}
return false;
});
this.projectUsers = [...this.projectUsers, ...users];
},
removeUsers() {
if (this.selectedUsersToRemove.length) {
this.projectUsers = this.projectUsers.filter(user => {
for (const selectedUser of this.selectedUsersToRemove) {
if (selectedUser.id === user.id) {
this.selectedUsersToRemove.splice(
this.selectedUsersToRemove.findIndex(user => user.id === selectedUser.id),
1,
);
return false;
}
}
return true;
});
}
},
},
computed: {
addableUsers() {
const users = Array.from(this.users);
if (this.projectUsers.length) {
const addedUsersIds = this.projectUsers.map(u => u[this.usersService.getIdParam()]);
addedUsersIds.forEach(id => {
users.splice(
users.findIndex(user => {
return user[this.usersService.getIdParam()] === id;
}),
1,
);
});
}
return users;
},
},
};
</script>
<style lang="scss" scoped>
.project-members-form {
.row {
margin-bottom: $layout-01;
}
&__action-btn {
margin-bottom: $layout-01;
}
}
</style>

View File

@@ -0,0 +1,963 @@
<template>
<div>
<div class="crud crud__content">
<div class="page-controls">
<h1 class="page-title crud__title">{{ project.name }}</h1>
<div class="control-items">
<div class="control-item">
<at-button
v-if="$can('create', 'task')"
type="primary"
size="large"
icon="icon-edit"
@click="$router.push({ name: 'Tasks.crud.tasks.new' })"
>
{{ $t('projects.add_task') }}
</at-button>
</div>
<div class="control-item">
<at-button size="large" @click="$router.push({ name: 'Projects.crud.projects' })">
{{ $t('control.project-list') }}
</at-button>
</div>
<div class="control-item">
<at-button size="large" @click="$router.go(-1)">{{ $t('control.back') }}</at-button>
</div>
</div>
</div>
<div ref="kanban" class="project-tasks_kanban at-container">
<kanban-board
ref="board"
:config="config"
:stages="stages.map(s => s.name)"
:blocks="blocks"
@update-block="updateBlock"
>
<div
v-for="stage in stages"
:slot="stage.name"
:key="`stage_${stage.name}`"
class="status"
:style="getHeaderStyle(stage.name)"
>
<at-button
v-if="stage.order !== 0"
type="text"
size="large"
class="button-kanban"
icon="icon-chevron-left"
:style="getHeaderStyle(stage.name)"
@click="changeOrder(stage.order, 'left')"
>
</at-button>
<h3>{{ stage.name }}</h3>
<at-button
v-if="stage.order !== stages.length - 1"
type="text"
size="large"
class="button-kanban"
icon="icon-chevron-right"
:style="getHeaderStyle(stage.name)"
@click="changeOrder(stage.order, 'right')"
>
</at-button>
</div>
<div
v-for="block in blocks"
:slot="block.id"
:key="`block_${block.id}`"
:class="{ task: true, handle: isDesktop }"
@click="loadTask(block.id)"
@mousedown="onDownMouse"
@touchstart="onDown"
@pointerdown="task = null"
>
<h4 class="task-name">{{ getTask(block.id).task_name }}</h4>
<at-tag class="tag-priority" :color="getTask(block.id).priority.color">{{
getTask(block.id).priority.name
}}</at-tag>
<p class="task-description" v-html="getTask(block.id).description"></p>
<div class="task-users">
<div class="task__tags">
<at-tag v-if="isOverDue(companyData.timezone, block)" color="error"
>{{ $t('tasks.due_date--overdue') }}
</at-tag>
<at-tag v-if="isOverTime(block)" color="warning"
>{{ $t('tasks.estimate--overtime') }}
</at-tag>
</div>
<span class="total-time-row">
<i class="icon icon-clock"></i>&nbsp; {{ block.estimate }} {{ $t(`control.of`) }}
{{ block.total_spent_time }}
</span>
<team-avatars :users="getTask(block.id).users"></team-avatars>
</div>
<at-button
type="primary"
hollow
circle
:class="{ 'hide-on-mobile': isDesktop, 'move-task': !isDesktop, handle: !isDesktop }"
@click.stop
></at-button>
</div>
</kanban-board>
</div>
</div>
<div
v-custom-scroll="{ targetSelector: '.drag-container' }"
:class="{ 'scrollbar-top': true, 'hide-on-mobile': !isDesktop }"
></div>
<transition name="slide">
<div v-if="task" class="task-view">
<div class="actions__toggle">
<div class="task-view-header">
<h4 class="task-view-title">{{ task.task_name }}</h4>
<p class="task-view-description" v-html="task.description"></p>
<div class="task-view-close" @click="task = null">
<span class="icon icon-x"></span>
</div>
</div>
<div class="row">
<div class="col-10 label">{{ $t('field.users') }}:</div>
<div class="col">
<team-avatars :users="task.users"></team-avatars>
</div>
</div>
<div class="row">
<div class="col-10 label">{{ $t('field.due_date') }}:</div>
<div class="col">{{ task.due_date ? formatDate(task.due_date) : '' }}</div>
</div>
<div class="row">
<div class="col-10 label">{{ $t('field.total_spent') }}:</div>
<div class="col">
{{
task.total_spent_time
? formatDurationString(+task.total_spent_time + +task.total_offset)
: ''
}}
</div>
</div>
<div class="row">
<div class="col-10 label">{{ $t('field.priority') }}:</div>
<div class="col">{{ task.priority ? task.priority.name : '' }}</div>
</div>
<div class="row">
<div class="col-10 label">{{ $t('field.source') }}:</div>
<div class="col">{{ task.project.source }}</div>
</div>
<div class="row">
<div class="col-10 label">{{ $t('field.created_at') }}:</div>
<div class="col">{{ formatDate(task.created_at) }}</div>
</div>
</div>
<div class="row">
<at-button
class="control-item"
size="large"
icon="icon-eye"
:title="$t('control.view')"
@click="viewTask(task)"
/>
<at-button
v-if="$can('update', 'task', task)"
class="control-item"
size="large"
icon="icon-edit"
:title="$t('control.edit')"
@click="editTask(task)"
/>
<at-button
v-if="$can('delete', 'task', task)"
class="control-item"
size="large"
type="error"
icon="icon-trash-2"
:title="$t('control.delete')"
@click="deleteTask(task)"
/>
</div>
</div>
</transition>
</div>
</template>
<script>
import moment from 'moment-timezone';
import TeamAvatars from '@/components/TeamAvatars';
import ProjectService from '@/services/resource/project.service';
import StatusService from '@/services/resource/status.service';
import TasksService from '@/services/resource/task.service';
import { mapGetters } from 'vuex';
import { getTextColor } from '@/utils/color';
import { formatDate, formatDurationString } from '@/utils/time';
import { throttle } from 'lodash';
export default {
components: {
TeamAvatars,
},
name: 'Tasks',
data() {
return {
projectService: new ProjectService(),
statusService: new StatusService(),
taskService: new TasksService(),
project: {},
statuses: [],
tasks: [],
task: null,
config: {
moves: function (el, container, handle) {
return handle.classList.contains('handle');
},
},
isDesktop: false,
isMobile: false,
scrollInterval: null,
mouseX: null,
isDragging: false,
edgeThreshold: Math.min(window.innerWidth * 0.1, 100),
maxScrollSpeed: window.innerWidth * 0.002,
};
},
computed: {
...mapGetters('user', ['companyData']),
stages() {
return this.statuses.map(status => ({ name: status.name, order: this.getStatusByOrder(status.id) }));
},
blocks() {
return this.tasks.map(task => ({
id: +task.id,
estimate: formatDurationString(task.estimate),
status: this.getStatusName(task.status_id),
total_spent_time: formatDurationString(+task.total_spent_time + +task.total_offset),
total_spent_time_over: +task.total_spent_time + +task.total_offset,
estimate_over: task.estimate,
due_date: task.due_date,
}));
},
},
methods: {
getTextColor,
formatDate,
formatDurationString,
handleClick(e) {
if (!e.target.closest('.actions__toggle')) {
if (e.target.closest('.task')) {
return;
}
this.task = null;
}
},
isOverDue(companyTimezone, item) {
return (
typeof companyTimezone === 'string' &&
item.due_date != null &&
moment.utc(item.due_date).tz(companyTimezone, true).isBefore(moment())
);
},
checkScreenSize() {
this.isDesktop = window.screen.width > 900;
this.checkScreenSizeMobile();
},
checkScreenSizeMobile() {
this.isMobile = window.screen.width < 600;
},
isOverTime(item) {
return item.estimate_over != null && item.total_spent_time_over > item.estimate_over;
},
getBlock(id) {
return this.blocks.find(block => +block.id === +id);
},
getTask(id) {
return this.tasks.find(task => +task.id === +id);
},
getStatusByName(name) {
return this.statuses.find(status => status.name === name);
},
getStatusByOrder(id) {
const index = this.statuses.findIndex(item => item.id === id);
return index;
},
getStatusName(id) {
const status = this.statuses.find(status => +status.id === +id);
if (status !== undefined) {
return status.name;
}
return '';
},
onDownMouse(e) {
if (e.buttons & 1) {
document.addEventListener('mousemove', this.onMoveMouse);
document.addEventListener('mouseup', this.onUpMouse);
this.onScrollMouse();
this.scrollTimer = setInterval(this.onScrollMouse, 1);
}
},
onMove(e) {
this.mouseX = e.touches[0].clientX;
},
onMoveMouse(e) {
this.mouseX = e.clientX;
},
onUpMouse(e) {
document.removeEventListener('mousemove', this.onMoveMouse);
document.removeEventListener('mouseup', this.onUpMouse);
clearInterval(this.scrollTimer);
},
onDown(e) {
document.addEventListener('touchmove', this.onMove);
document.addEventListener('touchend', this.onUp);
if (!this.isMobile) {
this.onScrollMouse();
this.scrollTimer = setInterval(this.onScrollMouse, 1);
} else {
this.scrollTimer = setInterval(this.onScrollTimerTouch, 1);
}
},
onScrollTimerTouch() {
if (this.mouseX < 50) {
document.querySelector('.drag-container').scrollLeft -= 1;
}
if (this.mouseX > window.screen.width - 50) {
document.querySelector('.drag-container').scrollLeft += 1;
}
},
onScrollMouse() {
let scrollSpeed = 0;
if (this.mouseX !== null && this.mouseX < this.edgeThreshold) {
scrollSpeed = this.maxScrollSpeed * (1 - this.mouseX / this.edgeThreshold);
document.querySelector('.drag-container').scrollBy(-scrollSpeed, 0);
} else if (this.mouseX > window.innerWidth - this.edgeThreshold) {
const distanceToRightEdge = window.innerWidth - this.mouseX;
scrollSpeed = this.maxScrollSpeed * (1 - distanceToRightEdge / this.edgeThreshold);
document.querySelector('.drag-container').scrollBy(scrollSpeed, 0);
}
},
onUp(e) {
document.removeEventListener('touchmove', this.onMove);
document.removeEventListener('touchend', this.onUp);
clearInterval(this.scrollTimer);
},
onUpMove(e) {
document.removeEventListener('mousemove', this.onMove);
document.removeEventListener('mouseend', this.onUpMove);
document.querySelector('.drag-container').removeEventListener('scroll', this.onDragContainerScroll);
this.mouseX = null;
clearInterval(this.scrollTimer);
},
changeOrder: throttle(async function (index, direction) {
const service = this.statusService;
const item = this.statuses[index];
const targetIndex = direction === 'left' ? index - 1 : index + 1;
const targetItem = this.statuses[targetIndex];
await service.save({ ...targetItem, order: item.order });
this.$set(this.statuses, index, { ...targetItem, order: item.order });
this.$set(this.statuses, targetIndex, { ...item, order: targetItem.order });
}, 1000),
getHeaderStyle(name) {
const status = this.getStatusByName(name);
return {
background: status.color,
color: this.getTextColor(status.color),
};
},
async updateBlock(blockId, newStatusName) {
const block = this.getBlock(blockId);
const newStatus = this.statuses.find(s => s.name === newStatusName);
const blockElement = this.$refs.kanban.querySelector(`[data-block-id="${blockId}"]`);
const prevBlockElement = blockElement.previousSibling;
const nextBlockElement = blockElement.nextSibling;
const prevBlockId = prevBlockElement ? +prevBlockElement.getAttribute('data-block-id') : 0;
const prevTask = prevBlockId ? this.getTask(prevBlockId) : null;
const nextBlockId = nextBlockElement ? +nextBlockElement.getAttribute('data-block-id') : 0;
const nextTask = nextBlockId ? this.getTask(nextBlockId) : null;
let newRelativePosition;
if (prevTask !== null && nextTask !== null) {
newRelativePosition = (prevTask.relative_position + nextTask.relative_position) / 2;
} else if (prevTask !== null) {
newRelativePosition = prevTask.relative_position + 1;
} else if (nextTask !== null) {
newRelativePosition = nextTask.relative_position - 1;
} else {
newRelativePosition = 0;
}
const task = this.getTask(blockId);
const updatedTask = await this.taskService.save({
...task,
users: task.users.map(user => +user.id),
status_id: newStatus.id,
relative_position: newRelativePosition,
});
const taskIndex = this.tasks.findIndex(t => +t.id === +updatedTask.id);
if (taskIndex !== -1) {
const tasks = [...this.tasks];
tasks.splice(taskIndex, 1, { ...task, ...updatedTask });
tasks.sort((a, b) => a.relative_position - b.relative_position);
this.tasks = tasks;
}
},
async loadTask(id) {
this.task = this.getTask(id);
this.task = (
await this.taskService.getItem(id, {
with: ['users', 'priority', 'project', 'can'],
withSum: [
['workers as total_spent_time', 'duration'],
['workers as total_offset', 'offset'],
],
})
).data.data;
},
viewTask(task) {
this.$router.push({
name: 'Tasks.crud.tasks.view',
params: { id: task.id },
});
},
editTask(task) {
this.$router.push({
name: 'Tasks.crud.tasks.edit',
params: { id: task.id },
});
},
async deleteTask(task) {
const isConfirm = await this.$CustomModal({
title: this.$t('notification.record.delete.confirmation.title'),
content: this.$t('notification.record.delete.confirmation.message'),
okText: this.$t('control.delete'),
cancelText: this.$t('control.cancel'),
showClose: false,
styles: {
'border-radius': '10px',
'text-align': 'center',
footer: {
'text-align': 'center',
},
header: {
padding: '16px 35px 4px 35px',
color: 'red',
},
body: {
padding: '16px 35px 4px 35px',
},
},
width: 320,
type: 'trash',
typeButton: 'error',
});
if (isConfirm !== 'confirm') {
return;
}
await this.taskService.deleteItem(task.id);
this.$Notify({
type: 'success',
title: this.$t('notification.record.delete.success.title'),
message: this.$t('notification.record.delete.success.message'),
});
this.task = null;
const projectId = this.$route.params['id'];
this.tasks = (
await this.taskService.getWithFilters(
{
where: { project_id: projectId },
orderBy: ['relative_position'],
with: ['users', 'priority', 'project', 'can'],
withSum: [
['workers as total_spent_time', 'duration'],
['workers as total_offset', 'offset'],
],
},
{ headers: { 'X-Paginate': 'false' } },
)
).data;
},
},
async created() {
const projectId = this.$route.params['id'];
this.project = (await this.projectService.getItem(projectId)).data;
this.statuses = (
await this.statusService.getWithFilters({ orderBy: ['order'] }, { headers: { 'X-Paginate': 'false' } })
).data.data;
this.tasks = (
await this.taskService.getWithFilters(
{
where: { project_id: projectId },
orderBy: ['relative_position'],
with: ['users', 'priority', 'project', 'can'],
withSum: [
['workers as total_spent_time', 'duration'],
['workers as total_offset', 'offset'],
],
},
{ headers: { 'X-Paginate': 'false' } },
)
).data.data;
},
mounted() {
if (this.$route.query.task) {
this.loadTask(+this.$route.query.task);
}
this.checkScreenSize();
window.addEventListener('resize', this.checkScreenSize);
window.addEventListener('click', this.handleClick);
document.querySelector('.drag-container').addEventListener('scroll', this.onDragContainerScroll);
},
beforeDestroy() {
window.removeEventListener('click', this.handleClick);
window.removeEventListener('resize', this.checkScreenSize);
window.removeEventListener('touchstart', this.onDown);
window.removeEventListener('mousedown', this.onDownMouse);
document.querySelector('.drag-container').removeEventListener('scroll', this.onDragContainerScroll);
},
directives: {
customScroll: {
bind: (el, binding) => {
const extraWidth = 100;
const scrollbar = document.createElement('div');
el.appendChild(scrollbar);
scrollbar.style.height = '20px';
const targetSelector = binding.value.targetSelector;
setTimeout(() => {
const width = document.querySelector(targetSelector).scrollWidth + 100;
if (scrollbar && width !== null) {
scrollbar.style.width = `${width}px`;
}
}, 1000);
el.addEventListener('scroll', e => {
const targetElement = document.querySelector(targetSelector);
if (targetElement) {
targetElement.scrollLeft = e.target.scrollLeft;
}
});
},
},
},
};
</script>
<style lang="scss" scoped>
.task-view-description {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-height: 290px;
}
.tag-priority {
margin: 5px 0;
}
.crud__content {
padding: 0 !important;
}
.scrollbar-top {
overflow: scroll;
width: 100%;
height: 16px;
position: fixed;
bottom: 4px;
left: 0;
z-index: 1;
}
.handle {
touch-action: none;
}
.task.handle > * {
pointer-events: none;
}
.at-container {
position: relative;
}
.control-item:not(:last-child) {
margin-right: 16px;
}
.status {
width: 100%;
padding: 16px;
display: flex;
justify-content: space-between;
min-width: 300px;
align-items: flex-start;
h3 {
font-size: $font-size-lger;
padding: 10px;
flex: 1;
color: inherit;
text-align: center;
}
}
.task {
background: #ffffff;
padding: 16px;
cursor: default;
overflow: hidden;
&-description {
height: 24px;
overflow: hidden;
}
.move-task {
display: flex;
align-self: flex-start;
color: $black-900;
font-size: 1rem;
font-weight: bold;
padding: 10px;
}
&-users {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
margin-left: auto;
}
}
.hide-on-mobile {
display: none;
}
.task-view {
position: fixed;
bottom: 0;
right: 0;
display: flex;
z-index: 1;
flex-flow: column nowrap;
justify-content: space-between;
background: #ffffff;
border: 1px solid #c5d9e8;
border-radius: 4px;
height: 100vh;
overflow: hidden;
padding: 16px;
max-width: 500px;
&-header {
position: relative;
padding: 32px;
}
&-title {
margin-bottom: 16px;
}
&-close {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
widows: 32px;
height: 32px;
cursor: pointer;
}
.row {
margin: 0 32px;
padding-bottom: 16px;
}
.row:not(:last-child) {
border-bottom: 1px solid #eeeef5;
margin-bottom: 16px;
}
.label {
font-weight: bold;
}
}
.project-tasks_kanban {
padding: 16px;
overflow-y: hidden;
}
.project-tasks_kanban ::v-deep {
.drag-container {
overflow: hidden;
}
ul.drag-list,
ul.drag-inner-list {
list-style-type: none;
margin: 0;
padding: 0;
}
.drag-list {
display: flex;
align-items: stretch;
min-height: calc(100vh - 250px);
}
.drag-column {
flex: 1;
flex-shrink: 0;
flex-basis: 400px;
max-width: 400px;
position: relative;
border: 1px solid #c5d9e8;
border-radius: 6px;
background-color: rgb(246, 248, 250);
h2 {
font-size: 0.8rem;
margin: 0;
text-transform: uppercase;
font-weight: 600;
}
}
.drag-column:not(:last-child) {
margin-right: 16px;
}
.drag-column-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.drag-inner-list {
min-height: 50px;
height: 100%;
color: white;
}
.drag-item {
margin: 16px;
border: 1px solid #c5d9e8;
border-radius: 6px;
transition: border 0.2s;
overflow: hidden;
&:hover {
border-color: #79a1eb;
}
}
.drag-header-more {
cursor: pointer;
}
/* Dragula CSS */
.gu-mirror {
position: fixed !important;
margin: 0 !important;
z-index: 9999 !important;
opacity: 0.8;
list-style-type: none;
}
.gu-mirror.withoutoffset {
transform: translate(-50%, -50%);
}
.gu-hide {
display: none !important;
}
.gu-unselectable {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
}
.gu-transit {
opacity: 0.2;
}
}
.total-time-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 21px;
color: $black-900;
font-size: 1rem;
font-weight: bold;
flex-wrap: nowrap;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 250ms ease;
}
.slide-enter,
.slide-leave-to {
transform: translate(100%, 0);
}
.at-tag {
vertical-align: middle;
display: inline-flex;
align-items: center;
}
.project-tasks_kanban {
overflow-x: auto;
}
@media (max-width: 900px) {
.crud {
overflow: auto;
}
.project-tasks_kanban ::v-deep .drag-item {
margin: 6px;
}
.scrollbar-top {
display: none;
}
.project-tasks_kanban ::v-deep {
.drag-container {
overflow: auto;
}
scroll-snap-type: x mandatory;
}
.task ::v-deep {
padding: 8px;
}
.task-view {
padding-top: 60px;
left: 0;
}
.task-name {
font-size: $font-size-normal;
}
.task-description {
font-size: $font-size-base;
}
.task-users {
font-size: $font-size-smer;
}
.total-time-row {
font-size: 10px;
}
.project-tasks_kanban ::v-deep .drag-column {
scroll-snap-align: center;
flex-shrink: 0;
}
}
@media (max-width: $screen-sm) {
.crud {
overflow: auto;
}
.project-tasks_kanban ::v-deep .drag-item {
margin: 4px;
}
.scrollbar-top {
display: none;
}
.project-tasks_kanban ::v-deep {
.drag-container {
overflow: auto;
}
scroll-snap-type: x mandatory;
}
.task ::v-deep {
padding: 8px;
}
.task-view {
padding-top: 60px;
width: auto;
left: 0;
}
.task-name {
font-size: $font-size-normal;
}
.task-description {
font-size: $font-size-base;
}
.task-users {
font-size: $font-size-smer;
}
.total-time-row {
font-size: 10px;
}
.project-tasks_kanban ::v-deep .drag-column {
scroll-snap-align: center;
flex-shrink: 0;
}
}
@media only screen and (max-width: $screen-xs) {
.crud {
overflow: auto;
}
.status {
min-width: 100%;
}
.scrollbar-top {
display: none;
}
.project-tasks_kanban ::v-deep .drag-item {
margin: 2px;
}
.project-tasks_kanban ::v-deep {
.drag-container {
overflow: auto;
}
scroll-snap-type: x mandatory;
}
.task-view {
padding-top: 60px;
left: 0;
max-width: 100%;
width: 100%;
margin: 0;
border-radius: 0;
}
.task ::v-deep {
padding: 8px;
}
.task-name {
font-size: $font-size-normal;
}
.task-description {
font-size: $font-size-base;
}
.task-users {
font-size: $font-size-smer;
}
.total-time-row {
font-size: 7px;
}
.project-tasks_kanban ::v-deep .drag-column {
flex: 0 0 90%;
margin-right: 10px;
scroll-snap-align: center;
max-width: 100%;
// flex-basis: 100%;
flex-shrink: 0;
}
}
.button-kanban {
aspect-ratio: 1;
color: inherit;
}
.button-kanban:hover {
color: #999 !important;
}
</style>

View File

@@ -0,0 +1,5 @@
{
"navigation": {
"screenshots": "Screenshots"
}
}

View File

@@ -0,0 +1,5 @@
{
"navigation": {
"screenshots": "Скриншоты"
}
}

View File

@@ -0,0 +1,31 @@
export const ModuleConfig = {
routerPrefix: 'screenshots',
loadOrder: 20,
moduleName: 'Screenshots',
};
export function init(context) {
context.addRoute({
path: '/screenshots',
name: 'screenshots',
component: () => import(/* webpackChunkName: "screenshots" */ './views/Screenshots.vue'),
meta: {
auth: true,
},
});
context.addNavbarEntry({
label: 'navigation.screenshots',
to: {
name: 'screenshots',
},
displayCondition: store => store.getters['screenshots/enabled'],
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,308 @@
<template>
<div class="screenshots">
<h1 class="page-title">{{ $t('navigation.screenshots') }}</h1>
<div class="controls-row">
<div class="controls-row__item">
<Calendar :sessionStorageKey="sessionStorageKey" @change="onCalendarChange" />
</div>
<div class="controls-row__item">
<UserSelect @change="onUsersChange" />
</div>
<div class="controls-row__item">
<ProjectSelect @change="onProjectsChange" />
</div>
<div class="controls-row__item">
<TimezonePicker :value="timezone" @onTimezoneChange="onTimezoneChange" />
</div>
</div>
<div class="at-container">
<div class="at-container__inner">
<template v-if="this.intervals.length > 0">
<div class="row">
<div v-for="interval in this.intervals" :key="interval.id" class="col-4 screenshots__card">
<Screenshot
class="screenshot"
:disableModal="true"
:interval="interval"
:task="interval.task"
:user="modal.user"
:timezone="timezone"
@click="showImage(interval)"
/>
</div>
</div>
<ScreenshotModal
:project="modal.project"
:interval="modal.interval"
:show="modal.show"
:showNavigation="true"
:task="modal.task"
:user="modal.user"
@close="onHide"
@remove="removeScreenshot"
@showNext="showNext"
@showPrevious="showPrevious"
/>
</template>
<div v-else class="no-data">
<span>{{ $t('message.no_data') }}</span>
</div>
<preloader v-if="isDataLoading"></preloader>
</div>
</div>
<div class="screenshots__pagination">
<at-pagination
:total="intervalsTotal"
:current="page"
:page-size="limit"
@page-change="loadPage"
></at-pagination>
</div>
</div>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex';
import Calendar from '@/components/Calendar';
import Screenshot from '@/components/Screenshot';
import ScreenshotModal from '@/components/ScreenshotModal';
import UserSelect from '@/components/UserSelect';
import ProjectService from '@/services/resource/project.service';
import TimeIntervalService from '@/services/resource/time-interval.service';
import { getStartOfDayInTimezone, getEndOfDayInTimezone, getDateWithTimezoneDifference } from '@/utils/time';
import Preloader from '@/components/Preloader';
import ProjectSelect from '@/components/ProjectSelect';
import TimezonePicker from '@/components/TimezonePicker';
export default {
name: 'Screenshots',
components: {
Calendar,
Screenshot,
ScreenshotModal,
UserSelect,
Preloader,
ProjectSelect,
TimezonePicker,
},
data() {
const limit = 15;
const localStorageKey = 'user-select.users';
const sessionStorageKey = 'amazingcat.session.storage.screenshots';
return {
intervals: [],
userIDs: null,
projectsList: [],
datepickerDateStart: '',
datepickerDateEnd: '',
projectService: new ProjectService(),
intervalService: new TimeIntervalService(),
modal: {
show: false,
},
limit: limit,
page: 1,
intervalsTotal: 0,
localStorageKey: localStorageKey,
sessionStorageKey: sessionStorageKey,
isDataLoading: false,
};
},
computed: {
...mapGetters('dashboard', ['timezone']),
...mapGetters('timeline', ['service', 'users']),
...mapGetters('user', ['user', 'companyData']),
},
watch: {
companyData() {
this.getScreenshots();
},
timezone() {
this.getScreenshots();
},
},
async created() {
window.addEventListener('keydown', this.onKeyDown);
try {
await this.getScreenshots();
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.log(response ? response : 'request to screenshots is canceled');
}
}
},
beforeDestroy() {
window.removeEventListener('keydown', this.onKeyDown);
},
methods: {
getStartOfDayInTimezone,
getEndOfDayInTimezone,
...mapMutations({
setTimezone: 'dashboard/setTimezone',
}),
onTimezoneChange(timezone) {
this.setTimezone(timezone);
},
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 currentIndex = this.intervals.findIndex(x => x.id === this.modal.interval.id);
if (currentIndex !== 0) {
this.modal.interval = this.intervals[currentIndex - 1];
}
},
showNext() {
const currentIndex = this.intervals.findIndex(x => x.id === this.modal.interval.id);
if (currentIndex + 1 !== this.intervals.length) {
this.modal.interval = this.intervals[currentIndex + 1];
}
},
showImage(interval) {
this.modal = {
interval,
user: interval.user,
task: interval.task,
project: interval.task?.project,
show: true,
};
},
onUsersChange(userIDs) {
this.userIDs = userIDs;
if (this._isMounted) {
this.getScreenshots();
}
},
onProjectsChange(projectIDs) {
this.projectsList = projectIDs;
if (this._isMounted) {
this.getScreenshots();
}
},
onCalendarChange({ start, end }) {
this.datepickerDateStart = start;
this.datepickerDateEnd = end;
this.getScreenshots();
},
async getScreenshots() {
if (
this.userIDs === 'undefined' ||
!this.datepickerDateStart ||
!this.datepickerDateEnd ||
!this.timezone ||
!this.companyData.timezone
) {
return;
}
this.isDataLoading = true;
const startAt = getDateWithTimezoneDifference(
this.datepickerDateStart,
this.companyData.timezone,
this.timezone,
);
const endAt = getDateWithTimezoneDifference(
this.datepickerDateEnd,
this.companyData.timezone,
this.timezone,
false,
);
try {
const { data } = await this.intervalService.getAll({
where: {
user_id: ['in', this.userIDs],
'task.project_id': ['in', this.projectsList],
start_at: ['between', [startAt, endAt]],
},
page: this.page,
with: ['task', 'task.project', 'user'],
});
this.intervalsTotal = data.pagination.total;
this.intervals = data.data;
} catch ({ response }) {
return;
}
this.isDataLoading = false;
},
async removeScreenshot(id) {
try {
await this.intervalService.deleteItem(id);
this.$Notify({
type: 'success',
title: this.$t('notification.screenshot.delete.success.title'),
message: this.$t('notification.screenshot.delete.success.message'),
});
this.intervals = this.intervals.filter(interval => interval.id !== id);
this.onHide();
} catch (e) {
this.$Notify({
type: 'error',
title: this.$t('notification.screenshot.delete.error.title'),
message: this.$t('notification.screenshot.delete.error.message'),
});
}
},
async loadPage(page) {
this.page = page;
await this.getScreenshots();
},
},
};
</script>
<style lang="scss" scoped>
.at-container {
overflow: hidden;
margin-bottom: $layout-01;
&__inner {
position: relative;
}
}
.screenshots {
&__card {
margin-bottom: $layout-02;
cursor: pointer;
}
&__pagination {
flex-direction: row-reverse;
display: flex;
}
}
.screenshot::v-deep {
.screenshot-image {
img {
height: 150px;
border-radius: 5px;
}
}
}
.no-data {
position: relative;
text-align: center;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,280 @@
<template>
<div class="colors">
<ul v-if="colorsConfig.length > 0">
<li v-for="(config, index) in colorsConfig" :key="index" class="color-readiness__item">
<at-input
:value="getPercent(config)"
class="color-readiness__start"
type="number"
placeholder="Start percent"
:min="0"
:max="100"
@blur="setStart(index, $event)"
>
<template slot="append">
<span v-if="config.start < 1">%</span>
<span v-else>{{ $t('Over Time') }}</span>
</template>
</at-input>
<at-input
v-if="config.start < 1"
:value="parseInt(config.end * 100)"
class="color-readiness__end"
type="number"
placeholder="End percent"
:min="1"
:max="100"
@blur="setEnd(index, $event)"
>
<template slot="append">
<span>%</span>
</template>
</at-input>
<div class="color-readiness__color">
<ColorInput
class="color-input at-input__original"
:value="config.color"
@change="setColor(config, $event)"
/>
</div>
<at-button class="color-readiness__remove" @click.prevent="remove(index)">
<span class="icon icon-x"></span>
</at-button>
</li>
</ul>
<at-button-group>
<at-button class="color-readiness__add" :disabled="isDisabledAddButton" @click.prevent="add">{{
$t('control.add')
}}</at-button>
<at-button class="color-readiness__reset" @click.prevent="reset">{{ $t('control.reset') }}</at-button>
</at-button-group>
</div>
</template>
<script>
import ColorInput from '@/components/ColorInput';
const defaultInterval = { color: '#3ba8da', start: 0, end: 0.1 };
export default {
props: {
colorsConfig: {
required: true,
},
},
components: {
ColorInput,
},
data() {
return {};
},
computed: {
usedIntervals() {
return this.colorsConfig.map(el => ({
start: parseInt(el.start * 100),
end: parseInt(el.end * 100),
}));
},
freeIntervals() {
if (!this.usedIntervals.length) {
return [{ start: 0, end: 100 }];
}
const lastIndex = this.usedIntervals.length - 1;
return this.usedIntervals.reduce(
(accum, curEl, i, arr) => {
const index = i === arr.length - 1 ? i : i + 1;
//if have OverTime
if (lastIndex === index && curEl.start === 100 && curEl.end === 0) {
return accum;
}
//if the first interval doesn't start from null
if (i === 0 && curEl.start !== 0) {
accum[i].end = curEl.start - 1;
}
//if first interval starts from null, then remove the default intterval
if (i === 0 && accum[i].end === 0) {
accum.splice(i, 1);
}
//if not have last free interval
if (lastIndex === i && curEl.end !== 100) {
return [...accum, { start: curEl.end + 1, end: 100 }];
}
//if there's no Overtime, we can add it
if (lastIndex === i && curEl.start !== 100) {
return [...accum, { start: 100, end: '' }];
}
// if the interval is 100
if (arr[index].start === curEl.end) {
return accum;
}
// if there's free interval
if (arr[index].start - 1 !== curEl.end) {
return [...accum, { start: curEl.end + 1, end: arr[index].start - 1 }];
}
return accum;
},
[{ start: 0, end: 0 }],
);
},
isDisabledAddButton() {
return this.freeIntervals.length === 0;
},
},
methods: {
add() {
let interval = defaultInterval;
if (this.freeIntervals.length > 0) {
interval = {
start: this.freeIntervals[0].start / 100,
end: this.freeIntervals[0].end / 100,
color: '#3ba8da',
};
}
this.$emit('addColorReadiness', [interval]);
},
remove(index) {
this.$emit('onRemoveRelation', index);
},
setEnd(index, ev) {
const newEnd = ev.target.valueAsNumber;
if (this.colorsConfig[index].end === newEnd / 100) {
return;
}
const haveThisInterval = this.usedIntervals.filter((el, i) => {
if (index !== i) {
return newEnd >= el.start && newEnd <= el.end;
}
});
if (haveThisInterval.length > 0) {
this.$Notify({
type: 'error',
title: this.$t('message.error'),
message: this.$t('settings.color_interval.notification.interval_already_in_use'),
});
this.$forceUpdate();
return;
}
this.$emit('setEnd', index, newEnd / 100);
},
setStart(index, ev) {
const newStart = ev.target.valueAsNumber;
if (this.colorsConfig[index].start === newStart / 100) {
return;
}
if (newStart >= 100) {
const haveOverTime = this.colorsConfig.filter(el => el.start === 1).length;
if (haveOverTime > 0) {
this.$Notify({
type: 'error',
title: this.$t('message.error'),
message: this.$t('settings.color_interval.notification.gt_100'),
});
this.$forceUpdate();
return;
}
}
const haveThisInterval = this.usedIntervals.filter((el, i) => {
if (index !== i) {
return newStart >= el.start && newStart <= el.end;
}
});
if (haveThisInterval.length > 0) {
this.$Notify({
type: 'error',
title: this.$t('message.error'),
message: this.$t('settings.color_interval.notification.interval_already_in_use'),
});
this.$forceUpdate();
return;
}
this.$emit('setStart', index, newStart / 100);
if (newStart === 100) {
this.$emit('setEnd', index, 0);
} else if (this.colorsConfig[index].end === 0) {
//eslint-disable-next-line vue/no-mutating-props
this.colorsConfig[index].end = 1;
}
},
getPercent(config) {
if (config.start >= 1) {
config.isOverTime = true;
this.$emit('setOverTime', this.colorsConfig);
}
if (config.start < 1 && 'isOverTime' in config) {
this.$delete(config, 'isOverTime');
this.$emit('setOverTime', this.colorsConfig);
}
return parseInt(config.start * 100);
},
setColor(config, event) {
this.$set(config, 'color', event);
},
reset() {
this.$emit('reset');
},
},
};
</script>
<style lang="scss" scoped>
.color-input {
width: 170px;
height: 40px;
cursor: pointer;
border-radius: 5px;
padding: 0px;
border: none;
}
.color-readiness {
&__item {
display: flex;
flex-flow: row nowrap;
}
&__start,
&__end,
&__color {
flex: 1;
margin-right: 0.5em;
margin-bottom: 0.75em;
}
&__remove {
height: 40px;
}
&__color {
max-width: 170px;
}
}
input[type='color' i]::-webkit-color-swatch-wrapper,
input[type='color' i]::-webkit-color-swatch {
padding: 0px;
border: none;
}
</style>

View File

@@ -0,0 +1,12 @@
{
"settings": {
"company_language": "Company language",
"color_interval": {
"label": "Progress of the working day",
"notification": {
"gt_100": "The value can't be greater than 100",
"interval_already_in_use": "This interval already in use"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"settings": {
"company_language": "Язык компании",
"color_interval": {
"label": "Прогресс рабочего дня",
"notification" : {
"gt_100": "Значение не может быть больше 100",
"interval_already_in_use": "Этот интервал уже используется"
}
}
}
}

View File

@@ -0,0 +1,24 @@
import { hasRole } from '@/utils/user';
export const ModuleConfig = {
routerPrefix: 'settings',
loadOrder: 10,
moduleName: 'Settings',
};
export function init(context) {
const sectionGeneral = require('./sections/general');
context.addCompanySection(sectionGeneral.default);
context.addUserMenuEntry({
label: 'navigation.company_settings',
icon: 'icon-settings',
to: { name: 'company.settings.general' },
displayCondition: store => hasRole(store.getters['user/user'], 'admin'),
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,201 @@
import LanguageSelector from '@/components/LanguageSelector';
import TimezonePicker from '@/components/TimezonePicker';
import CompanyService from '../services/company.service';
import ColorSelect from '../components/ColorSelect';
import PrioritySelect from '@/components/PrioritySelect';
import ScreenshotsStateSelect from '@/components/ScreenshotsStateSelect';
import { store } from '@/store';
import { hasRole } from '@/utils/user';
export default {
// Check if this section can be rendered and accessed, this param IS OPTIONAL (true by default)
// NOTICE: this route will not be added to VueRouter AT ALL if this check fails
// MUST be a function that returns a boolean
accessCheck: async () => hasRole(store.getters['user/user'], 'admin'),
scope: 'company',
order: 0,
route: {
// After processing this route will be named as 'settings.exampleSection'
name: 'company.settings.general',
// After processing this route can be accessed via URL 'settings/example'
path: '/company/general',
meta: {
// After render, this section will be labeled as 'Example Section'
label: 'settings.general',
// Service class to gather the data from API, should be an instance of Resource class
service: new CompanyService(),
// Renderable fields array
fields: [
{
label: 'settings.company_timezone',
key: 'timezone',
render: (h, props) => {
const value = props.values.timezone ?? 'UTC';
return h(TimezonePicker, {
props: {
value,
},
on: {
onTimezoneChange(ev) {
props.inputHandler(ev);
},
},
});
},
},
{
label: 'field.work_time',
key: 'work_time',
maxValue: 24,
minValue: 0,
fieldOptions: {
type: 'number',
placeholder: 'field.work_time',
},
tooltipValue: 'tooltip.work_time',
},
{
label: 'settings.color_interval.label',
key: 'color',
displayable: store =>
'work_time' in store.getters['user/companyData'] && store.getters['user/companyData'].work_time,
tooltipValue: 'tooltip.color_intervals',
render(h, props) {
const defaultConfig = [
{
start: 0,
end: 0.75,
color: '#ffb6c2',
},
{
start: 0.76,
end: 1,
color: '#93ecda',
},
{
start: 1,
end: 0,
color: '#3cd7b6',
isOverTime: true,
},
];
if (!Array.isArray(props.currentValue)) {
'color' in props.companyData
? (props.currentValue = props.companyData.color)
: (props.currentValue = defaultConfig);
this.inputHandler(props.currentValue);
}
return h(ColorSelect, {
props: {
colorsConfig: props.currentValue,
},
on: {
addColorReadiness(data) {
props.inputHandler(
[...props.currentValue, ...data].sort((a, b) => {
return a.start - b.start;
}),
);
},
onRemoveRelation(index) {
props.currentValue.splice(index, 1);
props.inputHandler(props.currentValue);
},
setOverTime(data) {
props.inputHandler(data);
},
reset() {
props.inputHandler(defaultConfig);
},
setStart(index, newStart) {
props.currentValue[index].start = newStart;
props.inputHandler(props.currentValue);
},
setEnd(index, newEnd) {
props.currentValue[index].end = newEnd;
props.inputHandler(props.currentValue);
},
},
});
},
},
{
label: 'field.auto_thin',
key: 'auto_thinning',
fieldOptions: {
type: 'switch',
placeholder: 'field.auto_thin',
},
tooltipValue: 'tooltip.auto_thin',
},
{
label: 'field.screenshots_state',
key: 'screenshots_state',
render: (h, props) => {
return h(ScreenshotsStateSelect, {
props: {
value: store.getters['screenshots/getCompanyStateWithOverrides'](
props.values.screenshots_state,
),
isDisabled: store.getters['screenshots/isCompanyStateLocked'],
hideIndexes: [0],
},
on: {
input(value) {
props.inputHandler(value);
},
},
});
},
},
{
label: 'field.default_priority',
key: 'default_priority_id',
render: (h, props) => {
const value = props.values.default_priority_id ?? 0;
return h(PrioritySelect, {
props: {
value,
clearable: true,
},
on: {
input(value) {
props.inputHandler(value);
},
},
});
},
},
{
label: 'settings.company_language',
key: 'language',
render: (h, props) => {
const lang = props.values.language ?? 'en';
return h(LanguageSelector, {
props: {
value: lang,
},
on: {
setLanguage(lang) {
props.inputHandler(lang);
},
},
});
},
},
],
},
},
};

View File

@@ -0,0 +1,35 @@
import axios from '@/config/app';
import SettingsService from '@/services/settings.service';
/**
* Section service class.
* Used to fetch data from api for inside DynamicSettings.vue
* Data is stored inside store -> settings -> sections -> data
*/
export default class CompanyService extends SettingsService {
/**
* API endpoint URL
* @returns string
*/
getItemRequestUri() {
return `company-settings`;
}
/**
* Fetch item data from api endpoint
* @returns {data}
*/
async getAll() {
return (await axios.get(this.getItemRequestUri(), { ignoreCancel: true })).data.data;
}
/**
* Save item data
* @param data
* @returns {Promise<void>}
*/
async save(payload) {
const { data } = await axios.patch(this.getItemRequestUri(), payload);
return data;
}
}

View File

@@ -0,0 +1,13 @@
{
"navigation": {
"statuses": "Statuses"
},
"statuses": {
"grid-title": "Statuses",
"crud-title": "Status"
},
"field": {
"order": "Order",
"close_task": "Close task"
}
}

View File

@@ -0,0 +1,13 @@
{
"navigation": {
"statuses": "Статусы"
},
"statuses": {
"grid-title": "Статусы",
"crud-title": "Статус"
},
"field": {
"order": "Порядок",
"close_task": "Закрывать задачу"
}
}

View File

@@ -0,0 +1,16 @@
export const ModuleConfig = {
routerPrefix: 'settings',
loadOrder: 10,
moduleName: 'Statuses',
};
export function init(context, router) {
context.addCompanySection(require('./sections/statuses').default(context, router));
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,258 @@
import cloneDeep from 'lodash/cloneDeep';
import i18n from '@/i18n';
import { store } from '@/store';
import StatusService from '../services/statuse.service';
import Statuses from '../views/Statuses';
import ColorInput from '@/components/ColorInput';
import { hasRole } from '@/utils/user';
import Vue from 'vue';
import { throttle } from 'lodash';
export default (context, router) => {
const statusesContext = cloneDeep(context);
statusesContext.routerPrefix = 'company/statuses';
const crud = statusesContext.createCrud('statuses.crud-title', 'statuses', StatusService);
const crudEditRoute = crud.edit.getEditRouteName();
const crudNewRoute = crud.new.getNewRouteName();
const navigation = { edit: crudEditRoute, new: crudNewRoute };
const statusOrder = throttle(async (data, direction) => {
const { gridView } = data;
const { tableData } = gridView;
const service = new StatusService();
const index = tableData.findIndex(item => item.id === data.item.id);
const item = tableData[index];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
const targetItem = tableData[targetIndex];
await service.save({ ...targetItem, order: item.order });
Vue.set(tableData, index, { ...targetItem, order: item.order });
Vue.set(tableData, targetIndex, { ...item, order: targetItem.order });
}, 1000);
crud.new.addToMetaProperties('permissions', 'statuses/create', crud.new.getRouterConfig());
crud.new.addToMetaProperties('navigation', navigation, crud.new.getRouterConfig());
crud.new.addToMetaProperties('afterSubmitCallback', () => router.go(-1), crud.new.getRouterConfig());
crud.edit.addToMetaProperties('permissions', 'statuses/edit', crud.edit.getRouterConfig());
const grid = statusesContext.createGrid('statuses.grid-title', 'statuses', StatusService, {
orderBy: ['order', 'asc'],
});
grid.addToMetaProperties('navigation', navigation, grid.getRouterConfig());
grid.addToMetaProperties('permissions', () => hasRole(store.getters['user/user'], 'admin'), grid.getRouterConfig());
const fieldsToFill = [
{
key: 'id',
displayable: false,
},
{
label: 'field.name',
key: 'name',
type: 'input',
required: true,
placeholder: 'field.name',
},
{
label: 'field.close_task',
key: 'active',
required: false,
initialValue: true,
render: (h, data) => {
return h('at-checkbox', {
props: {
checked: typeof data.currentValue === 'boolean' ? !data.currentValue : false,
},
on: {
'on-change'(value) {
data.inputHandler(!value);
},
},
});
},
},
{
label: 'field.color',
key: 'color',
required: false,
render: (h, data) => {
return h(ColorInput, {
props: {
value: typeof data.currentValue === 'string' ? data.currentValue : 'transparent',
},
on: {
change(value) {
data.inputHandler(value);
},
},
});
},
},
];
crud.edit.addField(fieldsToFill);
crud.new.addField(fieldsToFill);
grid.addColumn([
{
title: 'field.name',
key: 'name',
},
{
title: 'field.order',
key: 'order',
render(h, data) {
const index = data.gridView.tableData.findIndex(item => item.id === data.item.id);
const result = [];
result.push(
h(
'at-button',
{
style: {
marginRight: '8px',
},
on:
index > 0
? {
click: function () {
statusOrder(data, 'up');
},
}
: {},
props:
index > 0
? {}
: {
disabled: true,
},
},
[h('i', { class: 'icon icon-chevrons-up' })],
),
);
result.push(
h(
'at-button',
{
style: {
marginRight: '8px',
},
on:
index < data.gridView.tableData.length - 1
? {
click: function () {
statusOrder(data, 'down');
},
}
: {},
props:
index < data.gridView.tableData.length - 1
? {}
: {
disabled: true,
},
},
[h('i', { class: 'icon icon-chevrons-down' })],
),
);
return result;
},
},
{
title: 'field.close_task',
key: 'active',
render(h, { item }) {
return h('span', [i18n.t('control.' + (!item.active ? 'yes' : 'no'))]);
},
},
{
title: 'field.color',
key: 'color',
render(h, { item }) {
return h(
'span',
{
style: {
display: 'flex',
alignItems: 'center',
},
},
[
h('span', {
style: {
display: 'inline-block',
background: item.color,
borderRadius: '4px',
width: '16px',
height: '16px',
margin: '0 4px 0 0',
},
}),
h('span', {}, [item.color]),
],
);
},
},
]);
grid.addAction([
{
title: 'control.edit',
icon: 'icon-edit',
onClick: (router, { item }, context) => {
context.onEdit(item);
},
renderCondition: ({ $can }, item) => {
return $can('update', 'status', item);
},
},
{
title: 'control.delete',
actionType: 'error',
icon: 'icon-trash-2',
onClick: async (router, { item }, context) => {
context.onDelete(item);
},
renderCondition: ({ $can }, item) => {
return $can('delete', 'status', item);
},
},
]);
grid.addPageControls([
{
label: 'control.create',
type: 'primary',
icon: 'icon-edit',
onClick: ({ $router }) => {
$router.push({ name: crudNewRoute });
},
},
]);
return {
accessCheck: async () => hasRole(store.getters['user/user'], 'admin'),
scope: 'company',
order: 20,
component: Statuses,
route: {
name: 'Statuses.crud.statuses',
path: '/company/statuses',
meta: {
label: 'navigation.statuses',
service: new StatusService(),
},
children: [
{
...grid.getRouterConfig(),
path: '',
},
...crud.getRouterConfig(),
],
},
};
};

View File

@@ -0,0 +1,32 @@
import axios from '@/config/app';
import ResourceService from '@/services/resource.service';
export default class StatusService extends ResourceService {
getAll(config = {}) {
return axios.get('statuses/list', config);
}
getItemRequestUri(id) {
return `statuses/show?id=${id}`;
}
getItem(id, filters = {}) {
return axios.get(this.getItemRequestUri(id));
}
save(data, isNew = false) {
if (typeof data.active === 'undefined') {
data.active = true;
}
return axios.post(`statuses/${isNew ? 'create' : 'edit'}`, data);
}
deleteItem(id) {
return axios.post('statuses/remove', { id });
}
getWithFilters(filters, config = {}) {
return axios.post('statuses/list', filters, config);
}
}

View File

@@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>

View File

@@ -0,0 +1,407 @@
<template>
<div class="attachments-wrapper">
<at-table :columns="columns" :data="rows"></at-table>
<div v-if="showControls" class="row">
<div class="upload-wrapper col-24">
<validation-observer ref="form" v-slot="{}">
<validation-provider ref="file_validation_provider" v-slot="{ errors }" mode="passive">
<at-input ref="file_input" class="attachments-input" name="attachments-files" type="file" />
<p>{{ errors[0] }}</p>
</validation-provider>
</validation-observer>
<at-button
class="offline-sync__upload-btn"
size="large"
icon="icon-upload"
type="primary"
:loading="isLoading"
:disabled="isLoading"
@click="uploadFiles"
>{{ $t('attachments.upload') }}
</at-button>
<div v-if="uploadQueue.length > 0">
<h3>{{ $t('attachments.upload_queue') }}</h3>
<div v-for="(file, index) in uploadQueue" :key="index">
<span :class="{ 'upload-failed': file.errorOnUpload }">{{ index + 1 }}) {{ file.name }}</span>
<div v-if="currentUploadIndex === index && uploadProgress != null" class="file-upload-progress">
<at-progress :percent="uploadProgress.progress" :stroke-width="15" />
<span class="file-upload-progress__total">{{ uploadProgress.humanReadable }}</span>
<span class="file-upload-progress__speed">{{ uploadProgress.speed }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { humanFileSize } from '@/utils/file';
import { ValidationObserver, ValidationProvider } from 'vee-validate';
import TasksService from '@/services/resource/task.service';
import Vue from 'vue';
import i18n from '@/i18n';
import { hasRole } from '@/utils/user';
const attachmentStatus = {
NOT_ATTACHED: 0, // file just uploaded, attachmentable not set yet, not calculating hash
PROCESSING: 1, //moving file to correct project folder and then calculating hash
GOOD: 2, // file hash matched (on cron check and initial upload)
BAD: 3, //file hash NOT matched (on cron check)
};
export default {
name: 'Attachments',
components: { ValidationObserver, ValidationProvider },
props: {
value: {
type: Number,
default: 0,
},
attachments: {
type: Array,
required: true,
},
showControls: {
type: Boolean,
default: true,
},
},
data() {
const columns = [
{
title: this.$t('attachments.status'),
render: (h, params) => {
const statusInfo = this.getAttachmentStatusInfo(params.item.status);
return h(
'at-tooltip',
{
attrs: {
content: statusInfo.text,
placement: 'top-left',
},
},
[
h('i', {
class: {
'status-icon': true,
icon: true,
[statusInfo.class]: true,
},
}),
],
);
},
},
{
title: i18n.t('field.user'),
render: (h, { item }) => {
if (!hasRole(this.$store.getters['user/user'], 'admin')) {
return h('span', item.user.full_name);
}
return h(
'router-link',
{
props: {
to: {
name: 'Users.crud.users.view',
params: { id: item.user_id },
},
},
},
item.user.full_name,
);
},
},
{
title: this.$t('attachments.name'),
render: (h, params) => {
return h(
'span',
{
class: {
strikethrough: params.item?.toDelete ?? false,
},
},
[
h(
'a',
{
on: {
click: async e => {
e.preventDefault();
const url = await this.taskService.generateAttachmentTmpUrl(
params.item.id,
30,
);
window.open(url);
},
},
},
params.item.original_name,
),
],
);
},
},
{
title: this.$t('attachments.size'),
render: (h, params) => {
return h('span', humanFileSize(params.item.size, true));
},
},
];
if (this.showControls) {
columns.push({
title: '',
width: '40',
render: (h, params) => {
return h('AtButton', {
props: {
type: params.item.toDelete ? 'warning' : 'error',
icon: params.item.toDelete ? 'icon-rotate-ccw' : 'icon-trash-2',
size: 'small',
},
on: {
click: async () => {
this.$set(this.rows, params.item.index, {
...this.rows[params.item.index],
toDelete: !params.item.toDelete,
});
this.$emit('change', this.rows);
},
},
});
},
});
} else {
columns.push({
title: '',
width: '40',
render: (h, params) => {
return h(
'at-popover',
{
attrs: {
placement: 'left',
title: this.$i18n.t('attachments.create_tmp_url_for'),
},
},
[
h('AtButton', {
props: {
type: 'primary',
icon: 'icon-external-link',
size: 'smaller',
},
}),
h('div', { class: 'tmp-link-popover', slot: 'content' }, [
h(
'AtButton',
{
props: {
type: 'primary',
size: 'smaller',
},
on: {
click: async () => {
await this.handleTmpUrlCreation(params.item.id, 60 * 60);
},
},
},
`1${this.$t('time.h')}`,
),
h(
'AtButton',
{
props: {
type: 'primary',
size: 'smaller',
},
on: {
click: async () => {
await this.handleTmpUrlCreation(params.item.id, 60 * 60 * 7);
},
},
},
`7${this.$t('time.h')}`,
),
h(
'AtButton',
{
props: {
type: 'primary',
size: 'smaller',
},
on: {
click: async () => {
await this.handleTmpUrlCreation(params.item.id, 60 * 60 * 24 * 7);
},
},
},
`7${this.$t('time.d')}`,
),
h(
'AtButton',
{
props: {
type: 'primary',
size: 'smaller',
},
on: {
click: async () => {
await this.handleTmpUrlCreation(params.item.id, 60 * 60 * 24 * 14);
},
},
},
`14${this.$t('time.d')}`,
),
]),
],
);
},
});
}
return {
columns,
rows: this.attachments,
files: [],
taskService: new TasksService(),
uploadProgress: null,
uploadQueue: [],
currentUploadIndex: null,
isLoading: false,
};
},
mounted() {
if (this.showControls) {
this.$refs.file_input.$el.querySelector('input').setAttribute('multiple', 'multiple');
}
},
methods: {
async handleTmpUrlCreation(attachmentUUID, seconds = null) {
const url = await this.taskService.generateAttachmentTmpUrl(attachmentUUID, seconds);
await this.setClipboard(window.location.origin + url);
Vue.prototype.$Notify.success({
title: this.$i18n.t('attachments.tmp_url_created'),
message: this.$i18n.t('attachments.copied_to_clipboard'),
duration: 5000,
});
},
async setClipboard(text) {
const type = 'text/plain';
const blob = new Blob([text], { type });
const data = [new ClipboardItem({ [type]: blob })];
await navigator.clipboard.write(data);
},
getAttachmentStatusInfo(status) {
if (status === attachmentStatus.GOOD) {
return { class: 'icon-check-circle', text: this.$i18n.t('attachments.is_good') };
}
if (status === attachmentStatus.BAD) {
return { class: 'icon-slash', text: this.$i18n.t('attachments.is_bad') };
}
if (status === attachmentStatus.NOT_ATTACHED) {
return { class: 'icon-alert-circle', text: this.$i18n.t('attachments.is_not_attached') };
}
if (status === attachmentStatus.PROCESSING) {
return { class: 'icon-cpu', text: this.$i18n.t('attachments.is_processing') };
}
},
async uploadFiles() {
const files = this.$refs.file_input.$el.querySelector('input').files;
this.uploadQueue = Array.from(files).map(file => ({
errorOnUpload: false,
name: file.name,
size: file.size,
type: file.type,
}));
const { valid } = await this.$refs.file_validation_provider.validate(files);
for (let i = 0; i < files.length; i++) {
const file = files[i];
this.currentUploadIndex = i;
this.isLoading = true;
try {
const result = await this.taskService.uploadAttachment(file, this.onUploadProgress.bind(this));
if (result.success) {
this.rows.push(result.data);
this.$emit('change', this.rows);
} else {
this.$set(this.uploadQueue[i], 'errorOnUpload', true);
}
} catch (e) {
this.$set(this.uploadQueue[i], 'errorOnUpload', true);
}
}
this.isLoading = false;
},
onUploadProgress(progressEvent) {
this.uploadProgress = {
progress: +(progressEvent.progress * 100).toFixed(2),
loaded: progressEvent.loaded,
total: progressEvent.total,
humanReadable: `${humanFileSize(progressEvent.loaded, true)} / ${humanFileSize(
progressEvent.total,
true,
)}`,
speed: `${progressEvent.rate ? humanFileSize(progressEvent.rate, true) : '0 kB'}/s`,
};
},
},
watch: {
attachments: function (val) {
this.rows = val;
},
},
};
</script>
<style lang="scss" scoped>
.attachments-wrapper,
.upload-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
::v-deep {
.tmp-link-popover {
display: flex;
gap: 0.1rem;
}
.strikethrough {
text-decoration: line-through;
}
.status-icon {
font-size: 1rem;
&.icon-check-circle {
color: $color-success;
}
&.icon-slash {
color: $color-error;
}
&.icon-alert-circle {
color: $color-info;
}
&.icon-cpu {
color: $color-warning;
}
}
}
.upload-failed {
color: $color-error;
}
.file-upload-progress {
margin-top: 0.5rem;
font-size: 0.8rem;
&::v-deep .at-progress {
display: flex;
align-items: end;
&-bar {
flex-basis: 40%;
}
}
}
</style>

View File

@@ -0,0 +1,245 @@
<template>
<div ref="dateinput" class="dateinput" @click="togglePopup">
<div class="at-input">
<at-input class="input" :readonly="true" :value="inputValue" />
<transition name="slide-up">
<div
v-show="showPopup"
class="datepicker-wrapper at-select__dropdown at-select__dropdown--bottom"
@click.stop
>
<div class="datepicker__main">
<date-picker
class="datepicker"
:append-to-body="false"
:clearable="false"
:editable="false"
:inline="true"
:lang="datePickerLang"
type="day"
:value="datePickerValue"
:disabled-date="disabledDate"
@change="onDateChange"
/>
</div>
<div class="datepicker__footer">
<at-button size="small" @click="onDateChange(null)">{{ $t('tasks.unset_due_date') }}</at-button>
<at-button size="small" @click="showPopup = false">{{ $t('control.ok') }}</at-button>
</div>
</div>
</transition>
</div>
</div>
</template>
<script>
import moment from 'moment-timezone';
const DATETIME_FORMAT = 'YYYY-MM-DD';
export default {
name: 'DatetimeInput',
props: {
inputHandler: {
type: Function,
required: true,
},
value: {
type: String,
required: false,
},
},
data() {
return {
showPopup: false,
datePickerLang: {},
};
},
computed: {
datePickerValue() {
return this.value !== null ? moment(this.value).toDate() : null;
},
inputValue() {
return this.value ? moment(this.value).format(DATETIME_FORMAT) : this.$t('tasks.unset_due_date');
},
},
mounted() {
window.addEventListener('click', this.hidePopup);
this.inputHandler(this.value);
this.$emit('change', this.value);
this.$nextTick(async () => {
try {
const locale = await import(`vue2-datepicker/locale/${this.$i18n.locale}`);
this.datePickerLang = {
...locale,
formatLocale: {
...locale.formatLocale,
firstDayOfWeek: 1,
},
monthFormat: 'MMMM',
};
} catch {
this.datePickerLang = {
formatLocale: { firstDayOfWeek: 1 },
monthFormat: 'MMMM',
};
}
});
},
methods: {
togglePopup() {
this.showPopup = !this.showPopup;
},
hidePopup(event) {
if (event.target.closest('.dateinput') !== this.$refs.dateinput) {
this.showPopup = false;
}
},
onDateChange(value) {
const newValue = value !== null ? moment(value).format(DATETIME_FORMAT) : null;
this.inputHandler(newValue);
this.$emit('change', newValue);
},
disabledDate(date) {
return false;
},
},
};
</script>
<style lang="scss" scoped>
.datepicker-wrapper {
position: absolute;
width: 400px;
max-height: unset;
}
.datepicker__main {
display: flex;
flex-flow: row;
align-items: stretch;
height: 280px;
}
.datepicker__footer {
display: flex;
flex-flow: row;
justify-content: space-between;
padding: 6px 12px;
}
.datepicker {
flex: 1;
}
.dateinput::v-deep {
.mx-datepicker {
max-height: unset;
}
.mx-datepicker-main,
.mx-datepicker-inline {
border: none;
}
.mx-datepicker-header {
padding: 0;
border-bottom: none;
}
.mx-calendar {
width: unset;
}
.mx-calendar-content {
width: unset;
}
.mx-calendar-header {
& > .mx-btn-text {
padding: 0;
width: 34px;
text-align: center;
}
}
.mx-calendar-header-label .mx-btn {
color: #1a051d;
}
.mx-table thead {
color: #b1b1be;
font-weight: 600;
text-transform: uppercase;
}
.mx-week-number-header,
.mx-week-number {
display: none;
}
.mx-table-date td {
font-size: 13px;
}
.mx-table-date .cell:last-child {
color: #ff5569;
}
.mx-table {
.cell.not-current-month {
color: #e7ecf2;
}
.cell.active {
background: transparent;
& > div {
display: inline-block;
background: #2e2ef9;
color: #ffffff;
border-radius: 7px;
width: 25px;
height: 25px;
line-height: 25px;
}
}
}
.mx-table-month {
color: #000000;
.cell {
height: 50px;
}
.cell.active > div {
border-radius: 5px;
width: 54px;
height: 30px;
}
}
.mx-table-year {
color: #000000;
.cell.active > div {
width: 54px;
}
}
.mx-btn:hover {
color: #2e2ef9;
}
.mx-table .cell.today {
color: #2a90e9;
}
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div>
<at-select
v-if="options.length"
ref="select"
v-model="model"
:placeholder="$t('control.select')"
filterable
clearable="clearable"
>
<at-option v-for="option of options" :key="option.id" :label="option.name" :value="option.id" />
</at-select>
<at-input v-else disabled></at-input>
</div>
</template>
<script>
import GanttService from '@/services/resource/gantt.service';
export default {
name: 'PhaseSelect',
props: {
value: {
type: [String, Number],
default: '',
},
projectId: {
type: Number,
},
clearable: {
type: Boolean,
default: () => false,
},
},
created() {
this.loadOptions();
},
data() {
return {
options: [],
};
},
methods: {
async loadOptions() {
if (this.projectId === 0) {
this.options = [];
return;
}
try {
this.options = (await new GanttService().getPhases(this.projectId)).data.data.phases;
await this.$nextTick();
if (this.$refs.select && Object.prototype.hasOwnProperty.call(this.$refs.select, '$children')) {
this.$refs.select.$children.forEach(option => {
option.hidden = false;
});
}
} catch ({ response }) {
this.options = [];
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to resource is canceled');
}
}
},
},
watch: {
projectId() {
this.loadOptions();
},
},
computed: {
model: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
},
};
</script>

View File

@@ -0,0 +1,187 @@
<template>
<div class="relations">
<h5 class="relations__title">{{ $t('tasks.relations.follows') }}</h5>
<at-table :columns="columns" :data="follows" size="small"></at-table>
<h5 class="relations__title">{{ $t('tasks.relations.precedes') }}</h5>
<at-table :columns="columns" :data="precedes" size="small"></at-table>
<div v-if="this.showControls" class="relations__actions">
<at-select v-model="relationType" :placeholder="$t('tasks.relations.type')">
<at-option value="follows">{{ $t('tasks.relations.follows') }}</at-option>
<at-option value="precedes">{{ $t('tasks.relations.precedes') }}</at-option>
</at-select>
<task-selector :value="selectedTask" :project-id="projectId" @change="handleTaskSelection" />
<at-button :disabled="addBtnDisabled" class="relations__add-btn" type="success" @click="addRelation">{{
$t('field.add_phase')
}}</at-button>
</div>
</div>
</template>
<script>
import TaskSelector from './TaskSelector.vue';
export default {
name: 'RelationsSelector',
components: { TaskSelector },
props: {
parents: {
type: Array,
required: true,
},
children: {
type: Array,
required: true,
},
projectId: {
type: Number,
default: null,
},
showControls: {
type: Boolean,
default: true,
},
},
data() {
const columns = [
{
title: this.$t('field.name'),
render: (h, params) => {
return h(
'router-link',
{
props: {
to: {
name: this.showControls ? 'Tasks.relations' : 'Tasks.crud.tasks.view',
params: { id: params.item.id },
},
},
},
params.item.task_name,
);
},
},
];
if (this.showControls) {
columns.push({
title: '',
width: '40',
render: (h, params) => {
return h('AtButton', {
props: {
type: 'error',
icon: 'icon-trash-2',
size: 'smaller',
},
on: {
click: async () => {
if (this.modalIsOpen) {
return;
}
this.modalIsOpen = true;
const isConfirm = await this.$CustomModal({
title: this.$t('notification.record.delete.confirmation.title'),
content: this.$t('notification.record.delete.confirmation.message'),
okText: this.$t('control.delete'),
cancelText: this.$t('control.cancel'),
showClose: false,
styles: {
'border-radius': '10px',
'text-align': 'center',
footer: {
'text-align': 'center',
},
header: {
padding: '16px 35px 4px 35px',
color: 'red',
},
body: {
padding: '16px 35px 4px 35px',
},
},
width: 320,
type: 'trash',
typeButton: 'error',
});
this.modalIsOpen = false;
if (isConfirm === 'confirm') {
this.$emit('unlink', params.item);
}
},
},
});
},
});
}
return {
modalIsOpen: false,
columns,
follows: this.parents,
precedes: this.children,
relationType: '',
tasks: [],
selectedTask: '',
};
},
methods: {
handleTaskSelection(value) {
this.selectedTask = value;
},
addRelation() {
this.$emit('createRelation', {
taskId: +this.selectedTask,
type: this.relationType,
});
},
},
computed: {
addBtnDisabled() {
return !(this.selectedTask && this.relationType);
},
},
watch: {
parents(newVal) {
this.follows = newVal;
},
children(newVal) {
this.precedes = newVal;
},
},
};
</script>
<style scoped lang="scss">
.relations {
&__title {
margin-bottom: $spacing-01;
&:last-of-type {
margin-top: $spacing-03;
}
@media (min-width: 768px) {
font-size: 0.8rem;
}
}
&::v-deep {
.at-input__original {
border: none;
width: 100%;
font-size: 0.8rem;
}
}
&__actions {
display: flex;
margin-top: $spacing-03;
column-gap: $spacing-03;
.at-select {
max-width: 120px;
}
.task-select {
flex-basis: 100%;
&::v-deep ul {
overflow-x: hidden;
}
}
}
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div class="comments">
<div ref="commentForm" class="comment-form">
<at-textarea v-model="commentMessage" class="comment-message" @change="commentMessageChange" />
<div
v-if="showUsers"
class="comment-form-users"
:style="{ top: `${usersTop - scrollTop - commentMessageScrollTop}px`, left: `${usersLeft}px` }"
>
<div
v-for="user in visibleUsers"
:key="user.id"
class="comment-form-user"
@click="insertUserName(user.full_name)"
>
<team-avatars class="user-avatar" :users="[user]" />
{{ user.full_name }}
</div>
</div>
<at-button class="comment-submit" @click.prevent="createComment(task.id)">
{{ $t('projects.add_comment') }}
</at-button>
</div>
<div v-for="comment in task.comments" :key="comment.id" class="comment">
<div class="comment-header">
<span class="comment-author">
<team-avatars class="comment-avatar" :users="[comment.user]" />
{{ comment.user.full_name }}
</span>
<span class="comment-date">{{ formatDate(comment.created_at) }}</span>
</div>
<div class="comment-content">
<template v-for="(content, index) in getCommentContent(comment)">
<span v-if="content.type === 'text'" :key="index">{{ content.text }}</span>
<span v-else-if="content.type === 'username'" :key="index" class="username">{{
content.text
}}</span>
</template>
</div>
</div>
</div>
</template>
<script>
import { offset } from 'caret-pos';
import TeamAvatars from '@/components/TeamAvatars';
import TaskActivityService from '@/services/resource/task-activity.service';
import UsersService from '@/services/resource/user.service';
import { formatDate } from '@/utils/time';
export default {
components: {
TeamAvatars,
},
props: {
task: {
type: Object,
required: true,
},
},
inject: ['reload'],
data() {
return {
taskActivityService: new TaskActivityService(),
userService: new UsersService(),
commentMessage: '',
users: [],
userFilter: '',
userNameStart: 0,
userNameEnd: 0,
showUsers: false,
usersTop: 0,
usersLeft: 0,
scrollTop: 0,
commentMessageScrollTop: 0,
};
},
computed: {
visibleUsers() {
return this.users.filter(user => {
return user.full_name.replace(/\s/g, '').toLocaleLowerCase().indexOf(this.userFilter) === 0;
});
},
},
methods: {
formatDate,
async createComment(id) {
const comment = await this.taskActivityService.save({
task_id: id,
content: this.commentMessage,
});
this.commentMessage = '';
if (this.reload) {
this.reload();
}
},
commentMessageChange(value) {
const textArea = this.$refs.commentForm.querySelector('textarea');
const regexp = /@([0-9a-zа-я._-]*)/gi;
let match,
found = false;
while ((match = regexp.exec(value)) !== null) {
const start = match.index;
const end = start + match[0].length;
if (textArea.selectionStart >= start && textArea.selectionEnd <= end) {
this.userNameStart = start;
this.userNameEnd = end;
this.userFilter = match[1].replace(/\s/g, '').toLocaleLowerCase();
this.showUsers = true;
this.scrollTop = document.scrollingElement.scrollTop;
this.commentMessageScrollTop = textArea.scrollTop;
const coords = offset(textArea);
this.usersTop = coords.top + 20;
this.usersLeft = coords.left;
found = true;
break;
}
}
if (!found) {
this.showUsers = false;
this.userFilter = '';
}
},
onScroll() {
this.scrollTop = document.scrollingElement.scrollTop;
},
insertUserName(value) {
const messageBefore = this.commentMessage.substring(0, this.userNameStart);
const messageAfter = this.commentMessage.substring(this.userNameEnd);
const userName = `@${value.replace(/[^0-9a-zа-я._-]/gi, '')}`;
this.commentMessage = [messageBefore, userName, messageAfter].join('');
this.$nextTick(() => {
const textArea = this.$refs.commentForm.querySelector('textarea');
textArea.focus();
textArea.selectionStart = this.userNameStart + userName.length;
textArea.selectionEnd = this.userNameStart + userName.length;
this.showUsers = false;
this.userFilter = '';
});
},
getCommentContent(comment) {
return comment.content.split(/(@[0-9a-zа-я._-]+)/gi).map(str => {
return {
type: /^@[0-9a-zа-я._-]+/i.test(str) ? 'username' : 'text',
text: str,
};
});
},
},
async created() {
this.users = (await this.userService.getAll()).data;
},
mounted() {
window.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
window.removeEventListener('scroll', this.onScroll);
},
};
</script>
<style lang="scss" scoped>
.comment-form {
width: 100%;
margin-top: 16px;
&-users {
position: fixed;
background: #fff;
border-radius: 4px;
box-shadow: 0px 0px 10px rgba(63, 51, 86, 0.1);
padding: 4px 0 4px;
z-index: 10;
}
&-user {
padding: 4px 8px 4px;
cursor: pointer;
&:hover {
background: #ecf2fc;
}
}
}
.user-avatar {
display: inline-block;
}
.comment-submit {
margin-top: 8px;
}
.comment {
display: block;
margin-top: 16px;
width: 100%;
&-header {
display: flex;
justify-content: space-between;
}
&-avatar {
display: inline-block;
}
.username {
background: #ecf2fc;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,978 @@
<template>
<div class="activity-wrapper">
<div class="sort-wrapper">
<at-dropdown>
<at-button size="middle">
{{ $t('tasks.sort_or_filter') }} <i class="icon icon-chevron-down"></i>
</at-button>
<at-dropdown-menu slot="menu">
<at-radio-group v-model="sort">
<at-dropdown-item>
<at-radio label="desc">{{ $t('tasks.newest_first') }}</at-radio>
</at-dropdown-item>
<at-dropdown-item>
<at-radio label="asc">{{ $t('tasks.oldest_first') }}</at-radio>
</at-dropdown-item>
</at-radio-group>
<at-radio-group v-model="typeActivity">
<at-dropdown-item divided>
<at-radio label="all">{{ $t('tasks.show_all_activity') }}</at-radio>
</at-dropdown-item>
<at-dropdown-item>
<at-radio label="comments">{{ $t('tasks.show_comments_only') }}</at-radio>
</at-dropdown-item>
<at-dropdown-item>
<at-radio label="history">{{ $t('tasks.show_history_only') }}</at-radio>
</at-dropdown-item>
</at-radio-group>
</at-dropdown-menu>
</at-dropdown>
</div>
<div ref="commentForm" class="comment-form" :class="{ 'comment-form--at-bottom': sort === 'asc' }">
<div class="comment-content" :class="{ 'comment-content--preview': mainPreview }">
<div class="preview-btn-wrapper">
<at-button
v-show="commentMessage.length > 0"
class="preview-btn"
:icon="mainPreview ? 'icon-eye-off' : 'icon-eye'"
size="small"
circle
type="info"
hollow
@click="togglePreview(true)"
></at-button>
</div>
<at-textarea
v-if="!mainPreview"
v-model="commentMessage"
class="comment-message mainTextArea"
autosize
resize="none"
@change="commentMessageChange"
/>
<vue-markdown
v-else
ref="markdown"
:source="commentMessage"
:plugins="markdownPlugins"
:options="{ linkify: true }"
/>
</div>
<div
v-if="showUsers"
class="comment-form-users"
:style="{ top: `${usersTop - scrollTop - commentMessageScrollTop}px`, left: `${usersLeft}px` }"
>
<div
v-for="user in visibleUsers"
:key="user.id"
class="comment-form-user"
@click="insertUserName(user.full_name)"
>
<team-avatars class="user-avatar" :users="[user]" />
{{ user.full_name }}
</div>
</div>
<div class="attachments-wrapper">
<Attachments :attachments="attachments" @change="handleAttachmentsChangeOnCreate" />
</div>
<at-button class="comment-button" type="primary" @click.prevent="createComment(task.id)">
{{ $t('tasks.activity.add_comment') }}
</at-button>
</div>
<div class="history">
<div v-for="item in activities" :key="item.id + (item.content ? 'c' : 'h')" class="comment">
<div v-if="!item.content" class="content">
<TeamAvatars class="history-change-avatar" :users="[item.user]" />
{{ getActivityMessage(item) }}
<at-collapse v-if="item.field !== 'important'" simple accordion :value="-1">
<at-collapse-item :title="$t('tasks.activity.show_changes')">
<CodeDiff
:old-string="formatActivityValue(item, false)"
:new-string="formatActivityValue(item, true)"
max-height="500px"
:hide-header="true"
output-format="line-by-line"
/>
</at-collapse-item>
</at-collapse>
</div>
<div v-if="item.content" class="content">
<div class="comment-header">
<span class="comment-author">
<team-avatars class="comment-avatar" :users="[item.user]" />
{{ item.user.full_name }} ·
<span class="comment-date">{{ fromNow(item.created_at) }}</span>
</span>
<div v-if="item.user.id === user.id" class="comment-functions">
<div class="comment-buttons">
<at-button
v-show="item.id === idComment"
:icon="editPreview ? 'icon-eye-off' : 'icon-eye'"
size="small"
circle
type="info"
hollow
@click="togglePreview(false)"
></at-button>
<at-button
:icon="item.id === idComment ? 'icon-x' : 'icon-edit-2'"
size="small"
circle
type="warning"
hollow
@click="item.id === idComment ? cancelChangeComment : changeComment(item)"
></at-button>
<at-button
icon="icon icon-trash-2"
size="small"
type="error"
hollow
circle
@click="deleteComment(item)"
></at-button>
</div>
</div>
</div>
<div v-if="item.id === idComment && !editPreview" ref="commentChangeForm" class="comment-content">
<at-textarea
v-model="changeMessageText"
:class="`commentTextArea${item.id}`"
class="comment-message"
autosize
resize="none"
/>
<div class="attachments-wrapper">
<Attachments :attachments="changeAttachments" @change="handleAttachmentsChangeOnEdit" />
</div>
<div class="comment-buttons">
<at-button class="comment-button" type="primary" @click.prevent="editComment(item)">
{{ $t('tasks.save_comment') }}
</at-button>
<at-button class="comment-button" @click.prevent="cancelChangeComment">
{{ $t('tasks.cancel') }}
</at-button>
</div>
</div>
<div
v-else
class="comment-content"
:class="{ 'comment-content--preview': editPreview && item.id === idComment }"
>
<template v-for="(content, index) in getCommentContent(item)">
<div :key="index">
<div v-if="content.type === 'text'">
<vue-markdown
ref="markdown"
:source="content.text"
:plugins="markdownPlugins"
:options="{ linkify: true }"
/>
</div>
<span v-else-if="content.type === 'username'" class="username">{{ content.text }}</span>
</div>
</template>
<div class="attachments-wrapper">
<Attachments
:attachments="item.id === idComment ? changeAttachments : item.attachments_relation"
:show-controls="false"
/>
</div>
</div>
<span v-if="item.updated_at !== item.created_at" class="comment-date">
{{ $t('tasks.edited') }} {{ fromNow(item.updated_at) }}
</span>
</div>
</div>
<div ref="activitiesObservable"></div>
</div>
</div>
</template>
<script>
import TeamAvatars from '@/components/TeamAvatars';
import StatusService from '@/services/resource/status.service';
import PriorityService from '@/services/resource/priority.service';
import ProjectService from '@/services/resource/project.service';
import { offset } from 'caret-pos';
import TaskActivityService from '@/services/resource/task-activity.service';
import UsersService from '@/services/resource/user.service';
import { formatDate, formatDurationString, fromNow } from '@/utils/time';
import VueMarkdown from '@/components/VueMarkdown';
import 'markdown-it';
import 'highlight.js/styles/github.min.css'; // Import a highlight.js theme (choose your favorite!)
import { CodeDiff } from 'v-code-diff';
import i18n from '@/i18n';
import moment from 'moment-timezone';
import { store as rootStore } from '@/store';
import Attachments from './Attachments.vue';
export default {
components: {
Attachments,
TeamAvatars,
VueMarkdown,
CodeDiff,
},
props: {
task: {
type: Object,
required: true,
},
},
inject: ['reload'],
data() {
return {
markdownPlugins: [
md => {
// Use a function to add the plugin
// Add the highlight.js plugin
md.use(require('markdown-it-highlightjs'), {
auto: true,
code: true,
// inline: true
});
md.use(require('markdown-it-sup'));
md.use(require('markdown-it-sub'));
},
],
statusService: new StatusService(),
priorityService: new PriorityService(),
taskActivityService: new TaskActivityService(),
projectService: new ProjectService(),
statuses: [],
priorities: [],
projects: [],
userService: new UsersService(),
users: [],
userFilter: '',
userNameStart: 0,
userNameEnd: 0,
showUsers: false,
usersTop: 0,
usersLeft: 0,
scrollTop: 0,
commentMessageScrollTop: 0,
user: null,
sort: 'desc',
typeActivity: 'all',
activities: [],
page: 1,
canLoad: true,
isLoading: false,
observer: null,
commentMessage: '',
attachments: [],
idComment: null,
changeMessageText: null,
changeAttachments: [],
isModalOpen: false,
mainPreview: false,
editPreview: false,
};
},
async created() {
this.users = await this.userService.getAll();
this.user = this.$store.state.user.user.data;
},
async mounted() {
this.observer = new IntersectionObserver(this.infiniteScroll, {
rootMargin: '300px',
threshold: 0,
});
this.observer.observe(this.$refs.activitiesObservable);
this.statuses = await this.statusService.getAll({
headers: {
'X-Paginate': 'false',
},
});
this.priorities = await this.priorityService.getAll({
headers: {
'X-Paginate': 'false',
},
});
this.projects = await this.projectService.getAll({
headers: {
'X-Paginate': 'false',
},
});
this.websocketEnterChannel(this.user.id, {
create: async data => {
if (data.model.task_id !== this.task.id) {
return;
}
if (this.sort === 'desc') {
this.activities.unshift(data.model);
} else if (this.sort === 'asc' && !this.canLoad && !this.isLoading) {
this.activities.push(data.model);
}
},
edit: async data => {
if (data.model.task_id !== this.task.id) {
return;
}
const comment = this.activities.find(el => el.id === data.model.id && el.content);
if (comment) {
comment.content = data.model.content;
comment.attachments_relation = data.model.attachments_relation;
}
},
});
},
beforeDestroy() {
this.observer.disconnect();
this.websocketLeaveChannel(this.user.id);
},
computed: {
visibleUsers() {
return this.users.filter(user => {
return user.full_name.replace(/\s/g, '').toLocaleLowerCase().indexOf(this.userFilter) === 0;
});
},
},
watch: {
sort() {
this.resetHistory();
},
typeActivity() {
this.resetHistory();
},
},
methods: {
fromNow,
async resetHistory() {
this.page = 1;
this.canLoad = true;
this.activities = [];
this.observer.disconnect();
this.observer.observe(this.$refs.activitiesObservable);
},
async getActivity(dataOptions = {}) {
return (
await this.taskActivityService.getActivity({
page: this.page,
orderBy: ['created_at', this.sort],
where: { task_id: ['=', [this.task.id]] },
task_id: this.task.id,
with: ['user'],
type: this.typeActivity,
...dataOptions,
})
).data;
},
formatActivityValue(item, isNew) {
let newValue = item.new_value;
let oldValue = item.old_value;
if (item.field === 'estimate') {
newValue = newValue == null ? newValue : formatDurationString(newValue);
oldValue = oldValue == null ? oldValue : formatDurationString(oldValue);
} else if (item.field === 'project_id') {
newValue = newValue == null ? newValue : this.getProjectName(newValue);
oldValue = oldValue == null ? oldValue : this.getProjectName(oldValue);
} else if (item.field === 'status_id') {
newValue = newValue == null ? newValue : this.getStatusName(newValue);
oldValue = oldValue == null ? oldValue : this.getStatusName(oldValue);
} else if (item.field === 'priority_id') {
newValue = newValue == null ? newValue : this.getPriorityName(newValue);
oldValue = oldValue == null ? oldValue : this.getPriorityName(oldValue);
} else if (item.field === 'start_date' || item.field === 'due_date') {
const isStart = item.field === 'start_date';
let oldDate = isStart ? i18n.t('tasks.unset_start_date') : i18n.t('tasks.unset_due_date');
let newDate = isStart ? i18n.t('tasks.unset_start_date') : i18n.t('tasks.unset_due_date');
const userTimezone = moment.tz.guess();
const companyTimezone = rootStore.getters['user/companyData'].timezone;
if (newValue != null && typeof newValue === 'string' && typeof companyTimezone === 'string') {
newDate =
formatDate(moment.utc(newValue).tz(companyTimezone, true).tz(userTimezone)) +
` (GMT${moment.tz(userTimezone).format('Z')})`;
}
if (oldValue != null && typeof oldValue === 'string' && typeof companyTimezone === 'string') {
oldDate =
formatDate(moment.utc(oldValue).tz(companyTimezone, true).tz(userTimezone)) +
` (GMT${moment.tz(userTimezone).format('Z')})`;
}
newValue = newDate;
oldValue = oldDate;
}
return isNew ? newValue : oldValue;
},
getActivityMessage(item) {
if (item.field === 'users') {
return this.$i18n.t(
item.new_value === ''
? 'tasks.activity.task_unassigned_users'
: 'tasks.activity.task_change_users',
{
user: item.user.full_name,
date: fromNow(item.created_at),
value: item.new_value,
},
);
}
if (item.field === 'task_name') {
return this.$i18n.t('tasks.activity.task_change_to', {
user: item.user.full_name,
field: this.$i18n.t(`field.${item.field}`).toLocaleLowerCase(),
value: item.new_value,
date: fromNow(item.created_at),
});
}
if (item.field === 'project_id') {
return this.$i18n.t('tasks.activity.task_change_to', {
user: item.user.full_name,
field: this.$i18n.t(`field.${item.field}`).toLocaleLowerCase(),
value: this.getProjectName(item.new_value),
date: fromNow(item.created_at),
});
}
if (item.field === 'status_id') {
return this.$i18n.t('tasks.activity.task_change_to', {
user: item.user.full_name,
field: this.$i18n.t(`field.${item.field}`).toLocaleLowerCase(),
value: this.getStatusName(item.new_value),
date: fromNow(item.created_at),
});
}
if (item.field === 'priority_id') {
return this.$i18n.t('tasks.activity.task_change_to', {
user: item.user.full_name,
field: this.$i18n.t(`field.${item.field}`).toLocaleLowerCase(),
value: this.getPriorityName(item.new_value),
date: fromNow(item.created_at),
});
}
if (item.field === 'project_phase_id') {
return this.$i18n.t('tasks.activity.task_change_to', {
user: item.user.full_name,
field: this.$i18n.t(`field.${item.field}`).toLocaleLowerCase(),
value: item.new_value == null ? this.$i18n.t('tasks.unset_phase') : item.new_value,
date: fromNow(item.created_at),
});
}
if (item.field === 'estimate') {
return this.$i18n.t('tasks.activity.task_change_to', {
user: item.user.full_name,
field: this.$i18n.t(`field.${item.field}`).toLocaleLowerCase(),
value:
item.new_value == null
? this.$i18n.t('tasks.unset_estimate')
: formatDurationString(item.new_value),
date: fromNow(item.created_at),
});
}
if (item.field === 'start_date' || item.field === 'due_date') {
const isStart = item.field === 'start_date';
let date = isStart ? i18n.t('tasks.unset_start_date') : i18n.t('tasks.unset_due_date');
const userTimezone = moment.tz.guess();
const companyTimezone = rootStore.getters['user/companyData'].timezone;
let newValue = item.new_value;
if (newValue != null && typeof newValue === 'string' && typeof companyTimezone === 'string') {
date =
formatDate(moment.utc(newValue).tz(companyTimezone, true).tz(userTimezone)) +
` (GMT${moment.tz(userTimezone).format('Z')})`;
}
return this.$i18n.t('tasks.activity.task_change_to', {
user: item.user.full_name,
field: this.$i18n.t(`field.${item.field}`).toLocaleLowerCase(),
value: date,
date: fromNow(item.created_at),
});
}
if (item.field === 'important') {
return this.$i18n.t(
+item.new_value === 1
? 'tasks.activity.marked_as_important'
: 'tasks.activity.marked_as_non_important',
{
user: item.user.full_name,
date: fromNow(item.created_at),
},
);
}
return this.$i18n.t('tasks.activity.task_change', {
user: item.user.full_name,
field: this.$i18n.t(`field.${item.field}`).toLocaleLowerCase(),
value: item.new_value,
date: fromNow(item.created_at),
});
},
getProjectName(id) {
const project = this.projects.find(project => +project.id === +id);
if (project) {
return project.name;
}
return '';
},
getStatusName(id) {
const status = this.statuses.find(status => +status.id === +id);
if (status) {
return status.name;
}
return '';
},
getPriorityName(id) {
const priority = this.priorities.find(priority => +priority.id === +id);
if (priority) {
return priority.name;
}
return '';
},
handleAttachmentsChangeOnCreate(attachments) {
this.attachments = attachments;
},
handleAttachmentsChangeOnEdit(attachments) {
this.changeAttachments = attachments;
},
async createComment(id) {
// mitigate validation issues for empty array
const payload = {
task_id: id,
content: this.commentMessage,
attachmentsRelation: this.attachments.filter(el => !el.toDelete).map(el => el.id),
attachmentsToRemove: this.attachments.filter(el => el.toDelete).map(el => el.id),
};
if (payload.attachmentsRelation.length === 0) {
delete payload.attachmentsRelation;
}
if (payload.attachmentsToRemove.length === 0) {
delete payload.attachmentsToRemove;
}
const comment = await this.taskActivityService.saveComment(payload);
this.attachments = [];
this.commentMessage = '';
},
commentMessageChange(value) {
const textArea = this.$refs.commentForm.querySelector('textarea');
const regexp = /@([0-9a-zа-я._-]*)/gi;
let match,
found = false;
while ((match = regexp.exec(value)) !== null) {
const start = match.index;
const end = start + match[0].length;
if (textArea.selectionStart >= start && textArea.selectionEnd <= end) {
this.userNameStart = start;
this.userNameEnd = end;
this.userFilter = match[1].replace(/\s/g, '').toLocaleLowerCase();
this.showUsers = true;
this.scrollTop = document.scrollingElement.scrollTop;
this.commentMessageScrollTop = textArea.scrollTop;
const coords = offset(textArea);
this.usersTop = coords.top + 20;
this.usersLeft = coords.left;
found = true;
break;
}
}
if (!found) {
this.showUsers = false;
this.userFilter = '';
}
},
async infiniteScroll([entry]) {
await this.$nextTick();
if (entry.isIntersecting && this.canLoad === true) {
this.observer.disconnect();
this.canLoad = false;
this.isLoading = true;
let data = (await this.getActivity()).data;
if (this.page === 1) {
this.activities = [];
}
this.page++;
if (data.length > 0) {
this.activities.push(...data);
this.canLoad = true;
this.isLoading = false;
this.observer.observe(this.$refs.activitiesObservable);
}
}
},
insertUserName(value) {
const messageBefore = this.commentMessage.substring(0, this.userNameStart);
const messageAfter = this.commentMessage.substring(this.userNameEnd);
const userName = `@${value.replace(/[^0-9a-zа-я._-]/gi, '')}`;
this.commentMessage = [messageBefore, userName, messageAfter].join('');
this.$nextTick(() => {
const textArea = this.$refs.commentForm.querySelector('textarea');
textArea.focus();
textArea.selectionStart = this.userNameStart + userName.length;
textArea.selectionEnd = this.userNameStart + userName.length;
this.showUsers = false;
this.userFilter = '';
});
},
getCommentContent(item) {
let content = item.content;
if (item.id === this.idComment && this.editPreview) {
content = this.changeMessageText;
}
return content.split(/(@[0-9a-zа-я._-]+)/gi).map(str => {
return {
type: /^@[0-9a-zа-я._-]+/i.test(str) ? 'username' : 'text',
text: str,
};
});
},
changeComment(item) {
this.changeAttachments = JSON.parse(JSON.stringify(item.attachments_relation));
this.idComment = item.id;
this.changeMessageText = item.content;
this.editPreview = false;
this.scrollToTextArea(`commentTextArea${this.idComment}`);
},
togglePreview(main = false) {
if (main) {
this.mainPreview = !this.mainPreview;
} else {
this.editPreview = !this.editPreview;
}
if (main && !this.mainPreview) {
this.scrollToTextArea('mainTextArea');
} else if (!main && this.idComment && !this.editPreview) {
this.scrollToTextArea(`commentTextArea${this.idComment}`);
}
},
scrollToTextArea(ref) {
this.$nextTick(() => {
document.querySelector(`.${ref}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
},
cancelChangeComment() {
this.changeAttachments = [];
this.idComment = null;
this.editPreview = false;
},
async editComment(item) {
// mitigate validation issues for empty array
const newComment = {
...item,
content: this.changeMessageText,
attachmentsRelation: this.changeAttachments.filter(el => !el.toDelete).map(el => el.id),
attachmentsToRemove: this.changeAttachments.filter(el => el.toDelete).map(el => el.id),
};
if (newComment.attachmentsRelation.length === 0) {
delete newComment.attachmentsRelation;
}
if (newComment.attachmentsToRemove.length === 0) {
delete newComment.attachmentsToRemove;
}
const result = await this.taskActivityService.editComment(newComment);
item.content = result.data.data.content;
item.updated_at = result.data.data.updated_at;
this.changeMessageText = '';
this.idComment = null;
this.changeAttachments = [];
},
async deleteComment(item) {
if (this.modalIsOpen) {
return;
}
this.modalIsOpen = true;
const isConfirm = await this.$CustomModal({
title: this.$t('notification.record.delete.confirmation.title'),
content: this.$t('notification.record.delete.confirmation.message'),
okText: this.$t('control.delete'),
cancelText: this.$t('control.cancel'),
showClose: false,
styles: {
'border-radius': '10px',
'text-align': 'center',
footer: {
'text-align': 'center',
},
header: {
padding: '16px 35px 4px 35px',
color: 'red',
},
body: {
padding: '16px 35px 4px 35px',
},
},
width: 320,
type: 'trash',
typeButton: 'error',
});
this.modalIsOpen = false;
if (isConfirm === 'confirm') {
const result = await this.taskActivityService.deleteComment(item.id);
if (result.status === 204) {
this.activities.splice(this.activities.indexOf(item), 1);
}
}
},
websocketLeaveChannel(userId) {
this.$echo.leave(`tasks_activities.${userId}`);
this.$echo.leave(`tasks.${userId}`);
},
websocketEnterChannel(userId, handlers) {
const channelActivity = this.$echo.private(`tasks_activities.${userId}`);
const channelTask = this.$echo.private(`tasks.${userId}`);
for (const action in handlers) {
channelActivity.listen(`.tasks_activities.${action}`, handlers[action]);
channelTask.listen(`.tasks.${action}`, handlers[action]);
}
},
},
};
</script>
<style lang="scss" scoped>
.activity-wrapper {
display: flex;
flex-direction: column;
}
.sort-wrapper {
display: flex;
justify-content: end;
}
.attachments-wrapper {
margin-top: 0.5rem;
}
.content {
position: relative;
.comment-functions {
position: absolute;
right: 0;
top: 0;
bottom: 0;
pointer-events: none;
z-index: 5;
.comment-buttons {
position: sticky;
top: 10px;
pointer-events: all;
button {
background: #fff;
}
}
}
}
.comment-content {
position: relative;
border: 1px solid transparent;
border-radius: 4px;
&--preview {
border: 1px solid $color-info;
border-radius: 4px;
}
&::v-deep {
.preview-btn-wrapper {
position: absolute;
top: 5px;
right: 5px;
bottom: 5px;
pointer-events: none;
}
.preview-btn {
pointer-events: all;
position: sticky;
top: 10px;
background: #fff;
}
img {
max-width: 35%;
}
h6 {
font-size: 14px;
}
hr {
border: 0;
border-top: 2px solid $gray-3;
border-radius: 5px;
}
p {
margin: 0 0 10px;
}
ul,
ol {
all: revert;
}
table {
width: 100%;
max-width: 100%;
margin-bottom: 20px;
border-collapse: collapse;
}
table > caption + thead > tr:first-child > th,
table > colgroup + thead > tr:first-child > th,
table > thead:first-child > tr:first-child > th,
table > caption + thead > tr:first-child > td,
table > colgroup + thead > tr:first-child > td,
table > thead:first-child > tr:first-child > td {
border-top: 0;
}
table > thead > tr > th {
vertical-align: bottom;
border-bottom: 2px solid #ddd;
}
table > tbody > tr:nth-child(odd) > td,
table > tbody > tr:nth-child(odd) > th {
background-color: #f9f9f9;
}
table > thead > tr > th,
table > tbody > tr > th,
table > tfoot > tr > th,
table > thead > tr > td,
table > tbody > tr > td,
table > tfoot > tr > td {
padding: 8px;
line-height: 1.42857143;
vertical-align: top;
border-top: 1px solid #ddd;
}
code.hljs {
white-space: pre;
padding: 9.5px;
}
pre {
white-space: pre !important;
display: block;
margin: 0 0 10px;
font-size: 13px;
line-height: 1.42857143;
color: #333;
word-break: break-all;
word-wrap: break-word;
background-color: #f5f5f5;
border: 1px solid #ccc;
border-radius: 4px;
code {
padding: 0;
font-size: inherit;
color: inherit;
white-space: pre-wrap;
background-color: transparent;
border-radius: 0;
}
}
code {
padding: 2px 4px;
font-size: 90%;
color: #c7254e;
background-color: #f9f2f4;
border-radius: 4px;
}
blockquote {
padding: 10px 20px;
margin: 0 0 20px;
font-size: 17.5px;
border-left: 5px solid #eee;
}
}
}
.history {
&-change {
margin-top: 16px;
}
&-change-avatar {
display: inline-block;
}
}
.comment-form,
.comment-content {
&::v-deep .comment-message textarea {
min-height: 140px !important;
max-height: 500px;
resize: none !important;
}
}
.comment-form {
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
&--at-bottom {
order: 999;
}
&-users {
position: fixed;
background: #fff;
border-radius: 4px;
box-shadow: 0px 0px 10px rgba(63, 51, 86, 0.1);
padding: 4px 0 4px;
z-index: 10;
}
&-user {
padding: 4px 8px 4px;
cursor: pointer;
&:hover {
background: #ecf2fc;
}
}
}
.user-avatar {
display: inline-block;
}
.comment-button {
margin-top: 8px;
margin-right: 8px;
margin-left: auto;
}
.buttons {
display: flex;
justify-content: space-between;
}
.at-dropdown-menu {
display: flex;
flex-direction: column;
}
.at-dropdown {
margin-top: 8px;
&-menu__item {
padding: 0;
.at-radio {
padding: 8px 16px;
width: 100%;
}
}
}
.comment {
display: block;
margin-top: 16px;
width: 100%;
&-header {
display: flex;
justify-content: space-between;
}
&-avatar {
display: inline-block;
}
&-date {
opacity: 0.5;
}
.username {
background: #ecf2fc;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div class="task-select">
<v-select
v-model="model"
:options="options"
:filterable="false"
label="label"
:clearable="true"
:reduce="option => option.id"
:components="{ Deselect, OpenIndicator }"
:placeholder="$t('tasks.relations.select_task')"
@open="onOpen"
@close="onClose"
@search="onSearch"
@option:selecting="handleSelecting"
>
<template #option="{ label, current }">
<span class="option" :class="{ 'option--current': current }">
<span class="option__text">
<span>{{ ucfirst(label) }}</span>
</span>
</span>
</template>
<template #no-options="{ search, searching }">
<template v-if="searching">
<span>{{ $t('tasks.relations.no_task_found', { query: search }) }}</span>
</template>
<em v-else style="opacity: 0.5">{{ $t('tasks.relations.type_to_search') }}</em>
</template>
<template #list-footer>
<li v-show="hasNextPage" ref="load" class="option__infinite-loader">
{{ $t('tasks.relations.loading') }} <i class="icon icon-loader" />
</li>
</template>
</v-select>
</div>
</template>
<script>
// TODO: extract infinite scroll into separate component
import { ucfirst } from '@/utils/string';
import TasksService from '@/services/resource/task.service';
import vSelect from 'vue-select';
import debounce from 'lodash/debounce';
const service = new TasksService();
export default {
name: 'TaskSelector',
props: {
value: {
type: [String, Number],
default: '',
},
projectId: {
type: Number,
default: null,
},
},
components: {
vSelect,
},
data() {
return {
options: [],
observer: null,
isSelectOpen: false,
totalPages: 0,
currentPage: 0,
query: '',
lastSearchQuery: '',
requestTimestamp: null,
localCurrentTask: null,
};
},
created() {
this.search = debounce(this.search, 350);
this.requestTimestamp = Date.now();
this.search(this.requestTimestamp);
},
mounted() {
this.observer = new IntersectionObserver(this.infiniteScroll);
if (typeof this.localCurrentTask === 'object' && this.localCurrentTask != null) {
this.options = [
{
id: this.localCurrentTask.id,
label: this.localCurrentTask.task_name,
current: true,
},
];
this.localCurrentTask = this.options[0];
}
},
watch: {
async valueAndQuery(newValue) {
if (newValue.value === '') {
this.localCurrentTask != null
? (this.localCurrentTask.current = false)
: (this.localCurrentTask = null);
this.localCurrentTask = null;
// this.$emit('setCurrent', '');
} else {
// this.$emit('setCurrent', {
// id: this.localCurrentTask.id,
// name: this.localCurrentTask.label,
// });
}
if (newValue.value === '' && newValue.query === '') {
this.requestTimestamp = Date.now();
this.search(this.requestTimestamp);
}
},
},
methods: {
ucfirst,
async onOpen() {
// this.requestTimestamp = Date.now();
// this.search(this.requestTimestamp);
this.isSelectOpen = true;
await this.$nextTick();
this.observe(this.requestTimestamp);
},
onClose() {
this.isSelectOpen = false;
this.observer.disconnect();
},
onSearch(query) {
this.query = query;
this.search.cancel();
this.requestTimestamp = Date.now();
if (this.query.length) {
this.search(this.requestTimestamp);
}
},
async search(requestTimestamp) {
this.observer.disconnect();
this.totalPages = 0;
this.currentPage = 0;
this.resetOptions();
this.lastSearchQuery = this.query;
await this.$nextTick();
await this.loadOptions(requestTimestamp);
await this.$nextTick();
this.observe(requestTimestamp);
},
handleSelecting(option) {
if (this.localCurrentTask != null) {
this.localCurrentTask.current = false;
}
option.current = true;
this.localCurrentTask = option;
// this.$emit('setCurrent', {
// id: this.localCurrentTask.id,
// name: this.localCurrentTask.label,
// });
},
async infiniteScroll([{ isIntersecting, target }]) {
if (isIntersecting) {
const ul = target.offsetParent;
const scrollTop = target.offsetParent.scrollTop;
const requestTimestamp = +target.dataset.requestTimestamp;
if (requestTimestamp === this.requestTimestamp) {
await this.loadOptions(requestTimestamp);
await this.$nextTick();
ul.scrollTop = scrollTop;
this.observer.disconnect();
this.observe(requestTimestamp);
}
}
},
observe(requestTimestamp) {
if (this.isSelectOpen && this.$refs.load) {
this.$refs.load.dataset.requestTimestamp = requestTimestamp;
this.observer.observe(this.$refs.load);
}
},
async loadOptions(requestTimestamp) {
const filters = {
search: { query: this.lastSearchQuery, fields: ['task_name'] },
page: this.currentPage + 1,
};
if (this.projectId) {
filters['where'] = { project_id: this.projectId };
}
// async fetchTasks(query, loading) {
// loading(true);
//
// const filters = { search: { query, fields: ['task_name'] }, with: ['project'] };
// if (this.userID) {
// filters['where'] = { 'users.id': this.userID };
// }
//
// this.options = await this.service.getWithFilters(filters).then(({ data }) => {
// loading(false);
//
// return data.data.map(task => {
// const label =
// typeof task.project !== 'undefined'
// ? `${task.task_name} (${task.project.name})`
// : task.task_name;
//
// return { ...task, label };
// });
// });
// },
return service.getWithFilters(filters).then(res => {
const data = res.data.data;
const pagination = res.data.pagination;
if (requestTimestamp === this.requestTimestamp) {
this.totalPages = pagination.totalPages;
this.currentPage = pagination.currentPage;
data.forEach(option => {
option.current = false;
if (this.options[0]?.id === option.id) {
this.options.shift();
if (option.id === this.localCurrentTask?.id) {
option.current = true;
}
}
this.options.push({
id: option.id,
label: option.task_name,
current: option.current,
});
if (option.current) {
this.localCurrentTask = this.options[this.options.length - 1];
}
});
}
});
},
resetOptions() {
if (typeof this.localCurrentTask === 'object' && this.localCurrentTask !== null) {
this.options = [
{
id: this.localCurrentTask.id,
label: this.localCurrentTask.label,
current: true,
},
];
this.localCurrentTask = this.options[0];
} else {
this.options = [];
}
},
},
computed: {
model: {
get() {
return this.value;
},
set(option) {
if (option == null) {
this.localCurrentTask = null;
this.requestTimestamp = Date.now();
this.search(this.requestTimestamp);
}
this.$emit('change', option);
},
},
valueAndQuery() {
return {
value: this.value,
query: this.query,
};
},
Deselect() {
return {
render: createElement =>
createElement('i', {
class: 'icon icon-x',
}),
};
},
OpenIndicator() {
return {
render: createElement =>
createElement('i', {
class: {
icon: true,
'icon-chevron-down': !this.isSelectOpen,
'icon-chevron-up': this.isSelectOpen,
},
}),
};
},
hasNextPage() {
return this.currentPage < this.totalPages;
},
},
};
</script>
<style lang="scss" scoped>
.task-select {
border-radius: 5px;
&::v-deep {
&:hover {
.vs__clear {
display: inline-block;
}
.vs__open-indicator {
display: none;
}
}
.vs--open {
.vs__open-indicator {
display: inline-block;
}
}
.vs__actions {
display: flex;
font-size: 14px;
margin-right: 8px;
width: 30px;
position: relative;
}
.vs__clear {
padding-right: 0;
margin-right: 0;
position: absolute;
right: 0;
}
.vs__open-indicator {
transform: none;
position: absolute;
right: 0;
}
}
}
.option {
&--current {
font-weight: bold;
}
&__infinite-loader {
display: flex;
justify-content: center;
align-items: center;
column-gap: 0.3rem;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More