first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
<template>
<div class="time-estimate">
<span class="time-estimate__input-wrapper">
<at-input-number
v-model="currentHours"
:min="0"
:max="maxHours"
@blur="handleInputNumber($event, 'currentHours')"
@change="handleInputNumberChange($event, 'currentHours')"
></at-input-number
><span class="time-estimate__text"> {{ $i18n.t('field.hour_short') }}.</span>
</span>
<span class="time-estimate__input-wrapper">
<at-input-number
v-model="currentMinutes"
:min="0"
:max="maxMinutes"
@blur="handleInputNumber($event, 'currentMinutes')"
@change="handleInputNumberChange($event, 'currentMinutes')"
></at-input-number
><span class="time-estimate__text"> {{ $i18n.t('field.minute_short') }}.</span>
</span>
</div>
</template>
<script>
import moment from 'moment-timezone';
const maxUnsignedInt = 4294967295;
export default {
name: 'TimeEstimate',
props: {
value: {
type: Number,
default: 0,
},
},
data() {
return {
seconds: [],
maxHours: Math.floor(maxUnsignedInt / 3600 - 1),
maxMinutes: 59,
};
},
methods: {
handleInputNumberChange(seconds, key) {
this.setValueInSeconds(key, seconds);
},
handleInputNumber(ev, key) {
let number = ev.target.valueAsNumber;
if (ev.target.max && number > ev.target.max) {
number = Number(ev.target.max);
ev.target.valueAsNumber = number;
ev.target.value = String(number);
}
if (ev.target.min && number < ev.target.min) {
number = Number(ev.target.min);
ev.target.valueAsNumber = number;
ev.target.value = String(number);
}
this.setValueInSeconds(key, number);
},
setValueInSeconds(key, value) {
let hoursInSeconds = this.currentHours * 3600;
if (key === 'currentHours') {
hoursInSeconds = value * 3600;
}
let minutesInSeconds = this.currentMinutes * 60;
if (key === 'currentMinutes') {
minutesInSeconds = value * 60;
}
const newTime = hoursInSeconds + minutesInSeconds;
this.$emit('input', newTime);
},
},
computed: {
currentHours: {
get() {
return this.hoursAndMinutes.hours;
},
set() {},
},
currentMinutes: {
get() {
return this.hoursAndMinutes.minutes;
},
set() {},
},
hoursAndMinutes() {
const duration = moment.duration(this.value, 'seconds');
const hours = Math.floor(duration.asHours());
const minutes = Math.round(duration.asMinutes()) - 60 * hours;
return {
hours,
minutes,
};
},
model: {
get() {
return this.value;
},
set(option) {
this.$emit('input', option);
},
},
},
};
</script>
<style lang="scss" scoped>
.time-estimate {
display: flex;
column-gap: 1rem;
&__text {
padding-left: 5px;
}
&__input-wrapper {
display: flex;
align-items: flex-end;
}
.at-input-number {
min-width: auto;
width: 70px;
padding: 7px 0;
height: auto;
}
}
</style>

View File

@@ -0,0 +1,94 @@
{
"navigation": {
"tasks": "Tasks"
},
"tasks": {
"grid-title": "Tasks",
"crud-title": "Task",
"source": {
"internal": "Internal"
},
"priority": {
"low": "Low",
"normal": "Normal",
"high": "High"
},
"activity": {
"task_change": "{user} changed {field} {date}",
"task_change_to": "{user} changed {field} to {value} {date}",
"task_change_users": "{user} assigned the task to {value} {date}",
"task_unassigned_users": "{user} removed all assignees {date}",
"show_history": "Show",
"hide_history": "Hide",
"add_comment": "Add comment",
"show_changes": "Show changes",
"marked_as_important": "{user} Marked the task as important {date}",
"marked_as_non_important": "{user} Unchecked the importance of the task {date}"
},
"projects": "Projects",
"users": "Users",
"spent_by_user": "Spent time by user",
"status": "Status",
"save_comment": "Save comment",
"cancel": "Cancel",
"edited": "Edited",
"sort_or_filter": "Sort or Filter",
"newest_first": "Newest first",
"oldest_first": "Oldest first",
"show_all_activity": "Show All Activity",
"show_comments_only": "Show comments Only",
"show_history_only": "Show history Only",
"statuses": {
"any": "Any",
"open": "Open",
"closed": "Closed"
},
"unassigned": "Unassigned",
"unset_start_date": "Not set",
"unset_due_date": "Not set",
"due_date--overdue": "Overdue",
"unset_estimate": "Not set",
"estimate--overtime": "Overtime",
"unset_phase": "Not set",
"important": "Important",
"not_important": "Not important",
"relations": {
"title": "Relations",
"for": "Relations for",
"follows": "Follows",
"precedes": "Precedes",
"loading": "Loading tasks",
"no_task_found": "No tasks found for {query}",
"type_to_search": "Start typing to search for a task",
"select_task": "Select task",
"type": "Relation type"
}
},
"field": {
"source": "Source",
"estimate": "Time estimate",
"start_date": "Start date",
"due_date": "Due date",
"hour_short": "h",
"minute_short": "m",
"phase": "Phase",
"attachments": "Attachments"
},
"attachments": {
"status": "Status",
"name": "Name",
"size": "Size",
"upload": "Upload",
"is_good": "Hash match",
"is_bad": "Hash do not match",
"is_not_attached": "Not attached",
"is_processing": "Calculating hash",
"upload_queue": "Upload queue",
"create_tmp_url_for": "Create temporary url for:",
"tmp_url_created": "Temporary url created",
"copied_to_clipboard": "Copied to clipboard"
},
"control": {
"kanban-board": "Kanban"
}
}

