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,158 @@
<template>
<div>
<transition appear mode="out-in" name="fade">
<Skeleton v-if="!loaded" />
<template v-else>
<component :is="openable ? 'a' : 'div'" :href="url" target="_blank">
<svg
v-if="error"
class="error-image"
viewBox="0 0 280 162"
x="0px"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
y="0px"
>
<rect height="162" width="280" />
<path
d="M140,30.59c-27.85,0-50.41,22.56-50.41,50.41s22.56,50.41,50.41,50.41s50.41-22.56,50.41-50.41
S167.85,30.59,140,30.59z M140,121.65c-22.42,0-40.65-18.23-40.65-40.65S117.58,40.35,140,40.35S180.65,58.58,180.65,81
S162.42,121.65,140,121.65z M123.74,77.75c3.6,0,6.5-2.91,6.5-6.5s-2.91-6.5-6.5-6.5s-6.5,2.91-6.5,6.5S120.14,77.75,123.74,77.75z
M156.26,64.74c-3.6,0-6.5,2.91-6.5,6.5s2.91,6.5,6.5,6.5c3.6,0,6.5-2.91,6.5-6.5S159.86,64.74,156.26,64.74z M140,90.76
c-8.17,0-15.85,3.6-21.1,9.88c-1.73,2.07-1.44,5.14,0.63,6.87c2.07,1.71,5.14,1.44,6.87-0.63c3.37-4.05,8.33-6.38,13.6-6.38
s10.22,2.32,13.6,6.38c1.65,1.97,4.7,2.42,6.87,0.63c2.07-1.73,2.34-4.8,0.63-6.87C155.85,94.35,148.17,90.76,140,90.76z"
/>
</svg>
<lazy-component v-else-if="lazy">
<img :src="url" alt="screenshot" @click="$emit('click', $event)" @error="handleError" />
</lazy-component>
<img v-else :src="url" alt="screenshot" @click="$emit('click', $event)" @error="handleError" />
</component>
</template>
</transition>
</div>
</template>
<script>
import axios from '@/config/app';
import { Skeleton } from 'vue-loading-skeleton';
export default {
name: 'AppImage',
props: {
src: {
type: String,
required: true,
},
lazy: {
type: Boolean,
default: false,
},
openable: {
type: Boolean,
default: false,
},
},
data() {
const baseUrl =
this.src.indexOf('http') === 0
? ''
: (process.env.VUE_APP_API_URL !== 'null'
? process.env.VUE_APP_API_URL
: `${window.location.origin}/api`) + '/';
const url = baseUrl + this.src;
return {
error: this.src === 'none',
loaded: this.src === 'none',
url,
baseUrl,
};
},
components: {
Skeleton,
},
methods: {
load() {
if (this.error) return;
if (this.src === 'none') {
this.error = true;
return;
}
this.loaded = false;
if (this.url) {
URL.revokeObjectURL(this.url);
this.url = null;
}
if (this.src) {
axios
.get(this.src, {
responseType: 'blob',
muteError: true,
})
.then(({ data }) => {
this.url = URL.createObjectURL(data);
})
.catch(() => {
this.error = true;
})
.finally(() => {
this.loaded = true;
});
}
},
handleError() {
this.error = true;
},
},
mounted() {
this.load();
},
beforeDestroy() {
if (this.url) {
URL.revokeObjectURL(this.url);
this.url = null;
}
},
watch: {
src() {
this.error = false;
this.load();
},
},
};
</script>
<style lang="scss" scoped>
img {
width: 100%;
object-fit: cover;
background-color: $gray-5;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.error-image {
rect {
fill: $gray-4;
}
path {
fill: $red-1;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<at-button v-bind="$attrs" v-on="$listeners" @click="onClick"><slot /></at-button>
</template>
<script>
export default {
data() {
return {
routeChanged: true,
};
},
beforeDestroy() {
this.routeChanged = true;
},
methods: {
onClick() {
this.routeChanged = false;
this.$router.go(-1);
setTimeout(() => {
if (!this.routeChanged && window.opener !== null) {
window.opener.focus();
window.close();
}
}, 100);
},
},
watch: {
$route() {
this.routeChanged = true;
},
},
};
</script>

View File

@@ -0,0 +1,712 @@
<template>
<div class="calendar" @click="togglePopup">
<at-input class="input" :readonly="true" :value="inputValue">
<template #prepend>
<i class="icon icon-chevron-left previous" @click.stop.prevent="selectPrevious"></i>
</template>
<template #append>
<i class="icon icon-chevron-right next" @click.stop.prevent="selectNext"></i>
</template>
</at-input>
<span class="calendar-icon icon icon-calendar" />
<transition name="slide-up">
<div
v-show="showPopup"
:class="{
'datepicker-wrapper': true,
'datepicker-wrapper--range': datePickerRange,
'at-select__dropdown at-select__dropdown--bottom': true,
}"
@click.stop
>
<div>
<at-tabs ref="tabs" v-model="tab" @on-change="onTabChange">
<at-tab-pane v-if="day" :label="$t('control.day')" name="day"></at-tab-pane>
<at-tab-pane v-if="week" :label="$t('control.week')" name="week"></at-tab-pane>
<at-tab-pane v-if="month" :label="$t('control.month')" name="month"></at-tab-pane>
<at-tab-pane v-if="range" :label="$t('control.range')" name="range"></at-tab-pane>
</at-tabs>
</div>
<date-picker
:key="$i18n.locale"
class="datepicker"
:append-to-body="false"
:clearable="false"
:editable="false"
:inline="true"
:lang="datePickerLang"
:type="datePickerType"
:range="datePickerRange"
:value="datePickerValue"
@change="onDateChange"
>
<template #footer>
<div v-if="day" class="datepicker__footer">
<button class="mx-btn mx-btn-text" size="small" @click="setToday">
{{ $t('control.today') }}
</button>
</div>
</template>
</date-picker>
</div>
</transition>
</div>
</template>
<script>
import moment from 'moment';
import { getDateToday, getEndDay, getStartDay } from '@/utils/time';
export default {
name: 'Calendar',
props: {
day: {
type: Boolean,
default: true,
},
week: {
type: Boolean,
default: true,
},
month: {
type: Boolean,
default: true,
},
range: {
type: Boolean,
default: true,
},
initialTab: {
type: String,
default: 'day',
},
sessionStorageKey: {
type: String,
default: 'amazingcat.session.storage',
},
},
data() {
const { query } = this.$route;
const today = this.getDateToday();
const data = {
showPopup: false,
lang: null,
datePickerLang: {},
};
const sessionData = {
type: sessionStorage.getItem(this.sessionStorageKey + '.type'),
start: sessionStorage.getItem(this.sessionStorageKey + '.start'),
end: sessionStorage.getItem(this.sessionStorageKey + '.end'),
};
if (typeof query['type'] === 'string' && this.validateTab(query['type'])) {
data.tab = query['type'];
} else if (typeof sessionData.type === 'string' && this.validateTab(sessionData.type)) {
data.tab = sessionData.type;
} else {
data.tab = this.initialTab;
}
if (typeof query['start'] === 'string' && this.validateDate(query['start'])) {
data.start = query['start'];
} else if (typeof sessionData.start === 'string' && this.validateDate(sessionData.start)) {
data.start = sessionData.start;
} else {
data.start = today;
}
if (typeof query['end'] === 'string' && this.validateDate(query['end'])) {
data.end = query['end'];
} else if (typeof sessionData.end === 'string' && this.validateDate(sessionData.end)) {
data.end = sessionData.end;
} else {
data.end = today;
}
switch (data.tab) {
case 'day':
case 'date':
data.end = data.start;
break;
case 'week': {
const date = moment(data.start, 'YYYY-MM-DD', true);
if (date.isValid()) {
data.start = date.startOf('isoWeek').format('YYYY-MM-DD');
data.end = date.endOf('isoWeek').format('YYYY-MM-DD');
}
break;
}
case 'month': {
const date = moment(data.start, 'YYYY-MM-DD', true);
if (date.isValid()) {
data.start = date.startOf('month').format('YYYY-MM-DD');
data.end = date.endOf('month').format('YYYY-MM-DD');
}
break;
}
}
return data;
},
mounted() {
window.addEventListener('click', this.hidePopup);
this.saveData(this.tab, this.start, this.end);
this.emitChangeEvent();
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',
};
}
});
},
beforeDestroy() {
window.removeEventListener('click', this.hidePopup);
},
computed: {
inputValue() {
switch (this.tab) {
case 'date':
default:
return moment(this.start, 'YYYY-MM-DD').locale(this.$i18n.locale).format('MMM DD, YYYY');
case 'week': {
const start = moment(this.start, 'YYYY-MM-DD').locale(this.$i18n.locale).startOf('isoWeek');
const end = moment(this.end, 'YYYY-MM-DD').locale(this.$i18n.locale).endOf('isoWeek');
if (start.month() === end.month()) {
return start.format('MMM DD-') + end.format('DD, YYYY');
}
return start.format('MMM DD — ') + end.format('MMM DD, YYYY');
}
case 'month':
return moment(this.start, 'YYYY-MM-DD')
.locale(this.$i18n.locale)
.startOf('month')
.format('MMM, YYYY');
case 'range': {
const start = moment(this.start, 'YYYY-MM-DD').locale(this.$i18n.locale);
const end = moment(this.end, 'YYYY-MM-DD').locale(this.$i18n.locale);
if (start.year() === end.year()) {
return start.format('MMM DD, — ') + end.format('MMM DD, YYYY');
} else {
return start.format('MMM DD, YYYY — ') + end.format('MMM DD, YYYY');
}
}
}
},
datePickerType() {
switch (this.tab) {
case 'day':
case 'range':
default:
return 'date';
case 'week':
return 'week';
case 'month':
return 'month';
}
},
datePickerRange() {
return this.tab === 'range';
},
datePickerValue() {
if (this.tab === 'range') {
return [moment(this.start, 'YYYY-MM-DD').toDate(), moment(this.end, 'YYYY-MM-DD').toDate()];
}
return moment(this.start, 'YYYY-MM-DD').toDate();
},
},
methods: {
getDateToday,
validateTab(tab) {
return ['day', 'date', 'week', 'month', 'range'].indexOf(tab) !== -1;
},
validateDate(date) {
return moment(date, 'YYYY-MM-DD', true).isValid();
},
togglePopup() {
this.showPopup = !this.showPopup;
},
hidePopup() {
if (this.$el.contains(event.target)) {
return;
}
this.showPopup = false;
},
selectPrevious() {
let start, end;
switch (this.tab) {
case 'day':
default: {
const date = moment(this.start).subtract(1, 'day').format('YYYY-MM-DD');
start = date;
end = date;
break;
}
case 'week': {
const date = moment(this.start).subtract(1, 'week');
start = date.startOf('isoWeek').format('YYYY-MM-DD');
end = date.endOf('isoWeek').format('YYYY-MM-DD');
break;
}
case 'month': {
const date = moment(this.start).subtract(1, 'month');
start = date.startOf('month').format('YYYY-MM-DD');
end = date.endOf('month').format('YYYY-MM-DD');
break;
}
case 'range': {
const diff = moment(this.end).diff(this.start, 'days') + 1;
start = moment(this.start).subtract(diff, 'days').format('YYYY-MM-DD');
end = moment(this.end).subtract(diff, 'days').format('YYYY-MM-DD');
break;
}
}
this.saveData(this.tab, start, end);
this.emitChangeEvent();
},
selectNext() {
let start, end;
switch (this.tab) {
case 'day':
default: {
const date = moment(this.start).add(1, 'day').format('YYYY-MM-DD');
start = date;
end = date;
break;
}
case 'week': {
const date = moment(this.start).add(1, 'week');
start = date.startOf('isoWeek').format('YYYY-MM-DD');
end = date.endOf('isoWeek').format('YYYY-MM-DD');
break;
}
case 'month': {
const date = moment(this.start).add(1, 'month');
start = date.startOf('month').format('YYYY-MM-DD');
end = date.endOf('month').format('YYYY-MM-DD');
break;
}
case 'range': {
const diff = moment(this.end).diff(this.start, 'days') + 1;
start = moment(this.start).add(diff, 'days').format('YYYY-MM-DD');
end = moment(this.end).add(diff, 'days').format('YYYY-MM-DD');
break;
}
}
this.saveData(this.tab, start, end);
this.emitChangeEvent();
},
onTabChange({ index, name }) {
this.tab = 'range';
this.$nextTick(() => {
this.tab = name;
});
},
setDate(value) {
let start, end;
switch (this.tab) {
case 'day':
default: {
const date = moment(value).format('YYYY-MM-DD');
start = date;
end = date;
break;
}
case 'week':
start = moment(value).startOf('isoWeek').format('YYYY-MM-DD');
end = moment(value).endOf('isoWeek').format('YYYY-MM-DD');
break;
case 'month':
start = moment(value).startOf('month').format('YYYY-MM-DD');
end = moment(value).endOf('month').format('YYYY-MM-DD');
break;
case 'range':
start = moment(value[0]).format('YYYY-MM-DD');
end = moment(value[1]).format('YYYY-MM-DD');
break;
}
this.saveData(this.tab, start, end);
this.emitChangeEvent();
},
saveData(type, start, end) {
this.tab = type;
this.start = start;
this.end = end;
sessionStorage.setItem(this.sessionStorageKey + '.type', type);
sessionStorage.setItem(this.sessionStorageKey + '.start', start);
sessionStorage.setItem(this.sessionStorageKey + '.end', end);
const { query } = this.$route;
const searchParams = new URLSearchParams({ type, start, end }).toString();
// HACK: The native history is used because changing
// params via Vue Router closes all pending requests
history.pushState(null, null, `?${searchParams}`);
},
emitChangeEvent() {
this.$emit('change', {
type: sessionStorage.getItem(this.sessionStorageKey + '.type'),
start: sessionStorage.getItem(this.sessionStorageKey + '.start'),
end: sessionStorage.getItem(this.sessionStorageKey + '.end'),
});
},
onDateChange(value) {
this.showPopup = false;
this.setDate(value);
},
setToday() {
this.tab = 'day';
this.$refs.tabs.setNavByIndex(0);
this.setDate(new Date());
this.hidePopup();
},
},
watch: {
$route(to, from) {
const { query } = to;
if (typeof query['type'] === 'string' && this.validateTab(query['type'])) {
sessionStorage.setItem(this.sessionStorageKey + '.type', (this.tab = query['type']));
}
if (typeof query['start'] === 'string' && this.validateDate(query['start'])) {
sessionStorage.setItem(this.sessionStorageKey + '.start', (this.start = query['start']));
}
if (typeof query['end'] === 'string' && this.validateDate(query['end'])) {
sessionStorage.setItem(this.sessionStorageKey + '.end', (this.end = query['end']));
}
this.emitChangeEvent();
},
},
};
</script>
<style lang="scss" scoped>
.calendar {
position: relative;
}
.calendar-icon {
position: absolute;
top: 0;
right: 2em;
color: #2e2ef9;
line-height: 40px;
pointer-events: none;
}
.input {
background: #ffffff;
width: 330px;
height: 40px;
border: 1px solid #eeeef5;
border-radius: 5px;
cursor: pointer;
&::v-deep {
.at-input-group__prepend,
.at-input-group__append,
.at-input__original {
border: 0;
background: transparent;
}
.at-input-group__prepend,
.at-input-group__append {
padding: 0;
font-weight: bold;
}
.at-input__original {
cursor: pointer;
}
}
.fa-calendar {
color: #2e2ef9;
}
.previous,
.next {
color: #2e2ef9;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
width: 28px;
height: 100%;
cursor: pointer;
user-select: none;
}
}
.datepicker-wrapper {
position: absolute;
width: 320px;
max-height: unset;
&--range {
width: 640px;
}
}
@media (max-width: 750px) {
.datepicker-wrapper {
&--range {
width: 320px;
}
}
}
.datepicker__footer {
text-align: left;
}
.calendar::v-deep {
.at-tabs__header {
margin-bottom: 0;
}
.at-tabs-nav {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
}
.at-tabs-nav__item {
color: #c4c4cf;
font-size: 15px;
font-weight: 600;
margin-right: 0;
padding: 0;
flex: 1;
text-align: center;
&--active {
color: #2e2ef9;
}
&::after {
background-color: #2e2ef9;
}
}
.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;
}
}
.cell.in-range {
background: transparent;
& > div {
display: inline-block;
background: #eeeef5;
color: inherit;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
width: 100%;
height: 22px;
line-height: 22px;
}
&:last-child > div {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
}
.cell.in-range + .cell.in-range > div {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.mx-active-week {
background: transparent;
.cell > div {
border-radius: 0;
}
.cell:nth-child(3) > div {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
.cell:nth-child(7) > div {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.cell + .cell:not(:last-child) > div {
display: inline-block;
background: #eeeef5;
color: #151941;
width: 100%;
height: 22px;
line-height: 22px;
}
.mx-week-number + .cell > div,
.cell:last-child > 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,72 @@
<template>
<div class="color-input__item">
<at-modal v-model="modal" :showHead="false" :showClose="false" :showFooter="false">
<ChromePicker :value="value" @input="$emit('change', $event.hex)" />
</at-modal>
<div class="color-input__color">
<div class="at-input__original" :style="{ background: value }" @click.prevent="modal = true" />
</div>
<at-button class="color-input__remove" @click.prevent="$emit('change', null)">
<span class="icon icon-x" />
</at-button>
</div>
</template>
<script>
import { Chrome } from 'vue-color';
export default {
components: {
ChromePicker: Chrome,
},
props: {
value: {},
},
data() {
return {
modal: false,
};
},
};
</script>
<style lang="scss" scoped>
.at-input__original {
width: 170px;
height: 40px;
cursor: pointer;
border-radius: 5px;
padding: 0;
}
.color-input {
&__item {
display: flex;
flex-flow: row nowrap;
&::v-deep .at-modal {
width: 225px !important;
}
&::v-deep .at-modal__body {
padding: 0;
}
}
&__color {
flex: 1;
margin-right: 0.5em;
margin-bottom: 0.75em;
}
&__remove {
height: 40px;
}
&__color {
max-width: 170px;
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div class="dropdown">
<at-dropdown :placement="position" :trigger="trigger" @on-dropdown-command="onExport">
<at-button type="text">
<span class="icon icon-save" />
</at-button>
<at-dropdown-menu slot="menu">
<at-dropdown-item v-for="(type, key) in types" :key="key" :name="key">{{
key.toUpperCase()
}}</at-dropdown-item>
</at-dropdown-menu>
</at-dropdown>
</div>
</template>
<script>
import AboutService from '@/services/resource/about.service';
const aboutService = new AboutService();
export default {
name: 'ExportDropdown',
props: {
position: {
type: String,
default: 'bottom-left',
},
trigger: {
type: String,
default: 'click',
},
},
data: () => ({
types: [],
}),
async created() {
this.types = await aboutService.getReportTypes();
},
methods: {
onExport(format) {
this.$emit('export', this.types[format]);
},
onClose() {
this.$emit('close');
},
},
};
</script>
<style lang="scss" scoped>
.dropdown {
display: block;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
&::v-deep .at-btn__text {
color: #2e2ef9;
font-size: 25px;
}
}
.at-dropdown-menu {
right: 5px;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,350 @@
<template>
<div ref="groupSelect" class="group-select" @click="onActive">
<v-select
v-model="model"
:options="options"
:filterable="false"
label="name"
:clearable="true"
:reduce="option => option.name"
:components="{ Deselect, OpenIndicator }"
:dropdownShouldOpen="dropdownShouldOpen"
@open="onOpen"
@close="onClose"
@search="onSearch"
@option:selecting="handleSelecting"
>
<template #option="{ id, name, depth, current }">
<span class="option" :class="{ 'option--current': current }">
<span class="option__text">
<span v-if="depth > 0" class="option__depth">{{ getSpaceByDepth(depth) }}</span>
<span class="option__label" :title="ucfirst(name)">{{ ucfirst(name) }}</span>
<span @click.stop>
<router-link
class="option__link"
:to="{ name: 'ProjectGroups.crud.groups.edit', params: { id: id } }"
target="_blank"
rel="opener"
>
<i class="icon icon-external-link" />
</router-link>
</span>
</span>
</span>
</template>
<template #no-options="{ search }">
<span>{{ $t('field.no_groups_found', { query: search }) }}</span>
</template>
<template #list-footer="{ search }">
<at-button v-show="query !== ''" type="primary" class="no-option" size="small" @click="createGroup">
<span class="icon icon-plus-circle"></span>
{{ $t('field.fast_create_group', { query: search }) }}
</at-button>
<at-button type="primary" class="no-option" size="small" @click="navigateToCreateGroup">
<span class="icon icon-plus-circle"></span>
{{ $t('field.to_create_group', { query: search }) }}
</at-button>
<li v-show="hasNextPage" ref="load" class="option__infinite-loader">
{{ $t('field.loading_groups') }} <i class="icon icon-loader"></i>
</li>
</template>
</v-select>
</div>
</template>
<script>
import ProjectGroupsService from '@/services/resource/project-groups.service';
import { ucfirst } from '@/utils/string';
import { mapGetters } from 'vuex';
import vSelect from 'vue-select';
import debounce from 'lodash/debounce';
const service = new ProjectGroupsService();
export default {
name: 'GroupSelect',
components: {
vSelect,
},
props: {
value: {
type: [Object],
default: null,
},
},
data() {
return {
isSelectOpen: false,
totalPages: 0,
currentPage: 0,
query: '',
lastSearchQuery: '',
Deselect: { render: h => h('i', { class: 'icon icon-x' }) },
};
},
computed: {
...mapGetters('projectGroups', ['groups']),
model: {
get() {
return this.value;
},
set(option) {
if (typeof option === 'object') {
this.$emit('input', option);
}
},
},
options() {
if (!this.groups.has(this.lastSearchQuery)) {
return [];
}
return this.groups.get(this.lastSearchQuery).map(({ id, name, depth }) => ({
id,
name,
depth,
current: id === this.value?.id,
}));
},
hasNextPage() {
return this.currentPage < this.totalPages;
},
OpenIndicator() {
return {
render: h =>
h('i', {
class: {
icon: true,
'icon-chevron-down': !this.isSelectOpen,
'icon-chevron-up': this.isSelectOpen,
},
}),
};
},
},
created() {
this.search = debounce(this.search, 350);
},
mounted() {
this.observer = new IntersectionObserver(this.infiniteScroll);
document.addEventListener('click', this.onClickOutside);
},
beforeDestroy() {
document.removeEventListener('click', this.onClickOutside);
},
methods: {
ucfirst,
navigateToCreateGroup() {
this.$router.push({ name: 'ProjectGroups.crud.groups.new' });
},
dropdownShouldOpen() {
if (this.isSelectOpen) {
this.onSearch(this.query);
}
return this.isSelectOpen;
},
async createGroup() {
const query = this.query;
this.query = '';
this.onClose();
try {
const { data } = await service.save({ name: query }, true);
this.model = data.data;
document.activeElement.blur();
} catch (e) {
// TODO
}
},
getSpaceByDepth: function (depth) {
return ''.padStart(depth, '-');
},
onActive() {
this.onOpen();
this.$refs.groupSelect.parentElement.style.zIndex = 1;
},
async onOpen() {
this.isSelectOpen = true;
await this.$nextTick();
this.observe();
},
onClose() {
this.$refs.groupSelect.parentElement.style.zIndex = 0;
this.isSelectOpen = false;
this.observer.disconnect();
},
onSearch(query) {
this.query = query;
this.search.cancel();
this.search();
},
async search() {
this.observer.disconnect();
this.totalPages = 0;
this.currentPage = 0;
this.lastSearchQuery = this.query;
await this.$nextTick();
await this.loadOptions();
await this.$nextTick();
this.observe();
},
handleSelecting(option) {
this.onClose();
this.model = option;
},
async infiniteScroll([{ isIntersecting, target }]) {
if (isIntersecting) {
const ul = target.offsetParent;
const scrollTop = target.offsetParent.scrollTop;
await this.loadOptions();
await this.$nextTick();
ul.scrollTop = scrollTop;
this.observer.disconnect();
this.observe();
}
},
observe() {
if (this.isSelectOpen && this.$refs.load) {
this.observer.observe(this.$refs.load);
}
},
async loadOptions() {
this.$store.dispatch('projectGroups/loadGroups', {
query: this.lastSearchQuery,
page: this.currentPage,
});
this.currentPage++;
},
onClickOutside(e) {
const opened = this.$el.contains(e.target);
if (!opened) {
this.onClose();
}
},
},
};
</script>
<style lang="scss" scoped>
.group-select {
border-radius: 5px;
width: 100%;
&::v-deep {
.v-select {
height: 100%;
}
.vs__selected-options {
display: block;
white-space: pre;
}
.vs__selected,
.vs__search {
display: inline;
}
.vs--open .vs__search {
width: 100%;
}
.vs__actions {
display: flex;
font-size: 14px;
margin-right: 8px;
width: 30px;
position: relative;
}
.vs__clear {
display: block;
}
.vs__open-indicator {
transform: none;
position: absolute;
right: 0;
}
.vs__no-options {
padding: 0;
font-family: inherit;
}
.vs__dropdown-menu {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
}
.option {
&--current {
font-weight: bold;
}
&__depth {
padding-right: 0.3em;
letter-spacing: 0.1em;
opacity: 0.3;
font-weight: 300;
}
&__infinite-loader {
display: flex;
justify-content: center;
align-items: center;
column-gap: 0.3rem;
z-index: 2;
}
&__text {
display: flex;
}
&__label {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 0.5em;
}
&__link {
font-size: 15px;
}
}
.no-option {
margin-top: 10px;
cursor: pointer;
display: block;
width: 100%;
& div {
word-break: break-all;
white-space: initial;
line-height: 20px;
&::before {
margin-right: 5px;
}
}
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<at-select v-if="Object.keys(languages).length > 0" :value="value" @on-change="inputHandler($event)">
<at-option v-for="(lang, index) in languages" :key="index" :value="lang.value">
{{ lang.label }}
</at-option>
</at-select>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
props: {
value: {
type: [Number, String],
required: true,
},
},
computed: {
...mapGetters('lang', ['langList']),
languages() {
return Object.keys(this.langList).map(p => ({
value: p,
label: this.langList[p],
}));
},
},
methods: {
inputHandler(ev) {
this.$emit('setLanguage', ev);
},
},
};
</script>

View File

@@ -0,0 +1,65 @@
<template>
<ul class="listbox">
<li v-for="(value, index) of values" :key="value[keyField]" class="listbox__item">
<at-checkbox :checked="value[valueField]" @on-change="onChange(index, $event)">
{{ value[labelField] }}
</at-checkbox>
</li>
</ul>
</template>
<script>
export default {
model: {
prop: 'values',
event: 'change',
},
props: {
values: {
type: Array,
default: () => [],
},
keyField: {
type: String,
required: true,
},
labelField: {
type: String,
required: true,
},
valueField: {
type: String,
required: true,
},
},
methods: {
onChange(index, value) {
const values = [...this.values];
values[index][this.valueField] = value;
this.$emit('change', values);
},
},
};
</script>
<style lang="scss" scoped>
.listbox {
border: 1px solid #c5d9e8;
border-radius: 4px;
transition: border 0.2s;
margin-bottom: 0.75em;
padding: 8px 12px;
min-height: 40px;
max-height: 200px;
overflow-y: auto;
&:hover {
border-color: #79a1eb;
}
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<div class="at-select-wrapper">
<at-select
ref="select"
v-model="model"
multiple
filterable
placeholder=""
:size="size"
@click="onClick"
@input="onChange"
>
<li v-if="showSelectAll" class="at-select__option" @click="selectAll()">
{{ allOptionsSelected ? $t('control.deselect_all') : $t('control.select_all') }}
</li>
<slot name="before-options"></slot>
<at-option
v-for="option of options"
:key="option.id"
:value="option.id"
:label="option.name"
@on-select-close="onClose"
>
</at-option>
</at-select>
<span v-if="showCount" class="at-select__placeholder">
{{ placeholderText }}
</span>
<i v-if="model.length > 0" class="icon icon-x at-select__clear" @click.stop="clearSelect"></i>
</div>
</template>
<script>
import ResourceService from '../services/resource.service';
export default {
props: {
service: {
type: ResourceService,
},
selected: {
type: [String, Number, Array, Object],
default: Array,
},
inputHandler: {
type: Function,
},
prependName: {
type: String,
default: '',
},
showSelectAll: {
type: Boolean,
default: true,
},
placeholder: {
type: String,
required: true,
},
size: {
type: String,
default: 'normal',
},
},
data() {
return {
model: [],
showCount: true,
options: [],
};
},
async created() {
try {
const all = await this.service.getAll({ headers: { 'X-Paginate': 'false' } });
this.options.push(...all);
this.$emit('onOptionsLoad', this.options);
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to projects is canceled');
}
}
if (Array.isArray(this.selected)) {
this.model = this.selected;
}
this.$nextTick(() => {
this.model.forEach(modelValue => {
if (this.$refs.select && Object.prototype.hasOwnProperty.call(this.$refs.select, '$children')) {
this.$refs.select.$children.forEach(option => {
if (option.value === modelValue) {
option.selected = true;
}
});
}
});
});
this.lastQuery = '';
this.$watch(
() => {
if (this.$refs.select === undefined) {
return;
}
return {
query: this.$refs.select.query,
visible: this.$refs.select.visible,
};
},
({ query, visible }) => {
if (visible) {
if (query.length) {
this.lastQuery = query;
} else {
if (
['input', 'keypress'].includes(window?.event?.type) ||
window?.event?.key === 'Backspace'
) {
// If query changed by user typing, save query
this.lastQuery = query;
} else {
// If query changed by clicking option and so on, restore query
this.$refs.select.query = this.lastQuery;
}
}
} else {
this.lastQuery = query;
}
},
);
},
watch: {
model(value) {
if (this.inputHandler) {
this.inputHandler(value);
}
},
},
methods: {
selectAll(predicate = () => true) {
if (this.allOptionsSelected) {
this.model = [];
} else {
// console.log(this.$refs.select);
const query = this.$refs.select.query.toUpperCase();
this.model = this.options
.filter(({ name }) => name.toUpperCase().indexOf(query) !== -1)
.filter(predicate)
.map(({ id }) => id);
}
},
clearSelect() {
this.$emit('input', []);
this.model = [];
},
onClick() {
if (this.showCount) {
this.showCount = false;
} else {
setTimeout(() => {
this.showCount = true;
}, 300);
}
},
onClose() {
this.$refs.select.query = '';
if (!this.showCount) {
setTimeout(() => {
this.showCount = true;
}, 300);
}
},
onChange(val) {
if (this.inputHandler) {
this.inputHandler(val);
}
},
},
computed: {
selectionAmount() {
return this.model.length;
},
allOptionsSelected() {
return this.options.length > 0 && this.options.length === this.selectionAmount;
},
placeholderText() {
const i18nKey = this.placeholder + (this.allOptionsSelected ? '_all' : '');
return this.$tc(i18nKey, this.selectionAmount, {
count: this.selectionAmount,
});
},
},
};
</script>
<style lang="scss" scoped>
.at-select-wrapper {
position: relative;
}
.at-select {
min-width: 240px;
&__placeholder {
left: 0;
position: absolute;
z-index: 1;
font-size: 0.9rem;
}
&--small ~ &__placeholder {
font-size: 11px;
padding: 5px 24px 0 8px;
}
&__clear {
margin-right: $spacing-05;
display: block;
cursor: pointer;
}
}
::v-deep {
.at-select {
&__placeholder {
color: #3f536d;
padding: 10px 12px;
}
&__input {
height: 100%;
z-index: 2;
}
&__selection {
border-radius: 5px;
color: black;
}
&--visible + .at-select__placeholder {
display: none;
}
&__clear {
z-index: 3;
}
&__arrow {
z-index: 3;
}
}
.at-tag {
display: none;
}
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<at-menu class="navbar container-fluid" router mode="horizontal">
<router-link to="/" class="navbar__logo"></router-link>
<div v-if="loggedIn">
<template v-for="(item, key) in navItems">
<at-submenu v-if="item.type === 'dropdown'" :key="item.label" :title="$t(item.label)">
<template slot="title">{{ $t(item.label) }}</template>
<template v-for="(child, childKey) in item.children">
<navigation-menu-item
:key="childKey"
:to="child.to || undefined"
@click="child.click || undefined"
>
{{ $t(child.label) }}
</navigation-menu-item>
</template>
</at-submenu>
<navigation-menu-item v-else :key="key" :to="item.to || undefined" @click="item.click || undefined">
{{ $t(item.label) }}
</navigation-menu-item>
</template>
</div>
<at-dropdown v-if="loggedIn" placement="bottom-right" @on-dropdown-command="userDropdownHandle">
<i class="icon icon-chevron-down at-menu__submenu-icon"></i>
<user-avatar :border-radius="10" :user="user"></user-avatar>
<at-dropdown-menu slot="menu">
<template v-for="(item, key) of userDropdownItems">
<at-dropdown-item :key="key" :name="item.to.name">
<span><i class="icon" :class="[item.icon]"></i>{{ item.title }}</span>
</at-dropdown-item>
</template>
<li class="at-dropdown-menu__item" @click="logout()">
<i class="icon icon-log-out"></i> {{ $t('navigation.logout') }}
</li>
</at-dropdown-menu>
</at-dropdown>
</at-menu>
</template>
<script>
import NavigationMenuItem from '@/components/NavigationMenuItem';
import UserAvatar from '@/components/UserAvatar';
import { getModuleList } from '@/moduleLoader';
import { mapGetters } from 'vuex';
export default {
components: {
UserAvatar,
NavigationMenuItem,
},
data() {
return {
modules: Object.values(getModuleList()).map(i => i.moduleInstance),
};
},
methods: {
userDropdownHandle(route) {
this.$router.push({ name: route });
},
async logout() {
await this.$store.getters['user/apiService'].logout();
},
},
computed: {
navItems() {
const navItems = [];
const dropdowns = {};
this.modules.forEach(m => {
const entries = m.getNavbarEntries();
entries.forEach(e => {
if (e.displayCondition(this.$store)) {
navItems.push(e.getData());
}
});
const entriesDropdown = m.getNavbarEntriesDropdown();
Object.keys(entriesDropdown).forEach(section => {
let entry = dropdowns[section];
if (typeof entry === 'undefined') {
entry = dropdowns[section] = {
type: 'dropdown',
label: section,
children: [],
};
navItems.push(entry);
}
entriesDropdown[section].forEach(e => {
if (e.displayCondition(this.$store)) {
entry.children.push(e.getData());
}
});
});
});
return navItems;
},
...mapGetters('user', ['user']),
userDropdownItems() {
const items = [
{
name: 'about',
to: {
name: 'about',
},
title: this.$t('navigation.about'),
icon: 'icon-info',
},
// {
// name: 'desktop-login',
// to: {
// name: 'desktop-login',
// },
// title: this.$t('navigation.client-login'),
// icon: 'icon-log-in',
// },
];
this.modules.forEach(m => {
const entriesDropdown = m.getNavbarMenuEntriesDropDown();
Object.keys(entriesDropdown).forEach(el => {
const { displayCondition, label, to, click, icon } = entriesDropdown[el];
if (displayCondition(this.$store)) {
items.push({
to,
icon,
click,
title: this.$t(label),
});
}
});
});
return items;
},
rules() {
return this.$store.getters['user/allowedRules'];
},
loggedIn() {
return this.$store.getters['user/loggedIn'];
},
},
};
</script>
<style lang="scss" scoped>
.navbar {
border-bottom: 0;
box-shadow: 0px 0px 10px rgba(63, 51, 86, 0.1);
display: flex;
height: auto;
justify-content: space-between;
padding: 0.75em 24px;
&__logo {
background: url('../assets/logo.svg');
background-size: cover;
height: 45px;
width: 45px;
flex-shrink: 0;
}
&::v-deep {
.at-menu {
&__item-link {
&::after {
bottom: -0.75em;
height: 3px;
}
}
}
.at-menu__submenu-title {
padding-right: 0 !important;
}
.at-dropdown {
align-items: center;
display: flex;
&-menu {
overflow: hidden;
&__item {
color: $gray-3;
font-weight: 600;
&:hover {
background-color: #fff;
color: $blue-2;
}
}
}
&__trigger {
align-items: center;
cursor: pointer;
display: flex;
.icon {
margin-right: 8px;
}
}
&__popover {
width: fit-content;
}
.at-dropdown-menu__item .icon {
margin-right: 6px;
}
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<li
class="at-menu__item"
:class="[this.active ? 'at-menu__item--active' : '', this.disabled ? 'at-menu__item--disabled' : '']"
>
<router-link v-if="Object.keys(to).length" ref="link" class="at-menu__item-link" :to="to">
<slot></slot>
</router-link>
<div v-else class="at-menu__item-link">
<slot></slot>
</div>
</li>
</template>
<script>
import { findComponentsUpward } from '@cattr/ui-kit/src/utils/util';
export default {
name: 'NavigationMenuItem',
props: {
name: {
type: [String, Number],
},
to: {
type: [Object, String],
default() {
return {};
},
},
replace: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
active: false,
};
},
mounted() {
this.$on('on-update-active', name => {
this.$nextTick(() => {
if (
this.name === name ||
(this.$refs.link && this.$refs.link.$el.classList.contains('router-link-active'))
) {
this.active = true;
const parents = findComponentsUpward(this, 'AtSubmenu');
if (parents && parents.length) {
parents.forEach(parent => {
parent.$emit('on-update-active', true);
});
}
} else {
this.active = false;
}
});
});
},
};
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div class="loader" :class="{ 'loader--transparent': isTransparent }">
<div class="lds-ellipsis">
<div />
<div />
<div />
<div />
</div>
</div>
</template>
<script>
export default {
name: 'Preloader',
props: {
isTransparent: {
type: Boolean,
default: false,
},
},
};
</script>
<style lang="scss" scoped>
.loader {
width: 100%;
height: 100%;
position: absolute;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 99;
transition: all 1s ease-out;
top: 0;
right: 0;
left: 0;
bottom: 0;
&--transparent {
background: rgba(255, 255, 255, 0.8);
}
.lds-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: $brand-blue-800;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<div class="priority-select" :style="{ background: color, color: getTextColor(color) }">
<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.value"
:label="ucfirst(option.label)"
:value="option.value"
>
<span class="option" :style="{ background: option.color, color: getTextColor(option.color) }">
<span class="option-text">
{{ ucfirst(option.label) }}
</span>
</span>
</at-option>
</at-select>
<at-input v-else disabled></at-input>
</div>
</template>
<script>
import { getTextColor } from '@/utils/color';
import { ucfirst } from '@/utils/string';
import PriorityService from '@/services/resource/priority.service';
export default {
name: 'PrioritySelect',
props: {
value: {
type: [String, Number],
default: '',
},
clearable: {
type: Boolean,
default: () => false,
},
},
async created() {
try {
this.options = await this.service.getAll().then(data => {
return data.map(option => {
return {
value: option.id,
label: option['name'],
color: option.color,
};
});
});
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 }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to resource is canceled');
}
}
},
data() {
return {
options: [],
service: new PriorityService(),
};
},
methods: {
ucfirst,
getTextColor,
},
computed: {
model: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
color() {
const option = this.options.find(option => +option.value === +this.value);
if (typeof option === 'undefined') {
return 'transparent';
}
return option.color;
},
},
};
</script>
<style lang="scss" scoped>
.priority-select {
border-radius: 5px;
&::v-deep .at-select__selection {
background: transparent;
}
&::v-deep .at-select__dropdown .at-select__option {
padding: 0;
}
&::v-deep .at-select {
color: inherit;
}
}
.option {
display: block;
width: 100%;
padding: 6px 12px;
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<multi-select
placeholder="control.project_selected"
:inputHandler="selectedProjects"
:selected="selectedProjectIds"
:service="projectService"
name="projects"
:size="size"
@onOptionsLoad="onLoad"
>
</multi-select>
</template>
<script>
import MultiSelect from '@/components/MultiSelect';
import ProjectService from '@/services/resource/project.service';
const localStorageKey = 'amazingcat.local.storage.project_select';
export default {
name: 'ProjectSelect',
components: {
MultiSelect,
},
props: {
size: {
type: String,
default: 'normal',
},
value: {
type: Array,
default: null,
},
},
data() {
const selectedProjectIds =
this.value !== null ? this.value : JSON.parse(localStorage.getItem(localStorageKey));
return {
projectService: new ProjectService(),
selectedProjectIds,
ids: [],
};
},
methods: {
onLoad(allSelectOptions) {
const allProjectIds = allSelectOptions.map(option => option.id);
this.ids = allProjectIds;
// Select all options if storage is empty
if (!localStorage.getItem(localStorageKey)) {
this.selectedProjectIds = allProjectIds;
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedProjectIds));
this.$emit('change', this.selectedProjectIds);
this.$nextTick(() => this.$emit('loaded'));
return;
}
// Remove options that no longer exists
const existingProjectIds = this.selectedProjectIds.filter(projectId =>
allProjectIds.includes(projectId),
);
if (this.selectedProjectIds.length > existingProjectIds.length) {
this.selectedProjectIds = existingProjectIds;
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedProjectIds));
}
this.$emit('change', this.selectedProjectIds);
this.$nextTick(() => this.$emit('loaded'));
},
selectedProjects(values) {
this.selectedProjectIds = values;
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedProjectIds));
this.$emit('change', values);
},
},
};
</script>

View File

@@ -0,0 +1,62 @@
<script>
import { mapGetters } from 'vuex';
export default {
name: 'RenderableField',
props: {
render: {
required: true,
type: Function,
},
value: {
default: Object,
},
field: {
required: true,
type: Object,
},
values: {
type: Object,
},
setValue: {
type: Function,
},
},
data() {
return {
currentValue: this.value,
};
},
watch: {
value(val) {
this.currentValue = val;
},
},
computed: {
...mapGetters('user', ['companyData']),
},
methods: {
inputHandler(val) {
this.$emit('input', val);
this.$emit('change', val);
},
focusHandler(evt) {
this.$emit('focus', evt);
},
blurHandler(evt) {
this.$emit('blur', evt);
},
},
render(h) {
return this.render(h, {
inputHandler: this.inputHandler,
currentValue: this.currentValue,
focusHandler: this.focusHandler,
blurHandler: this.blurHandler,
field: this.field,
values: this.values,
setValue: this.setValue,
companyData: this.companyData,
});
},
};
</script>

View File

@@ -0,0 +1,91 @@
<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="formattedLabel(option)" :value="option.id" />
</at-select>
<at-input v-else disabled></at-input>
</div>
</template>
<script>
import { ucfirst } from '@/utils/string';
export default {
name: 'ResourceSelect',
props: {
value: {
type: [String, Number],
default: '',
},
service: {
type: Object,
},
clearable: {
type: Boolean,
default: () => false,
},
},
async created() {
try {
this.options = await this.service.getAll({ headers: { 'X-Paginate': 'false' } });
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 }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to resource is canceled');
}
}
},
data() {
return {
options: [],
};
},
methods: {
ucfirst,
getName(object = {}) {
const names = ['full_name'];
let key = 'name';
if (typeof object === 'object') {
let keys = Object.keys(object);
for (let i = 0; i <= names.length; i++) {
if (keys.indexOf(names[i]) !== -1) {
key = names[i];
break;
}
}
return object[key] ?? '';
}
},
formattedLabel(option) {
const name = this.getName(option);
return name ? this.ucfirst(name) : '';
},
},
computed: {
model: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
},
};
</script>

View File

@@ -0,0 +1,73 @@
<template>
<at-select
v-if="Object.keys(roles).length > 0"
ref="select"
class="role-select"
:value="value"
@on-change="inputHandler"
>
<at-option v-for="(role, name) in roles" :key="role" :value="role" :label="$t(`field.roles.${name}.name`)">
<div>
<slot :name="`role_${name}_name`">
{{ $t(`field.roles.${name}.name`) }}
</slot>
</div>
<div class="role-select__description">
<slot :name="`role_${name}_description`">
{{ $t(`field.roles.${name}.description`) }}
</slot>
</div>
</at-option>
</at-select>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import { ucfirst } from '@/utils/string';
export default {
props: {
value: Number,
excludeRoles: {
type: Array,
default: () => [],
},
},
computed: {
roles() {
return Object.keys(this.$store.getters['roles/roles'])
.filter(key => !this.excludeRoles.includes(key))
.reduce((acc, el) => Object.assign(acc, { [el]: this.$store.getters['roles/roles'][el] }), {});
},
},
methods: {
ucfirst,
...mapActions({
getRoles: 'roles/loadRoles',
}),
inputHandler(value) {
this.$emit('input', value);
this.$emit('updateProps', value);
},
},
async created() {
await this.getRoles();
if (this.$refs.select && Object.prototype.hasOwnProperty.call(this.$refs.select, '$children')) {
this.$refs.select.$children.forEach(option => {
option.hidden = false;
});
}
},
};
</script>
<style lang="scss" scoped>
.role-select {
&__description {
white-space: normal;
opacity: 0.6;
font-size: 0.7rem;
}
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="screenshot" @click="$emit('click', $event)">
<AppImage
v-if="screenshotsEnabled"
:is-blob="true"
:src="getThumbnailPath(interval)"
class="screenshot__image"
:lazy="lazyImage"
@click="onShow"
/>
<i v-else class="icon icon-camera-off screenshot__image" />
<at-tooltip>
<template slot="content">
<div v-if="interval.activity_fill === null" class="screenshot__activity">
{{ $t('tooltip.activity_progress.not_tracked') }}
</div>
<div v-else class="screenshot__activity">
<span v-if="interval.activity_fill !== null" class="screenshot__overall-activity">
{{
$tc('tooltip.activity_progress.overall', interval.activity_fill, {
percent: interval.activity_fill,
})
}}
</span>
<div class="screenshot__device-activity">
<span v-if="interval.mouse_fill !== null">
{{
$tc('tooltip.activity_progress.mouse', interval.mouse_fill, {
percent: interval.mouse_fill,
})
}}
</span>
<span v-if="interval.keyboard_fill !== null">{{
$tc('tooltip.activity_progress.keyboard', interval.keyboard_fill, {
percent: interval.keyboard_fill,
})
}}</span>
</div>
</div>
</template>
<at-progress
class="screenshot__activity-bar"
:stroke-width="5"
:percent="+(+interval.activity_fill / 2 || 0)"
/>
</at-tooltip>
<div v-if="showText" class="screenshot__text">
<span v-if="task && showTask" class="screenshot__task" :title="`${task.task_name} (${task.project.name})`">
{{ task.task_name }} ({{ task.project.name }})
</span>
<span class="screenshot__time">{{ screenshotTime }}</span>
</div>
<ScreenshotModal
v-if="!disableModal"
:project="project"
:interval="interval"
:show="showModal"
:showNavigation="showNavigation"
:task="task"
:user="user"
@close="onHide"
@remove="onRemove"
@showNext="$emit('showNext')"
@showPrevious="$emit('showPrevious')"
/>
</div>
</template>
<script>
import moment from 'moment-timezone';
import AppImage from './AppImage';
import ScreenshotModal from './ScreenshotModal';
import { mapGetters } from 'vuex';
export function thumbnailPathProvider(interval) {
return `time-intervals/${interval.id}/thumb`;
}
export const config = { thumbnailPathProvider };
export default {
name: 'Screenshot',
components: {
AppImage,
ScreenshotModal,
},
props: {
interval: {
type: Object,
},
project: {
type: Object,
},
task: {
type: Object,
},
user: {
type: Object,
},
showText: {
type: Boolean,
default: true,
},
showTask: {
type: Boolean,
default: true,
},
showNavigation: {
type: Boolean,
default: false,
},
disableModal: {
type: Boolean,
default: false,
},
lazyImage: {
type: Boolean,
default: true,
},
timezone: {
type: String,
},
},
data() {
return { showModal: false };
},
computed: {
...mapGetters('user', ['companyData']),
...mapGetters('screenshots', { screenshotsEnabled: 'enabled' }),
screenshotTime() {
const timezone = this.timezone || this.companyData['timezone'];
if (!timezone || !this.interval.start_at) {
return;
}
return moment
.utc(this.interval.start_at)
.tz(this.companyData['timezone'], true)
.tz(timezone)
.format('HH:mm');
},
},
methods: {
onShow() {
if (this.disableModal) {
return;
}
this.showModal = true;
this.$emit('showModalChange', true);
},
onHide() {
this.showModal = false;
this.$emit('showModalChange', false);
},
onRemove() {
this.onHide();
this.$emit('remove', this.interval);
},
getThumbnailPath(interval) {
return config.thumbnailPathProvider(interval);
},
},
};
</script>
<style lang="scss" scoped>
.screenshot {
&__image {
border-radius: 5px;
cursor: pointer;
width: 100%;
line-height: 0;
overflow: hidden;
&::v-deep {
.pu-skeleton {
height: 100px;
}
img {
height: 150px;
}
}
}
.icon {
font-size: 70px;
display: flex;
justify-content: center;
}
&__text {
align-items: baseline;
color: #59566e;
display: flex;
flex-flow: row nowrap;
font-size: 11px;
font-weight: 600;
justify-content: space-between;
}
&__activity {
text-align: center;
}
&__device-activity {
white-space: nowrap;
}
&__task {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&::v-deep {
.at-tooltip {
width: 100%;
&__trigger {
width: 100%;
}
}
.at-progress__text {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<at-modal v-if="show" class="modal" :width="900" :value="true" @on-cancel="onClose" @on-confirm="onClose">
<template v-slot:header>
<span class="modal-title">{{ $t('field.screenshot') }}</span>
</template>
<AppImage
v-if="interval && interval.id && screenshotsEnabled"
class="modal-screenshot"
:src="getScreenshotPath(interval)"
:openable="true"
/>
<i v-else class="icon icon-camera-off modal-screenshot" />
<at-progress
class="screenshot__activity-bar"
:stroke-width="7"
:percent="+(+interval.activity_fill / 2 || 0)"
/>
<div v-if="showNavigation" class="modal-left">
<at-button type="primary" icon="icon-arrow-left" @click="$emit('showPrevious')"></at-button>
</div>
<div v-if="showNavigation" class="modal-right">
<at-button type="primary" icon="icon-arrow-right" @click="$emit('showNext')"></at-button>
</div>
<template v-slot:footer>
<div class="row">
<div class="col">
<div v-if="project" class="modal-field">
<span class="modal-label">{{ $t('field.project') }}:</span>
<span class="modal-value">
<router-link :to="`/projects/view/${project.id}`">{{ project.name }}</router-link>
</span>
</div>
<div v-if="task" class="modal-field">
<span class="modal-label">{{ $t('field.task') }}:</span>
<span class="modal-value">
<router-link :to="`/tasks/view/${task.id}`">{{ task.task_name }}</router-link>
</span>
</div>
<div v-if="user" class="modal-field">
<span class="modal-label">{{ $t('field.user') }}:</span>
<span class="modal-value">
{{ user.full_name }}
</span>
</div>
<div v-if="interval" class="modal-field">
<span class="modal-label">{{ $t('field.created_at') }}:</span>
<span class="modal-value">{{ formatDate(interval.start_at) }}</span>
</div>
</div>
<div class="col">
<div v-if="interval.activity_fill === null" class="screenshot__activity">
{{ $t('tooltip.activity_progress.not_tracked') }}
</div>
<div v-else class="screenshot__activity modal-field">
<div class="modal-field">
<span class="modal-label">{{ $tc('tooltip.activity_progress.overall', 0) }}</span>
<span class="modal-value">
{{ interval.activity_fill + '%' }}
</span>
</div>
<div v-if="interval.mouse_fill !== null" class="modal-field">
<span class="modal-label">
{{ $t('tooltip.activity_progress.just_mouse') }}
</span>
<span class="modal-value">
{{ interval.mouse_fill + '%' }}
</span>
</div>
<div v-if="interval.keyboard_fill !== null" class="modal-field">
<span class="modal-label">{{ $t('tooltip.activity_progress.just_keyboard') }}</span>
<span class="modal-value">
{{ interval.keyboard_fill + '%' }}
</span>
</div>
</div>
<div v-if="interval" class="modal-duration modal-field">
<span class="modal-label">{{ $t('field.duration') }}:</span>
<span class="modal-value">{{
$t('field.duration_value', [formatDate(interval.start_at), formatDate(interval.end_at)])
}}</span>
</div>
</div>
</div>
<div v-if="canRemove" class="row">
<at-button class="modal-remove" type="text" icon="icon-trash-2" @click="onRemove" />
</div>
</template>
</at-modal>
</template>
<script>
import moment from 'moment-timezone';
import AppImage from './AppImage';
import { mapGetters } from 'vuex';
export function screenshotPathProvider(interval) {
return `time-intervals/${interval.id}/screenshot`;
}
export const config = { screenshotPathProvider };
export default {
name: 'ScreenshotModal',
components: {
AppImage,
},
props: {
show: {
type: Boolean,
required: true,
},
project: {
type: Object,
},
task: {
type: Object,
},
interval: {
type: Object,
},
user: {
type: Object,
},
showNavigation: {
type: Boolean,
default: false,
},
canRemove: {
type: Boolean,
default: true,
},
},
computed: {
...mapGetters('user', ['companyData']),
...mapGetters('screenshots', { screenshotsEnabled: 'enabled' }),
},
methods: {
formatDate(value) {
return moment
.utc(value)
.tz(this.companyData.timezone, true)
.locale(this.$i18n.locale)
.format('MMMM D, YYYY — HH:mm:ss (Z)');
},
onClose() {
this.$emit('close');
},
onRemove() {
this.$emit('remove', this.interval.id);
},
getScreenshotPath(interval) {
return config.screenshotPathProvider(interval);
},
},
};
</script>
<style lang="scss" scoped>
.modal {
&::v-deep {
.pu-skeleton {
height: 70vh;
}
.at-modal__mask {
background: rgba(#151941, 0.7);
}
.at-modal__wrapper {
display: flex;
align-items: center;
justify-content: center;
overflow-y: scroll;
padding-top: 1rem;
padding-bottom: 1rem;
}
.at-modal {
border-radius: 15px;
top: unset;
height: fit-content;
}
.at-modal__header {
border: 0;
}
.at-modal__body {
padding: 0;
position: relative;
.icon-camera-off {
display: flex;
justify-content: center;
align-items: center;
font-size: 200px;
}
}
.at-modal__footer {
position: relative;
border: 0;
text-align: left;
}
.at-modal__close {
color: #b1b1be;
}
.at-progress-bar {
display: block;
&__wraper,
&__inner {
border-radius: 0;
}
}
.at-progress__text {
display: none;
}
}
&-left {
position: absolute;
left: 0;
top: 0;
height: 100%;
display: flex;
align-items: center;
}
&-right {
position: absolute;
top: 0;
right: 0;
height: 100%;
display: flex;
align-items: center;
}
&-title {
color: #000000;
font-size: 15px;
font-weight: 600;
}
&-screenshot {
display: block;
width: 100%;
height: auto;
min-height: 300px;
max-height: 70vh;
object-fit: contain;
object-position: center;
margin: 0 auto;
}
&-remove {
position: absolute;
bottom: 12px;
right: 16px;
color: #ff5569;
}
&-field {
color: #666;
font-size: 15px;
font-weight: 600;
&:not(:last-child) {
margin-bottom: 11px;
}
}
&-label {
margin-right: 0.5em;
}
&-value,
&-value a {
color: #2e2ef9;
}
&-duration {
padding-right: 3em;
}
}
@media (max-width: 500px) {
.modal ::v-deep .at-modal__wrapper {
align-items: start;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div>
<at-radio-group ref="select" v-model="model" class="screenshots-state-select">
<at-radio-button
v-for="(state, key) in states"
:key="key"
:label="key"
:disabled="isDisabled"
class="screenshots-state-select__btn"
>
<div>
<slot :name="`state__name`">
{{ $t(`control.screenshot_state_options.${state}`) }}
</slot>
</div>
</at-radio-button>
</at-radio-group>
<div v-if="hint.length > 0" class="hint">{{ $t(hint) }}</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Number,
default: () => 1,
},
isDisabled: {
type: Boolean,
default: () => false,
required: false,
},
hideIndexes: {
type: Array,
required: false,
default: () => [],
},
hint: {
type: String,
required: false,
default: () => '',
},
},
methods: {
inputHandler(value) {
this.$emit('input', value);
this.$emit('updateProps', value);
},
},
computed: {
model: {
get() {
return this.value;
},
set(value) {
this.inputHandler(value);
},
},
states() {
let states = [];
Object.keys(this.$store.getters['screenshots/states']).forEach((item, i) => {
if (!this.hideIndexes.includes(i)) {
return states.push(item);
}
});
return states;
},
},
};
</script>
<style lang="scss" scoped>
.screenshots-state-select {
&::v-deep {
.at-radio--checked {
.at-radio-button__inner {
background-color: $blue-2;
border-color: $blue-2;
}
}
}
}
.hint {
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<multi-select
ref="select"
placeholder="control.status_selected"
:inputHandler="selectedStatuses"
:selected="selectedStatusIds"
:service="statusService"
name="statuses"
:size="size"
@onOptionsLoad="onLoad"
>
<template v-slot:before-options>
<li class="at-select__option" @click="selectAllOpen">
{{ $t('control.select_all_open') }}
</li>
<li class="at-select__option" @click="selectAllClosed">
{{ $t('control.select_all_closed') }}
</li>
</template>
</multi-select>
</template>
<script>
import MultiSelect from '@/components/MultiSelect';
import StatusService from '@/services/resource/status.service';
const localStorageKey = 'amazingcat.local.storage.status_select';
export default {
name: 'StatusSelect',
components: {
MultiSelect,
},
props: {
size: {
type: String,
default: 'normal',
},
},
data() {
return {
statusService: new StatusService(),
selectedStatusIds: JSON.parse(localStorage.getItem(localStorageKey)),
};
},
methods: {
onLoad(allSelectOptions) {
const allStatusIds = allSelectOptions.map(option => option.id);
// Select all options if storage is empty
if (!localStorage.getItem(localStorageKey)) {
this.selectedStatusIds = allStatusIds;
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedStatusIds));
this.$emit('change', this.selectedStatusIds);
this.$nextTick(() => this.$emit('loaded'));
return;
}
// Remove options that no longer exists
const existingStatusIds = this.selectedStatusIds.filter(statusId => allStatusIds.includes(statusId));
if (this.selectedStatusIds.length > existingStatusIds.length) {
this.selectedStatusIds = existingStatusIds;
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedStatusIds));
}
this.$emit('change', this.selectedStatusIds);
this.$nextTick(() => this.$emit('loaded'));
},
selectedStatuses(values) {
this.selectedStatusIds = values;
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedStatusIds));
this.$emit('change', values);
},
selectAllOpen() {
this.$refs.select.selectAll(item => item.active);
},
selectAllClosed() {
this.$refs.select.selectAll(item => !item.active);
},
},
};
</script>

View File

@@ -0,0 +1,166 @@
<template>
<div class="col-offset-9">
<template v-if="storage">
<p class="row">
<span v-t="'about.storage.space.used'" class="col-6" />
<at-progress
:percent="storageSpaceUsed"
:status="storageSpaceUsed < storageSpaceMaxUsed ? 'error' : 'default'"
:stroke-width="15"
:title="getSize(storage.space.used)"
class="col-4"
/>
<span class="col-1" v-html="`${storageSpaceUsed}%`" />
</p>
<p class="row">
<span v-t="'about.storage.space.total'" class="col-6" />
<span class="col-4">
<at-tag>{{ getSize(storage.space.total) }}</at-tag>
</span>
</p>
<p class="row">
<span v-t="'about.storage.space.left'" class="col-6" />
<span class="col-4">
<at-tag>{{ getSize(storage.space.left) }}</at-tag>
</span>
</p>
<p class="row">
<span v-t="'about.storage.last_thinning'" class="col-6" />
<span class="col-4">
<at-tag :title="storageCleanTime">{{ storageRelativeCleanTime }}</at-tag>
</span>
</p>
<p class="row">
<span v-t="'about.storage.screenshots_available'" class="col-6" />
<span class="col-4">
<at-tag>{{
$tc('about.storage.screenshots', storage.screenshots_available, {
n: storage.screenshots_available,
})
}}</at-tag>
</span>
</p>
<p class="row">
<at-button
:disabled="thinRequested || !storage.screenshots_available || storage.thinning.now"
:loading="thinRequested"
:title="cleanButtonTitle"
class="col-10"
hollow
@click="cleanStorage"
>
{{ thinRequested ? '' : $t('about.storage.thin') }}
</at-button>
</p>
</template>
<p v-else v-t="'about.no_storage'" />
</div>
</template>
<script>
import moment from 'moment';
import AboutService from '@/services/resource/about.service';
const aboutService = new AboutService();
export default {
name: 'StorageManagementTab',
data: () => ({
storageSpaceMaxUsed: process.env.VUE_APP_STORAGE_SPACE_MAX_USED,
storage: null,
thinRequested: false,
}),
computed: {
storageSpaceUsed() {
return Math.round((this.storage.space.used * 100) / this.storage.space.total);
},
storageRelativeCleanTime() {
return moment(this.storage.thinning.last).fromNow();
},
storageCleanTime() {
return moment(this.storage.thinning.last).format('LLL');
},
cleanButtonTitle() {
return this.$t(
!this.storage.screenshots_available || this.storage.thinning.now
? 'about.storage.thin_unavailable'
: 'about.storage.thin_available',
);
},
},
methods: {
async cleanStorage() {
this.thinRequested = true;
try {
const { status } = await aboutService.startCleanup();
if (status === 204) {
this.$Message.success('Thin has been queued!');
this.storage.thinning.now = true;
} else {
this.$Message.error('Error happened during thin queueing!');
}
} catch (e) {
this.$Message.error('Error happened during thin queueing!');
} finally {
this.thinRequested = false;
}
},
getSize(value) {
if (value < 1024) {
return `${value} B`;
}
const KB = value / 1024;
if (KB < 1024) {
return `${Math.round(KB)} KB`;
}
const MB = KB / 1024;
if (MB < 1024) {
return `${Math.round(MB)} MB`;
}
const GB = MB / 1024;
if (GB < 1024) {
return `${Math.round(GB)} GB`;
}
return `${Math.round(GB / 1024)} TB`;
},
},
async mounted() {
this.isLoading = true;
try {
this.storage = await aboutService.getStorageInfo();
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to storage is canceled');
}
}
this.isLoading = false;
},
};
</script>
<style lang="scss" scoped>
.storage {
.at-progress {
position: relative;
top: 3px;
}
& > div {
text-align: left;
}
.at-btn {
margin-top: 15px;
}
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="team-avatars">
<div class="team-avatars__preview">
<at-tooltip v-for="user of users.slice(0, 2)" :key="user.id" placement="top" :content="user.full_name">
<user-avatar :user="user" class="team-avatars__avatar"></user-avatar>
</at-tooltip>
<at-popover placement="top" trigger="click">
<div v-if="users.length > 2" class="team-avatars__placeholder team-avatars__avatar">
<span>+{{ users.slice(2).length }}</span>
</div>
<template slot="content">
<div class="tooltip__avatars">
<at-tooltip
v-for="user of users.slice(2)"
:key="user.id"
placement="top"
:content="user.full_name"
>
<user-avatar :user="user" class="team-avatars__avatar"></user-avatar>
</at-tooltip>
</div>
</template>
</at-popover>
</div>
</div>
</template>
<script>
import UserAvatar from '@/components/UserAvatar.vue';
export default {
name: 'TeamAvatars',
components: {
UserAvatar,
},
props: {
users: {
required: true,
type: Array,
},
},
};
</script>
<style lang="scss" scoped>
.team-avatars {
&__preview {
display: flex;
}
&__avatar {
margin: $spacing-01;
}
&__placeholder {
display: flex;
width: 30px;
height: 30px;
border-radius: 5px;
font:
12px / 30px Helvetica,
Arial,
sans-serif;
align-items: center;
justify-content: center;
text-align: center;
user-select: none;
background-color: rgb(158, 158, 158);
color: rgb(238, 238, 238);
cursor: pointer;
}
}
.tooltip {
&__avatars {
display: flex;
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="at-select" :class="{ 'at-select--visible': visible }">
<v-select
ref="select"
v-model="model"
class="timezone-select"
:options="paginated"
:filterable="false"
:placeholder="$t('control.select')"
@open="onOpen"
@close="onClose"
@search="search = $event"
>
<template #list-footer>
<li v-show="hasNextPage" ref="load" class="vs__dropdown-option">Loading...</li>
</template>
</v-select>
<i class="icon icon-chevron-down at-select__arrow" />
</div>
</template>
<script>
import moment from 'moment-timezone';
import vSelect from 'vue-select';
export default {
props: {
value: {
type: [String, Object],
required: true,
},
},
components: {
vSelect,
},
data() {
return {
timezones: [],
limit: 10,
search: '',
observer: null,
visible: false,
};
},
computed: {
model: {
get() {
return {
value: this.value,
label: this.formatTimezone(this.value),
};
},
set(option) {
if (!option) return;
this.$emit('onTimezoneChange', option.value);
},
},
filtered() {
if (!this.timezones || !this.timezones.length) return [];
return this.timezones.filter(timezone =>
timezone.label.toLowerCase().includes(this.search.toLowerCase()),
);
},
paginated() {
return this.filtered.slice(0, this.limit);
},
hasNextPage() {
return this.paginated.length < this.filtered.length;
},
},
methods: {
inputHandler(value) {
this.$emit('onTimezoneChange', value);
},
async onOpen() {
if (this.hasNextPage) {
await this.$nextTick();
this.observer.observe(this.$refs.load);
}
this.visible = true;
},
onClose() {
this.visible = false;
this.observer.disconnect();
},
async infiniteScroll([{ isIntersecting, target }]) {
if (isIntersecting) {
const ul = target.offsetParent;
const scrollTop = target.offsetParent.scrollTop;
this.limit += 10;
await this.$nextTick();
ul.scrollTop = scrollTop;
}
},
setTimezones() {
if (this.timezones.length > 1) return;
moment.tz.names().map(timezoneName => {
if (this.timezones.some(t => t.value === timezoneName)) {
return;
}
//Asia/Kolkata
if (timezoneName === 'Asia/Calcutta') {
timezoneName = 'Asia/Kolkata';
}
if (typeof timezoneName !== 'string') return;
this.timezones.push({
value: timezoneName,
label: this.formatTimezone(timezoneName),
});
});
},
formatTimezone(timezone) {
return `${timezone} (GMT${moment.tz(timezone).format('Z')})`;
},
},
created() {
this.timezones.push({
value: this.value,
label: this.formatTimezone(this.value),
});
this.setTimezones();
},
mounted() {
this.observer = new IntersectionObserver(this.infiniteScroll);
},
};
</script>
<style lang="scss" scoped>
.timezone-select {
min-width: 240px;
&::v-deep {
.vs__dropdown-menu {
width: auto;
min-width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div class="avatar">
<vue-avatar
class="avatar__photo"
:username="username"
:size="size"
:customStyle="styles"
:backgroundColor="backgroundColor"
:src="src"
/>
<div v-show="user.online" class="avatar__online-status" />
</div>
</template>
<script>
import md5 from 'js-md5';
import Avatar from 'vue-avatar';
export default {
name: 'UserAvatar',
props: {
size: {
type: Number,
default: 30,
},
borderRadius: {
type: Number,
default: 5,
},
user: {
type: Object,
required: true,
},
},
components: {
'vue-avatar': Avatar,
},
computed: {
username() {
if (!this.user || !this.user.full_name) {
return '';
}
return this.user.full_name;
},
email() {
if (!this.user || !this.user.email) {
return '';
}
return this.user.email;
},
src() {
if (this.user.email) {
const emailMD5 = md5(this.email);
return `https://www.gravatar.com/avatar/${emailMD5}?d=404`;
}
return null;
},
backgroundColor() {
return !this.username ? '#eaeaea' : null;
},
styles() {
return {
borderRadius: `${this.borderRadius}px`,
};
},
},
};
</script>
<style lang="scss" scoped>
.avatar {
position: relative;
&__online-status {
height: 7px;
width: 7px;
position: absolute;
background: #6eceb2;
border-radius: 100%;
border: 1px solid white;
right: 0;
bottom: 0px;
}
}
</style>

View File

@@ -0,0 +1,462 @@
<template>
<div class="user-select" :class="{ 'at-select--visible': showPopup }" @click="togglePopup">
<at-input class="user-select-input" :readonly="true" :value="inputValue" :size="size" />
<span v-show="userIDs.length" class="user-select__clear icon icon-x at-select__clear" @click="clearSelection" />
<span class="icon icon-chevron-down at-select__arrow" />
<transition name="slide-up">
<div v-show="showPopup" class="at-select__dropdown at-select__dropdown--bottom" @click.stop>
<at-tabs :value="userSelectTab" @on-change="onTabChange">
<at-tab-pane :label="$t('control.active')" name="active" />
<at-tab-pane :label="$t('control.inactive')" name="inactive" />
</at-tabs>
<div v-if="userSelectTab == 'active'">
<div class="user-search">
<at-input v-model="searchValue" class="user-search-input" :placeholder="$t('control.search')" />
</div>
<div>
<at-select v-model="userType" placeholder="fields.type" class="user-type-filter">
<at-option key="all" value="all">
{{ $t('field.types.all') }}
</at-option>
<at-option key="employee" value="employee">
{{ $t('field.types.employee') }}
</at-option>
<at-option key="client" value="client">
{{ $t('field.types.client') }}
</at-option>
</at-select>
</div>
<div class="user-select-all" @click="selectAllActiveUsers">
<span>{{ $t(selectedActiveUsers.length ? 'control.clear_all' : 'control.select_all') }}</span>
</div>
<div class="user-select-list">
<preloader v-if="isLoading"></preloader>
<ul>
<li
v-for="user in filteredActiveUsers"
:key="user.id"
:class="{
'user-select-item': true,
active: userIDs.includes(user.id),
}"
@click="toggleUser(user.id)"
>
<UserAvatar
class="user-avatar"
:size="25"
:borderRadius="5"
:user="user"
:online="user.online"
/>
<div class="user-name">{{ user.full_name }}</div>
</li>
</ul>
</div>
</div>
<div v-if="userSelectTab == 'inactive'">
<div class="user-search">
<at-input v-model="searchValue" class="user-search-input" :placeholder="$t('control.search')" />
</div>
<div>
<at-select v-model="userType" placeholder="fields.type" class="user-type-filter">
<at-option key="all" value="all">
{{ $t('field.types.all') }}
</at-option>
<at-option key="employee" value="employee">
{{ $t('field.types.employee') }}
</at-option>
<at-option key="client" value="client">
{{ $t('field.types.client') }}
</at-option>
</at-select>
</div>
<div class="user-select-all" @click="selectAllInactiveUsers">
<span>{{ $t(selectedInactiveUsers.length ? 'control.clear_all' : 'control.select_all') }}</span>
</div>
<div class="user-select-list">
<preloader v-if="isLoading"></preloader>
<ul>
<li
v-for="user in filteredInactiveUsers"
:key="user.id"
:class="{
'user-select-item': true,
active: userIDs.includes(user.id),
}"
@click="toggleUser(user.id)"
>
<UserAvatar
class="user-avatar"
:size="25"
:borderRadius="5"
:user="user"
:online="user.online"
/>
<div class="user-name">{{ user.full_name }}</div>
</li>
</ul>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import UserAvatar from '@/components/UserAvatar';
import UsersService from '@/services/resource/user.service';
import Preloader from '@/components/Preloader';
export default {
name: 'UserSelect',
components: {
UserAvatar,
Preloader,
},
props: {
value: {
required: false,
default: () => {
return [];
},
},
size: {
type: String,
default: 'normal',
},
localStorageKey: {
type: String,
default: 'user-select.users',
},
},
data() {
let userIDs = [];
if (typeof this.value !== 'undefined' && this.value.length) {
userIDs = this.value;
} else {
if (localStorage.getItem(this.localStorageKey)) {
userIDs = JSON.parse(localStorage.getItem(this.localStorageKey));
}
}
return {
showPopup: false,
userSelectTab: 'active',
userIDs,
usersService: new UsersService(),
searchValue: '',
changed: false,
users: [],
userType: 'all',
isLoading: false,
};
},
async created() {
window.addEventListener('click', this.hidePopup);
this.isLoading = true;
try {
this.users = await this.usersService.getAll({ headers: { 'X-Paginate': 'false' } });
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to users is canceled');
}
}
if (!localStorage.getItem(this.localStorageKey)) {
this.userIDs = this.users.filter(user => user.active).map(user => user.id);
localStorage.setItem(this.localStorageKey, JSON.stringify(this.userIDs));
}
// remove nonexistent users from selected
const existingUserIDs = this.users.filter(user => this.userIDs.includes(user.id)).map(user => user.id);
if (this.userIDs.length > existingUserIDs.length) {
this.userIDs = existingUserIDs;
localStorage.setItem(this.localStorageKey, JSON.stringify(this.userIDs));
}
if (this.userIDs.length) {
this.$emit('change', this.userIDs);
}
this.isLoading = false;
this.$nextTick(() => this.$emit('loaded'));
},
beforeDestroy() {
window.removeEventListener('click', this.hidePopup);
},
computed: {
activeUsers() {
return this.users.filter(user => user.active);
},
inactiveUsers() {
return this.users.filter(user => !user.active);
},
selectedActiveUsers() {
return this.activeUsers.filter(({ id }) => this.userIDs.includes(id));
},
selectedInactiveUsers() {
return this.inactiveUsers.filter(({ id }) => this.userIDs.includes(id));
},
filteredActiveUsers() {
return this.activeUsers.filter(user => {
if (this.userType !== 'all' && user.type !== this.userType) {
return false;
}
const name = user.full_name.toUpperCase();
const value = this.searchValue.toUpperCase();
return name.indexOf(value) !== -1;
});
},
filteredInactiveUsers() {
return this.inactiveUsers.filter(user => {
if (this.userType !== 'all' && user.type !== this.userType) {
return false;
}
const name = user.full_name.toUpperCase();
const value = this.searchValue.toUpperCase();
return name.indexOf(value) !== -1;
});
},
inputValue() {
return this.$tc('control.user_selected', this.userIDs.length, {
count: this.userIDs.length,
});
},
},
methods: {
togglePopup() {
this.showPopup = !this.showPopup;
if (!this.showPopup && this.changed) {
this.changed = false;
this.$emit('change', this.userIDs);
}
},
hidePopup() {
if (this.$el.contains(event.target)) {
return;
}
this.showPopup = false;
if (this.changed) {
this.changed = false;
this.$emit('change', this.userIDs);
}
},
clearSelection() {
this.userIDs = [];
this.$emit('change', this.userIDs);
localStorage[this.localStorageKey] = JSON.stringify(this.userIDs);
},
toggleUser(userID) {
if (this.userIDs.includes(userID)) {
this.userIDs = this.userIDs.filter(id => id !== userID);
} else {
this.userIDs.push(userID);
}
this.changed = true;
localStorage[this.localStorageKey] = JSON.stringify(this.userIDs);
},
selectAllActiveUsers() {
// If some users already selected we are going to clear it
if (!this.selectedActiveUsers.length) {
this.userIDs = this.userIDs.concat(
this.activeUsers
.filter(({ full_name, type }) => {
if (this.userType !== 'all' && this.userType !== type) {
return false;
}
return full_name.toUpperCase().indexOf(this.searchValue.toUpperCase()) !== -1;
})
.map(({ id }) => id)
.filter(id => !this.userIDs.includes(id)),
);
} else {
this.userIDs = this.userIDs.filter(uid => !this.activeUsers.map(({ id }) => id).includes(uid));
}
this.changed = true;
localStorage[this.localStorageKey] = JSON.stringify(this.userIDs);
},
selectAllInactiveUsers() {
if (!this.selectedInactiveUsers.length) {
this.userIDs = this.userIDs.concat(
this.inactiveUsers
.filter(({ full_name, type }) => {
if (this.userType !== 'all' && this.userType !== type) {
return false;
}
return full_name.toUpperCase().indexOf(this.searchValue.toUpperCase()) !== -1;
})
.map(({ id }) => id)
.filter(id => !this.userIDs.includes(id)),
);
} else {
this.userIDs = this.userIDs.filter(uid => !this.inactiveUsers.map(({ id }) => id).includes(uid));
}
this.changed = true;
localStorage[this.localStorageKey] = JSON.stringify(this.userIDs);
},
onTabChange({ name }) {
this.userSelectTab = name;
},
},
};
</script>
<style lang="scss" scoped>
.user-select {
position: relative;
min-width: 240px;
&::v-deep {
.at-input__original {
border-radius: 5px;
padding-right: $spacing-08;
cursor: text;
}
.at-tabs-nav {
width: 100%;
}
.at-tabs-nav__item {
color: #b1b1be;
font-size: 15px;
font-weight: 600;
text-align: center;
margin: 0;
line-height: 39px;
width: 50%;
&--active {
color: #2e2ef9;
&::after {
background-color: #2e2ef9;
}
}
}
.at-tabs__nav {
height: 39px;
}
.at-tabs__header {
margin-bottom: 0;
}
.at-tabs__body {
display: none;
}
}
&__clear {
margin-right: $spacing-05;
display: block;
}
&-list {
overflow-y: scroll;
max-height: 200px;
position: relative;
min-height: 60px;
}
&-all {
position: relative;
display: block;
font-size: 10px;
font-weight: 600;
color: #59566e;
text-transform: uppercase;
padding: 8px 20px;
cursor: pointer;
}
&-item {
font-size: 13px;
font-weight: 500;
color: #151941;
cursor: pointer;
display: flex;
align-items: center;
padding: 7px 20px;
&.active {
background: #f4f4ff;
}
&::before,
&::after {
content: ' ';
display: table;
clear: both;
}
}
}
.user-search-input {
margin: 0;
&::v-deep {
.at-input__original {
border: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.user-type-filter {
padding: 0 12px;
}
.user-avatar {
float: left;
margin-right: 10px;
}
.user-name {
padding-bottom: 3px;
}
.at-select {
&__dropdown {
overflow: hidden;
max-height: 360px;
}
}
</style>

View File

@@ -0,0 +1,38 @@
<script>
import MarkdownIt from 'markdown-it';
export default {
name: 'VueMarkdown',
props: {
source: {
type: String,
required: true,
},
options: {
type: Object,
required: false,
},
plugins: {
type: Array,
required: false,
},
},
data() {
const md = new MarkdownIt(this.options);
for (const plugin of this.plugins ?? []) {
md.use(plugin);
}
return {
md,
};
},
computed: {
content() {
return this.md.render(this.source);
},
},
render(h) {
return h('div', { domProps: { innerHTML: this.content } });
},
};
</script>

View File

@@ -0,0 +1,239 @@
<template>
<div>
<transition name="fade">
<div v-show="visible" class="at-modal__mask" @click="handleMaskClick"></div>
</transition>
<div
class="at-modal__wrapper"
:class="{
'at-modal--hidden': !wrapShow,
'at-modal--confirm': isIconType,
[`at-modal--confirm-${type}`]: isIconType,
}"
@click.self="handleWrapperClick"
>
<transition name="fade">
<div v-show="visible" class="at-modal" :style="modalStyle">
<div v-if="showHead && ($slots.header || this.title)" class="at-modal__header" :style="headerStyle">
<div class="at-modal__title">
<slot name="header">
<i v-if="isIconType" class="icon at-modal__icon" :class="iconClass" />
<p>{{ title }}</p>
</slot>
</div>
</div>
<div class="at-modal__body" :style="bodyStyle">
<slot>
<p>{{ content }}</p>
<div v-if="showInput" class="at-modal__input">
<at-input
ref="input"
v-model="inputValue"
:placeholder="inputPlaceholder"
@keyup.enter.native="handleAction('confirm')"
></at-input>
</div>
</slot>
</div>
<div v-if="showFooter" class="at-modal__footer" :style="footerStyle">
<slot name="footer">
<at-button v-show="showCancelButton" @click.native="handleAction('cancel')"
>{{ localeCancelText }}
</at-button>
<at-button
v-show="showConfirmButton"
:type="typeButton"
@click.native="handleAction('confirm')"
>{{ localeOKText }}
</at-button>
</slot>
</div>
<span v-if="showClose" class="at-modal__close" @click="handleAction('cancel')"
><i class="icon icon-x"></i
></span>
</div>
</transition>
</div>
</div>
</template>
<script>
import { t } from '@cattr/ui-kit/src/locale';
export default {
name: 'custom-at-modal',
props: {
title: String,
content: String,
value: {
type: Boolean,
default: false,
},
cancelText: {
type: String,
},
okText: {
type: String,
},
maskClosable: {
type: Boolean,
default: true,
},
showHead: {
type: Boolean,
default: true,
},
showClose: {
type: Boolean,
default: true,
},
showFooter: {
type: Boolean,
default: true,
},
showInput: {
type: Boolean,
default: false,
},
width: {
type: [Number, String],
default: 520,
},
closeOnPressEsc: {
type: Boolean,
default: true,
},
styles: {
type: Object,
default() {
return {};
},
},
type: String,
typeButton: {
type: String,
default: 'primary',
},
},
data() {
return {
wrapShow: false,
showCancelButton: true,
showConfirmButton: true,
action: '',
visible: this.value,
inputValue: null,
inputPlaceholder: '',
callback: null,
};
},
computed: {
headerStyle() {
return Object.prototype.hasOwnProperty.call(this.styles, 'header') ? this.styles.header : {};
},
footerStyle() {
return Object.prototype.hasOwnProperty.call(this.styles, 'footer') ? this.styles.footer : {};
},
bodyStyle() {
return Object.prototype.hasOwnProperty.call(this.styles, 'body') ? this.styles.body : {};
},
iconClass() {
const classArr = {
success: 'icon-check-circle',
error: 'icon-x-circle',
warning: 'icon-alert-circle',
info: 'icon-info',
trash: 'icon-trash-2',
};
return classArr[this.type] || '';
},
isIconType() {
return ['success', 'error', 'warning', 'info', 'trash'].indexOf(this.type) > -1;
},
modalStyle() {
const style = {};
const styleWidth = {
width: `${this.width}px`,
};
Object.assign(style, styleWidth, this.styles);
return style;
},
localeOKText() {
return typeof this.okText === 'undefined' ? t('at.modal.okText') : this.okText;
},
localeCancelText() {
return typeof this.cancelText === 'undefined' ? t('at.modal.cancelText') : this.cancelText;
},
},
watch: {
value(val) {
this.visible = val;
},
visible(val) {
if (val) {
if (this.timer) {
clearTimeout(this.timer);
}
this.wrapShow = true;
} else {
this.timer = setTimeout(() => {
this.wrapShow = false;
}, 300);
}
},
},
methods: {
doClose() {
this.visible = false;
this.$emit('input', false);
this.$emit('on-cancel');
if (this.action && this.callback) {
this.callback(this.action, this);
}
},
handleMaskClick(evt) {
if (this.maskClosable) {
this.doClose();
}
},
handleWrapperClick(evt) {
this.action = 'close';
if (this.maskClosable) {
this.doClose();
}
},
handleAction(action) {
this.action = action;
if (action === 'confirm') {
this.$emit('input', false);
this.$emit('on-confirm');
}
this.doClose();
},
handleKeyCode(evt) {
if (this.visible && this.showClose) {
if (evt.keyCode === 27) {
// Escape
this.doClose();
}
}
},
},
mounted() {
if (this.visible) {
this.wrapShow = true;
}
document.addEventListener('keydown', this.handleKeyCode);
},
beforeDestory() {
document.removeEventListener('keydown', this.handleKeyCode);
},
};
</script>

View File

@@ -0,0 +1,226 @@
import Vue from 'vue';
import CustomAtModal from './CustomAtModal.vue';
const DialogConstructer = Vue.extend(CustomAtModal);
let currentModal;
let instance;
let modalQueue = [];
const defaults = {
title: '',
content: '',
type: '',
};
const defultCallback = action => {
if (currentModal) {
const callback = currentModal.callback;
if (typeof callback === 'function') {
if (instance.showInput) {
callback(instance.inputValue, action);
} else {
callback(action);
}
}
if (currentModal.resolve) {
const type = currentModal.options.type;
if (type === 'confirm' || type === 'prompt') {
if (action === 'confirm') {
if (instance.showInput) {
currentModal.resolve({ value: instance.inputValue, action });
} else {
currentModal.resolve(action);
}
} else if (action === 'cancel' && currentModal.reject) {
currentModal.reject(action);
}
} else {
currentModal.resolve(action);
}
}
}
};
const initInstance = () => {
instance = new DialogConstructer({
el: document.createElement('div'),
});
instance.callback = defultCallback;
};
const showNextModal = () => {
initInstance();
instance.action = '';
if (!instance.visible && modalQueue.length) {
currentModal = modalQueue.shift();
const options = currentModal.options;
for (const prop in options) {
if (Object.prototype.hasOwnProperty.call(options, prop)) {
instance[prop] = options[prop];
}
}
if (typeof options.callback !== 'function') {
instance.callback = defultCallback;
}
const oldCallback = instance.callback;
instance.callback = (action, instance) => {
oldCallback(action, instance);
showNextModal();
};
document.body.appendChild(instance.$el);
Vue.nextTick(() => {
instance.visible = true;
});
}
};
const Dialog = (options, callback) => {
if (Vue.prototype.$isServer) return;
if (options.callback && !callback) {
callback = options.callback;
}
if (typeof Promise !== 'undefined') {
return new Promise((resolve, reject) => {
modalQueue.push({
options: Object.assign({}, defaults, options),
callback,
resolve,
reject,
});
showNextModal();
});
}
modalQueue.push({
options: Object.assign({}, defaults, options),
callback,
});
showNextModal();
};
Dialog.close = () => {
instance.visible = false;
modalQueue = [];
currentModal = null;
};
/**
* Such like window.alert
*/
Dialog.alert = (content, title, options) => {
if (typeof content === 'object') {
options = content;
content = options.content;
title = options.title || '';
}
return Dialog(
Object.assign(
{
title,
content,
type: 'alert',
maskClosable: false,
showCancelButton: false,
},
options,
),
);
};
/**
* Such like window.confirm
*/
Dialog.confirm = (content, title, options) => {
if (typeof content === 'object') {
options = content;
content = options.content;
title = options.title || '';
}
return Dialog(
Object.assign(
{
title,
content,
type: 'confirm',
},
options,
),
);
};
/**
* Such like window.prompt
*/
Dialog.prompt = (content, title, options) => {
if (typeof content === 'object') {
options = content;
content = options.content;
title = options.title || '';
}
return Dialog(
Object.assign(
{
title,
content,
type: 'prompt',
showInput: true,
},
options,
),
);
};
/**
* Status Dialog
*/
function createStatusDialog(type) {
const statusTitles = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
};
return (content, title, options) => {
if (typeof content === 'object') {
options = content;
content = options.content;
title = options.title || statusTitles[type];
}
return Dialog(
Object.assign(
{
title,
content,
type,
maskClosable: false,
showCancelButton: false,
showClose: false,
},
options,
),
);
};
}
Dialog.info = createStatusDialog('info');
Dialog.success = createStatusDialog('success');
Dialog.warning = createStatusDialog('warning');
Dialog.error = createStatusDialog('error');
export default Dialog;