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