View File

@@ -0,0 +1,94 @@
{
"navigation": {
"tasks": "Задачи"
},
"tasks": {
"grid-title": "Задачи",
"crud-title": "Задача",
"source": {
"internal": "Внутренняя"
},
"priority": {
"low": "Низкий",
"normal": "Нормальный",
"high": "Высокий"
},
"activity": {
"task_change": "{user} изменил {field} {date}",
"task_change_to": "{user} изменил {field} на {value} {date}",
"task_change_users": "{user} назначил исполнителем {value} {date}",
"task_unassigned_users": "{user} убрал всех исполнителей {date}",
"show_history": "Показать",
"hide_history": "Скрыть",
"add_comment": "Добавить комментарий",
"show_changes": "Показать изменения",
"marked_as_important": "{user} Отметил задачу как важную {date}",
"marked_as_non_important": "{user} Снял отметку важности с задачи {date}"
},
"projects": "Проекты",
"users": "Пользователи",
"spent_by_user": "Время потраченное пользователями",
"status": "Статус",
"save_comment":"Сохранить коментарий",
"cancel":"Отменить",
"edited":"Отредактирован",
"sort_or_filter":"Сортировка и фильтрация",
"newest_first":"Сначала новые",
"oldest_first":"Сначала старые",
"show_all_activity":"Показать всю активность",
"show_comments_only":"Показать только коментарии",
"show_history_only":"Показать только изменения",
"statuses": {
"any": "Все",
"open": "Открыта",
"closed": "Закрыта"
},
"unassigned": "Не назначен",
"unset_start_date": "Не установлена",
"unset_due_date": "Не установлена",
"due_date--overdue": "Просрочено",
"unset_estimate": "Не установлена",
"estimate--overtime": "Сверхурочно",
"unset_phase": "Не установлена",
"important": "Важная",
"not_important": "Не важная",
"relations": {
"title": "Связи",
"for": "Связи для",
"follows": "Следует",
"precedes": "Предшествует",
"loading": "Загружаем задачи",
"no_task_found": "Для {query} задачи не найдены",
"type_to_search": "Начните печатать, чтобы найти задачу",
"select_task": "Выберите задачу",
"type": "Тип связи"
}
},
"field": {
"source": "Источник",
"estimate": "Оценка времени",
"start_date": "Дата начала",
"due_date": "Дата завершения",
"hour_short": "ч",
"minute_short": "м",
"phase": "Стадия",
"attachments": "Вложения"
},
"attachments": {
"status": "Статус",
"name": "Имя",
"size": "Размер",
"upload": "Загрузить",
"is_good": "Хэш совпадает",
"is_bad": "Хэш не совпадает",
"is_not_attached": "Не прикреплен",
"is_processing": "Вычисляется хэш",
"upload_queue": "Очередь загрузки",
"create_tmp_url_for": "Создать временную ссылку на:",
"tmp_url_created": "Временная ссылка создана",
"copied_to_clipboard": "Скопирована в буфер обмена"
},
"control": {
"kanban-board": "Kanban"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
<template>
<div class="container-fluid">
<div class="row flex-around">
<div class="col-24 col-sm-22 col-lg-20 at-container">
<div class="crud crud__content">
<preloader v-if="fetching" is-transparent></preloader>
<div class="page-controls">
<h1 class="page-title crud__title">
{{ $t('tasks.relations.for') }}: {{ this.task.task_name }}
</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>
<relations-selector
v-if="task.project_id"
:parents="parents"
:children="children"
:project-id="task.project_id"
:show-controls="true"
@unlink="handleUnlink"
@createRelation="handleCreate"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import TasksService from '@/services/resource/task.service';
import GanttService from '@/services/resource/gantt.service';
import RelationsSelector from '../components/RelationsSelector.vue';
import Preloader from '@/components/Preloader.vue';
const tasksService = new TasksService();
const ganttService = new GanttService();
export default {
name: 'TaskRelations',
components: {
Preloader,
RelationsSelector,
},
data() {
return {
task: {},
parents: [],
children: [],
saving: false,
fetching: false,
};
},
async mounted() {
try {
this.fetching = true;
const task = await tasksService.getItem(this.$route.params.id, {
with: ['status', 'parents', 'children'],
});
this.task = task.data.data;
this.children = this.task.children;
this.parents = this.task.parents;
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: {
handleCreate(relation) {
ganttService.createRelation(this.task.id, relation).then(res => {
this.$Notify({
type: 'success',
title: this.$t('notification.record.save.success.title'),
message: this.$t('notification.record.save.success.message'),
});
const task = res.data.data;
if (relation.type === 'follows') {
this.parents.push(task);
} else {
this.children.push(task);
}
});
// .catch(e => {
// this.$Notify({
// type: 'error',
// title: this.$t('notification.save.error.title'),
// message: e.response.data.message ?? this.$t('notification.save.error.message'),
// });
// });
},
handleUnlink(relatedTask) {
const isParent = relatedTask.pivot.child_id === this.task.id;
ganttService.removeRelation(relatedTask.pivot).then(res => {
this.$Notify({
type: 'success',
title: this.$t('notification.record.delete.success.title'),
message: this.$t('notification.record.delete.success.message'),
});
if (isParent) {
this.parents.splice(relatedTask.index, 1);
} else {
this.children.splice(relatedTask.index, 1);
}
});
},
},
computed: {},
};
</script>
<style lang="scss" scoped>
.project-members-form {
.row {
margin-bottom: $layout-01;
}
&__action-btn {
margin-bottom: $layout-01;
}
}
.page-controls {
margin-bottom: 1.5em;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
</style>