first commit
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
94
resources/frontend/core/modules/Tasks/locales/en.json
Normal file
94
resources/frontend/core/modules/Tasks/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
94
resources/frontend/core/modules/Tasks/locales/ru.json
Normal file
94
resources/frontend/core/modules/Tasks/locales/ru.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1049
resources/frontend/core/modules/Tasks/module.init.js
Normal file
1049
resources/frontend/core/modules/Tasks/module.init.js
Normal file
File diff suppressed because it is too large
Load Diff
140
resources/frontend/core/modules/Tasks/views/TaskRelations.vue
Normal file
140
resources/frontend/core/modules/Tasks/views/TaskRelations.vue
Normal 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>
|
||||
Reference in New Issue
Block a user