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,119 @@
<template>
<div id="app">
<component :is="config.beforeLayout" />
<component :is="layout">
<router-view :key="$route.path" />
</component>
</div>
</template>
<script>
import * as Sentry from '@sentry/vue';
import moment from 'moment';
import { getLangCookie, setLangCookie } from '@/i18n';
export const config = { beforeLayout: null };
export default {
name: 'App',
async created() {
if (!(await this.$store.dispatch('httpRequest/getCattrStatus'))) {
if (this.$route.name !== 'api.error') {
await this.$router.replace({ name: 'api.error' });
return;
}
return;
} else {
if (this.$route.name === 'api.error') {
await this.$router.replace('/');
return;
}
}
const userApi = this.$store.getters['user/apiService'];
if (userApi.token()) {
try {
this.$Loading.start();
await userApi.checkApiAuth();
await userApi.getCompanyData();
Sentry.setUser({
full_name: this.$store.state.user.user.data.full_name,
id: this.$store.state.user.user.data.id,
role: this.$store.state.user.user.data.role_id,
locale: this.$store.state.user.user.data.user_language,
});
} catch (e) {
console.log(e);
// Whoops
} finally {
this.$Loading.finish();
}
}
},
mounted() {
if (sessionStorage.getItem('logout')) {
this.$store.dispatch('user/setLoggedInStatus', null);
sessionStorage.removeItem('logout');
}
},
methods: {
setUserLocale() {
const user = this.$store.getters['user/user'];
const cookieLang = getLangCookie();
// Set user locale after auth
if (user.user_language) {
this.$i18n.locale = user.user_language;
setLangCookie(user.user_language);
moment.locale(user.user_language);
} else if (cookieLang) {
this.$i18n.locale = cookieLang;
moment.locale(cookieLang);
}
},
},
computed: {
isLoggedIn() {
// Somehow this is the only place in vue lifecycle which is working as it should be
// All the other places brake locale in different places
this.setUserLocale();
return this.$store.getters['user/loggedIn'];
},
layout() {
return this.$route.meta.layout || 'default-layout';
},
config() {
return config;
},
},
watch: {
isLoggedIn(status) {
if (status) {
this.$router.push({ path: '/' });
} else {
const reason = this.$store.getters['user/lastLogoutReason'];
const message =
reason === null ? 'You has been logged out' : `You has been logged out. Reason: ${reason}`;
this.$Notify({
title: 'Warning',
message,
type: 'warning',
});
this.$router.push({ name: 'auth.login' });
}
},
},
};
</script>
<style lang="scss">
@import 'sass/app';
.at-loading-bar {
&__inner {
transition: width 0.5s linear;
}
}
</style>

View File

@@ -0,0 +1,9 @@
export default class Builder {
routerConfig = {};
constructor(moduleContext) {
this.moduleContext = moduleContext;
this.routerPrefix =
(moduleContext.getRouterPrefix().startsWith('/') ? '' : '/') + moduleContext.getRouterPrefix();
}
}

View File

@@ -0,0 +1,62 @@
import Builder from '../builder';
import View from './crud/view';
import Edit from './crud/edit';
import New from './crud/new';
export default class Crud extends Builder {
view = {};
edit = {};
new = {};
constructor(
crudName,
id,
serviceClass,
filters = {},
moduleContext,
defaultPrefix = '',
hasPages = { edit: true, view: true, new: true },
) {
super(moduleContext);
this.moduleContext = moduleContext;
this.crudName = crudName;
this.id = id;
this.serviceClass = typeof serviceClass === 'object' ? serviceClass : new serviceClass();
this.hasEdit = hasPages.edit;
this.hasView = hasPages.view;
this.hasNew = hasPages.new;
this.defaultPrefix = defaultPrefix;
this.routerConfig = [];
this.filters = filters;
if (this.hasView) {
this.view = new View(this);
}
if (this.hasEdit) {
this.edit = new Edit(this);
}
if (this.hasNew) {
this.new = new New(this);
}
}
getRouterConfig() {
const toReturn = [];
if (this.view) {
toReturn.push(this.view.getRouterConfig());
}
if (this.edit) {
toReturn.push(this.edit.getRouterConfig());
}
if (this.new) {
toReturn.push(this.new.getRouterConfig());
}
return toReturn;
}
}

View File

@@ -0,0 +1,12 @@
import set from 'lodash/set';
export default class AbstractCrud {
/**
* @param property
* @param data
* @param routerConfig
*/
addToMetaProperties(property, data, routerConfig) {
set(routerConfig.meta, property, data);
}
}

View File

@@ -0,0 +1,64 @@
import AbstractCrud from './abstractCrud';
export default class Edit extends AbstractCrud {
context = {};
routerConfig = {};
constructor(context) {
super();
this.context = context;
this.routerConfig = {
path: `${context.routerPrefix}${context.defaultPrefix.length ? '/' + context.defaultPrefix : ''}/edit/:id`,
name: this.getEditRouteName(),
component: () => import(/* webpackChunkName: "editview" */ '@/views/Crud/EditView.vue'),
meta: {
auth: true,
service: context.serviceClass,
filters: context.filters,
fields: [],
pageData: {
title: context.crudName,
type: 'edit',
pageControls: [],
},
},
};
}
addPageControls() {
const arg = arguments[0];
this.addToMetaProperties('pageData.pageControls', arg, this.getRouterConfig());
return this;
}
addPageControlsToBottom() {
const arg = arguments[0];
this.addToMetaProperties('pageData.bottomComponents.pageControls', arg, this.getRouterConfig());
return this;
}
addBottomComponents() {
const arg = arguments[0];
this.addToMetaProperties('pageData.bottomComponents.components', arg, this.getRouterConfig());
return this;
}
/**
* @param {Config} fieldConfig
* @returns {Edit}
*/
addField(fieldConfig) {
this.addToMetaProperties('fields', fieldConfig, this.getRouterConfig());
return this;
}
getRouterConfig() {
return this.routerConfig;
}
getEditRouteName() {
return this.context.moduleContext.getModuleRouteName() + `.crud.${this.context.id}.edit`;
}
}

View File

@@ -0,0 +1,56 @@
import AbstractCrud from './abstractCrud';
export default class New extends AbstractCrud {
context = {};
routerConfig = {};
constructor(context) {
super();
this.context = context;
this.routerConfig = {
path: `${context.routerPrefix}${context.defaultPrefix.length ? '/' + context.defaultPrefix : ''}/new`,
name: this.getNewRouteName(),
component: () => import(/* webpackChunkName: "editview" */ '@/views/Crud/EditView.vue'),
meta: {
auth: true,
service: context.serviceClass,
filters: context.filters,
fields: [],
pageData: {
title: context.crudName,
type: 'new',
pageControls: [],
},
},
};
}
/**
*
* @param config
* @returns {New}
*/
addPageControls(config) {
this.addToMetaProperties('pageData.pageControls', config, this.getRouterConfig());
return this;
}
/**
* @param fieldConfig
* @returns {New}
*/
addField(fieldConfig) {
this.addToMetaProperties('fields', fieldConfig, this.getRouterConfig());
return this;
}
getRouterConfig() {
return this.routerConfig;
}
getNewRouteName() {
return this.context.moduleContext.getModuleRouteName() + `.crud.${this.context.id}.new`;
}
}

View File

@@ -0,0 +1,53 @@
import AbstractCrud from './abstractCrud';
export default class View extends AbstractCrud {
context = {};
routerConfig = {};
constructor(context) {
super();
this.context = context;
this.routerConfig = {
path: `${context.routerPrefix}${context.defaultPrefix.length ? '/' + context.defaultPrefix : ''}/view/:id`,
name: this.getViewRouteName(context),
component: () => import(/* webpackChunkName: "itemview" */ '@/views/Crud/ItemView.vue'),
meta: {
auth: true,
service: context.serviceClass,
filters: context.filters,
fields: [],
pageData: {
title: context.crudName,
type: 'view',
pageControls: [],
},
},
};
}
addPageControls() {
const arg = arguments[0];
this.addToMetaProperties('pageData.pageControls', arg, this.getRouterConfig());
return this;
}
/**
* Add field to page
* @param {Object} fieldConfig
* @returns {View}
*/
addField(fieldConfig) {
this.addToMetaProperties('fields', fieldConfig, this.getRouterConfig());
return this;
}
getRouterConfig() {
return this.routerConfig;
}
getViewRouteName() {
return this.context.moduleContext.getModuleRouteName() + `.crud.${this.context.id}.view`;
}
}

View File

@@ -0,0 +1,86 @@
import Builder from '../builder';
import set from 'lodash/set';
export default class Grid extends Builder {
constructor(label, id, serviceClass, moduleContext, gridData = undefined, gridRouterPath = '') {
super(moduleContext);
this.label = label;
this.id = id;
this.routerConfig = {
name: moduleContext.getModuleRouteName() + `.crud.${id}`,
path: this.routerPrefix + '/' + gridRouterPath,
component: () => import(/* webpackChunkName: "gridview" */ '../../views/Crud/GridView.vue'),
meta: {
auth: true,
pageControls: [],
gridData: {
title: label,
columns: [],
filters: [],
filterFields: [],
actions: [],
service: typeof serviceClass === 'object' ? serviceClass : new serviceClass(),
...gridData,
},
},
};
}
addColumn() {
const arg = arguments[0];
this.addToGridData('columns', arg);
return this;
}
addAction() {
const arg = arguments[0];
this.addToGridData('actions', arg);
return this;
}
addFilter() {
const arg = arguments[0];
this.addToGridData('filters', arg);
return this;
}
addFilterField() {
const arg = arguments[0];
this.addToGridData('filterFields', arg);
return this;
}
addToGridData(property, data) {
if (Array.isArray(data)) {
data.forEach(p => {
this.routerConfig.meta.gridData[property].push(p);
});
} else {
this.routerConfig.meta.gridData[property].push(data);
}
}
addToMetaProperties(property, data, routerConfig) {
set(routerConfig.meta, property, data);
}
addPageControls() {
const data = arguments[0];
if (Array.isArray(data)) {
data.forEach(p => {
this.routerConfig.meta.pageControls.push(p);
});
} else {
this.routerConfig.meta.pageControls.push(data);
}
return this;
}
getRouterConfig() {
return this.routerConfig;
}
}

View File

@@ -0,0 +1,24 @@
import isObject from 'lodash/isObject';
export default class NavbarEntry {
constructor({ label, to, displayCondition = () => true, section, icon }) {
if (!isObject(to)) {
throw new Error('[to] instance must be a JavaScript object');
}
this.label = label;
this.to = to;
this.displayCondition = displayCondition;
this.section = section;
this.icon = icon;
}
getData() {
return {
label: this.label,
to: this.to,
displayCondition: this.displayCondition,
section: this.section,
icon: this.icon,
};
}
}

View File

@@ -0,0 +1,74 @@
import { store } from '@/store';
/**
* Section class - create new section with provided params
*
* @param path string - section route path
* @param name string - section route name
* @param meta - section route meta with described fields, service etc
* @param accessCheck - function to check if user has access to work with section content
*/
export default class SettingsSection {
path = '';
name = '';
meta = {};
component = null;
children = [];
accessCheck = null;
section = {};
scope = 'settings';
constructor(
path,
name,
meta,
accessCheck = () => true,
scope = 'settings',
order = 99,
component = null,
children = [],
) {
this.path = path;
this.name = name;
this.meta = meta;
this.component =
component || (() => import(/* webpackChunkName: "settings" */ '@/views/Settings/DynamicSettings.vue'));
this.children = children;
this.accessCheck = accessCheck;
this.scope = scope;
this.order = order;
this.section = {
path: this.path,
name: this.name,
meta: this.meta,
component: this.component,
children: this.children,
accessCheck: this.accessCheck,
scope: this.scope,
order: this.order,
};
}
/**
* Init new section in store
* @returns {Promise<void>}
*/
async initSection() {
await store.dispatch('settings/setSettingSection', this.section);
}
/**
* Get section route, used to create settings child route
* @returns {{path: string, component: null, meta, name: string}}
*/
getRoute() {
return {
path: this.path,
name: this.name,
meta: this.meta,
component: this.component,
children: this.children,
};
}
}

View File

@@ -0,0 +1,331 @@
import Grid from './builder/grid';
import Crud from './builder/crud';
import NavbarEntry from './builder/navbar';
import SettingsSection from './builder/sections';
import isObject from 'lodash/isObject';
import { store } from '@/store';
/**
* Module class. This class represents the context of a module in module.init.js -> init() function.
*/
export default class Module {
routes = [];
navEntries = [];
navEntriesDropdown = {};
navEntriesMenuDropdown = [];
settingsSections = [];
companySections = [];
locales = {};
pluralizationRules = {};
additionalFields = [];
constructor(routerPrefix, moduleName) {
this.routerPrefix = routerPrefix;
this.moduleName = moduleName;
}
/**
* Add locale code to allow custom locale select
*
* @param {String} code
* @param {String} label
*
* @returns {Module}
*/
addLocaleCode(code, label) {
store.dispatch('lang/setLang', { code, label });
return this;
}
/**
* Add module to Vuex store
*
* @param {Object} vuexModule
* @returns {Module}
*/
registerVuexModule(vuexModule) {
if (!isObject(vuexModule)) {
throw new Error('Vuex Module must be an object.');
}
store.registerModule(this.moduleName.toLowerCase(), { ...vuexModule, namespaced: true });
store.dispatch(`${this.moduleName.toLowerCase()}/init`);
return this;
}
/**
* Create GRID instance, which can be exported to RouterConfig
* @param label
* @param id
* @param serviceClass
* @param gridData
* @param gridRouterPath
* @returns {Grid}
*/
createGrid(label, id, serviceClass, gridData = undefined, gridRouterPath = '') {
return new Grid(label, id, serviceClass, this, gridData, gridRouterPath);
}
/**
* Create CRUD instance, which can be exported to RouterConfig
*
* @param label
* @param id
* @param serviceClass
* @param filters
* @param defaultPrefix
* @param pages
* @returns {Crud}
*/
createCrud(label, id, serviceClass, filters, defaultPrefix = '', pages = { edit: true, view: true, new: true }) {
return new Crud(label, id, serviceClass, filters, this, defaultPrefix, pages);
}
/**
* Add route to module-scoped routerConfig
*
* @param routerConfig
* @returns {Module}
*/
addRoute(routerConfig) {
if (Array.isArray(routerConfig)) {
routerConfig.forEach(p => {
this.routes.push(p);
});
} else {
this.routes.push(routerConfig);
}
return this;
}
/**
* Add navbar entry
*/
addNavbarEntry(...args) {
Array.from(args).forEach(p => {
this.navEntries.push(
new NavbarEntry({
label: p.label,
to: p.to,
displayCondition: Object.prototype.hasOwnProperty.call(p, 'displayCondition')
? p.displayCondition
: () => true,
}),
);
});
}
/**
* Add navbar Dropdown Entry
*/
addNavbarEntryDropDown(...args) {
Array.from(args).forEach(p => {
if (!Object.prototype.hasOwnProperty.call(this.navEntriesDropdown, p.section)) {
this.navEntriesDropdown[p.section] = [];
}
this.navEntriesDropdown[p.section].push(
new NavbarEntry({
label: p.label,
to: p.to,
displayCondition: Object.prototype.hasOwnProperty.call(p, 'displayCondition')
? p.displayCondition
: () => true,
section: p.section,
}),
);
});
}
/**
* Add to user menu entry of the navbar
*/
addUserMenuEntry(...args) {
Array.from(args).forEach(a => {
this.navEntriesMenuDropdown.push(
new NavbarEntry({
label: a.label,
to: a.to,
displayCondition: Object.prototype.hasOwnProperty.call(a, 'displayCondition')
? a.displayCondition
: () => true,
icon: a.icon,
}),
);
});
}
/**
* Create new section with provided params
*/
addSettingsSection(...args) {
Array.from(args).forEach(({ route, accessCheck, scope, order, component }) => {
const { path, name, meta, children } = route;
const section = new SettingsSection(path, name, meta, accessCheck, scope, order, component, children);
this.settingsSections.push(section);
});
}
/**
* Create new section with provided params
*/
addCompanySection(...args) {
Array.from(args).forEach(({ route, accessCheck, scope, order, component }) => {
const { path, name, meta, children } = route;
const section = new SettingsSection(path, name, meta, accessCheck, scope, order, component, children);
this.companySections.push(section);
});
}
addField(scope, path, field) {
this.additionalFields.push({ scope, path, field });
}
/**
* Add locales
*/
addLocalizationData(locales) {
this.locales = locales;
}
/**
* Add pluralization rules
*/
addPluralizationRules(rules) {
this.pluralizationRules = rules;
}
/**
* Init all available sections
* @returns {Promise<void>[]}
*/
initSettingsSections() {
this.additionalFields
.filter(({ scope }) => scope === 'settings')
.forEach(({ scope, path, field }) => {
store.dispatch('settings/addField', { scope, path, field });
});
return this.settingsSections.map(s => s.initSection());
}
/**
* Init all available sections
* @returns {Promise<void>[]}
*/
initCompanySections() {
this.additionalFields
.filter(({ scope }) => scope === 'company')
.forEach(({ scope, path, field }) => {
store.dispatch('settings/addField', { scope, path, field });
});
return this.companySections.map(s => s.initSection());
}
/**
* Init all available sections
*/
reinitAllSections() {
this.initSettingsSections();
this.initCompanySections();
}
/**
* Get all available to fill /settings route children
* @returns {{path: string, component: null, meta, name: string}[]}
*/
getSettingSectionsRoutes() {
return this.settingsSections.map(s => s.getRoute());
}
/**
* Get all available to fill /settings route children
* @returns {{path: string, component: null, meta, name: string}[]}
*/
getCompanySectionsRoutes() {
return this.companySections.map(s => s.getRoute());
}
/**
* Get Navigation bar entries array
*
* @returns {Array<Object>}
*/
getNavbarEntries() {
return this.navEntries;
}
/**
* Get Navigation Dropdown bar entries array
*
* @returns {Array<Object>}
*/
getNavbarEntriesDropdown() {
return this.navEntriesDropdown;
}
/**
* Get Navigation Menu Dropdown entries array
*
* @returns {Array<Object>}
*/
getNavbarMenuEntriesDropDown() {
return this.navEntriesMenuDropdown;
}
/**
* Get module-scoped routes for Vue Router
*
* @returns {Array<Object>}
*/
getRoutes() {
return this.routes;
}
/**
* Get locales
*
* @returns {Array}
*/
getLocalizationData() {
return this.locales;
}
/**
* Get pluralization rules
*
* @returns {Object}
*/
getPluralizationRules() {
return this.pluralizationRules;
}
/**
* Get module name
*
* @returns {string}
*/
getModuleName() {
return this.moduleName;
}
/**
* Get module route name
*
* @returns {string}
*/
getModuleRouteName() {
return this.moduleName;
}
/**
* Get router prefix for module-scoped routes
*
* @returns {string}
*/
getRouterPrefix() {
return this.routerPrefix;
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

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;

View File

@@ -0,0 +1,11 @@
import axios from 'axios';
import httpInterceptor from '@/helpers/httpInterceptor';
axios.defaults.baseURL = `${window.location.origin}/api/`;
axios.defaults.headers.common['X-REQUESTED-WITH'] = 'XMLHttpRequest';
axios.defaults.headers.common['X-CATTR-CLIENT'] = window.location.host;
axios.defaults.headers.common['X-CATTR-VERSION'] = process.env.MIX_APP_VERSION;
httpInterceptor.setup();
export default axios;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,44 @@
import DefaultLayout from '@/layouts/DefaultLayout';
import AuthLayout from '@/layouts/AuthLayout';
import CustomAtModal from '@/components/global/CustomModal/dialog';
import axios from 'axios';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
const components = {
DefaultLayout,
AuthLayout,
CustomAtModal,
};
function installGlobalComponents(Vue) {
for (const component in components) {
if (components[component].name) {
Vue.component(components[component].name, components[component]);
}
}
Vue.prototype.$CustomModal = CustomAtModal;
Vue.prototype.$http = axios;
Vue.prototype.$echo = new Echo({
broadcaster: 'pusher',
key: process.env.MIX_REVERB_APP_KEY,
wsHost: process.env.MIX_REVERB_HOST ?? window.location.hostname,
wsPath: process.env.MIX_REVERB_PATH ?? '',
wsPort: process.env.MIX_REVERB_FRONTEND_PORT ?? 80,
wssPort: process.env.MIX_REVERB_FRONTEND_PORT ?? 443,
forceTLS: (process.env.MIX_REVERB_SCHEME ?? 'https') === 'https',
disableStats: true,
enabledTransports: ['ws', 'wss'],
Pusher,
cluster: 'eu',
auth: {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
},
},
});
}
export default installGlobalComponents;

View File

@@ -0,0 +1,28 @@
export function getParentElement(child, search) {
if (child.parentElement.classList.contains(search)) {
return child.parentElement;
}
return getParentElement(child.parentElement, search);
}
export function loadSections(context, router, requireSection) {
const sections = requireSection
.keys()
.map(fn => requireSection(fn).default)
.map(section => {
if (typeof section === 'function') {
return section(context, router);
}
return section;
});
sections.forEach(section => {
if (Object.prototype.hasOwnProperty.call(section, 'scope') && section.scope === 'company') {
context.addCompanySection(section);
} else {
context.addSettingsSection(section);
}
});
}

View File

@@ -0,0 +1,117 @@
import Vue from 'vue';
import axios from 'axios';
import { store } from '@/store';
import has from 'lodash/has';
import isObject from 'lodash/isObject';
// Queue for pending queries
let pendingRequests = 0;
// Changes loader bar state
const setLoading = value => {
if (value) {
if (pendingRequests === 0) Vue.prototype.$Loading.start();
pendingRequests++;
} else {
setTimeout(() => {
if (pendingRequests > 0) pendingRequests--;
if (pendingRequests < 1) Vue.prototype.$Loading.finish();
}, 300);
}
};
// Returns access token
const getAuthToken = () => localStorage.getItem('access_token');
// Adds response interceptor
const responseInterceptor = response => {
setLoading(false);
return response;
};
// Adds error response interceptor
const responseErrorInterceptor = async error => {
setLoading(false);
if (!has(error, 'response.status') || (error.request.responseType === 'blob' && error.request.status === 404)) {
return Promise.reject(error);
}
switch (error.response.status) {
case 401:
store.getters['user/loggedIn'] && store.dispatch('user/forceUserExit', error.response.data.message);
break;
case 503:
store.getters['user/loggedIn'] && store.dispatch('user/forceUserExit', 'Data reset');
break;
default:
Vue.prototype.$Notify.error({
title: 'Error',
message: has(error, 'response.data.error.message')
? error.response.data.error.message
: 'Internal server error',
duration: 5000,
});
if (isObject(error.response.data.error.fields)) {
for (const [fieldName, msgArr] of Object.entries(error.response.data.error.fields)) {
for (const msg of msgArr) {
await Vue.prototype.$nextTick();
Vue.prototype.$Notify.warning({
title: 'Warning',
message: msg,
duration: 5000,
});
}
}
}
}
return Promise.reject(error);
};
// Adds request interceptor
const requestInterceptor = config => {
setLoading(true);
return config;
};
// Adds request error interceptor
const requestErrorInterceptor = error => {
axios.isCancel(error) ? setLoading(false) : Vue.prototype.$Loading.error();
return Promise.reject(error);
};
// Sets the access token on the request
const authInterceptor = config => {
config.headers['Authorization'] = `Bearer ${getAuthToken()}`;
return config;
};
// Save pending request cancel tokens to the store
const pendingRequestsInterceptor = config => {
if (config.ignoreCancel) {
return config;
}
// Generate cancel token source
let source = axios.CancelToken.source();
// Set cancel token on this request
config.cancelToken = source.token;
store.commit('httpRequest/addCancelToken', source);
return config;
};
export default {
setup() {
axios.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
axios.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
axios.interceptors.request.use(pendingRequestsInterceptor);
axios.interceptors.request.use(authInterceptor);
},
};

View File

@@ -0,0 +1,9 @@
const slogans = ['Cattr - a free open source time tracker', 'Manage your time with ease'];
const getRandomInt = max => {
return Math.floor(Math.random() * Math.floor(max));
};
export default () => {
return slogans[getRandomInt(slogans.length)];
};

View File

@@ -0,0 +1,74 @@
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import { getModuleList, ModuleLoaderInterceptor } from '@/moduleLoader';
import merge from 'lodash/merge';
import veeValidateEn from 'vee-validate/dist/locale/en.json';
import veeValidateRu from 'vee-validate/dist/locale/ru.json';
import moment from 'moment';
import * as Sentry from '@sentry/vue';
export function getLangCookie() {
const v = document.cookie.match('(^|;) ?lang=([^;]*)(;|$)');
return v ? v[2] : null;
}
// Set root domain cookie, ex: *.cattr.app
export function setLangCookie(lang) {
const rootDomain = location.hostname.split('.').reverse().splice(0, 2).reverse().join('.');
document.cookie = 'lang=' + lang + '; domain=' + rootDomain;
}
// Get the browser language
export function getUserLang() {
return typeof navigator.language !== 'undefined' && navigator.language.length
? navigator.language.substring(0, 2).toLowerCase()
: 'en';
}
Vue.use(VueI18n);
let messages = {
en: require('./locales/en'),
ru: require('./locales/ru'),
};
let pluralizationRules = {};
ModuleLoaderInterceptor.on('loaded', () => {
const modules = Object.values(getModuleList()).map(i => {
return i.moduleInstance;
});
modules.forEach(m => {
const moduleMessages = m.getLocalizationData();
merge(messages, moduleMessages);
merge(pluralizationRules, m.getPluralizationRules());
});
});
merge(messages, {
en: {
validation: veeValidateEn.messages,
},
ru: {
validation: veeValidateRu.messages,
},
});
merge(pluralizationRules, require('./pluralizationRules'));
const locale = getLangCookie() || getUserLang();
Sentry.setTag('locale', locale);
moment.locale(locale);
const i18n = new VueI18n({
locale: locale,
fallbackLocale: 'en',
silentFallbackWarn: true,
messages,
pluralizationRules,
});
export default i18n;

View File

@@ -0,0 +1,462 @@
{
"navigation": {
"about": "About",
"settings": "Settings",
"client-login": "Desktop app login",
"company_settings": "Company Settings",
"logout": "Logout",
"dropdown": {
"reports": "Reports",
"projects": "Projects"
}
},
"setup": {
"process": {
"info_without_docker": "Please, change your server's settings according to examples below. Note, that you should replace paths with the path to your Cattr installed.",
"important_information": "Important information",
"title_supervisor": "Supervisor configuration",
"title_cron": "Cron configuration",
"end_install": "Start use Cattr",
"button_process": "Installing",
"config_confirmation": "The required configuration files are changed accordingly",
"title": "Cattr install",
"subtitle": "Please, do not close or reload this tab until the installation is completed"
},
"step_description": {
"welcome": "Welcome",
"database_settings": "Database configuration",
"mail_settings": "Mail configuration",
"company_settings": "Company configuration",
"account": "Create account",
"permission": "Permissions",
"backend_ping": "Connection to backend server",
"recaptcha": "reCAPTCHA configuration"
},
"buttons": {
"next": "Next",
"back": "Back",
"complete": "Complete",
"update": "Update",
"connect": "Connect to database",
"checked": "Enabled",
"unchecked": "Disabled"
},
"header": {
"welcome": {
"title": "Welcome to Cattr",
"subtitle": "This is a quick setup",
"language": "Please, select your company language"
},
"backend_ping": {
"title": "Connection to backend server",
"subtitle": "Checking the connection with your Cattr backend server",
"success": "Success",
"error": "Connection refused",
"process": "Processing",
"status": "Backend status",
"server_url": "Trying to find the server at: {serverUrl}",
"wrong_url": "Wrong url?",
"building_when": "URL should be set up on frontend building",
"read_more": "You can read more in {0}",
"documentation": "documentation"
},
"database_settings": {
"title": "Database configuration",
"subtitle": "Set MySQL configuration",
"success": "Success",
"error": "Wrong params",
"process": "Wait process",
"host": "Host",
"database": "Database name",
"username":"User name",
"password":"Password",
"status": "Database status",
"docker_title": "Cattr is running inside the official Docker container",
"docker_subtitle": "You do not need to adjust database connection settings"
},
"mail_settings": {
"title": "Mail configuration",
"subtitle": "Set mail settings",
"email": "E-mail",
"password": "Password",
"host": "Server address",
"port": "Port",
"encryption": "Encryption"
},
"company_settings": {
"title": "Company settings",
"subtitle": "Set company settings",
"language": "Language",
"timezone": "Timezone"
},
"account": {
"title": "Admin account settings",
"subtitle": "Create admin account",
"email": "E-mail",
"password": "Password"
},
"permission": {
"title": "Permission",
"subtitle": "Give permission",
"registration_process": "Wait until the registration process is completed",
"registration": "Registration"
},
"recaptcha": {
"title": "reCAPTCHA settings",
"subtitle": "Connect reCAPTCHA to protect user accounts from password attacks",
"get_recaptcha": "Get reCAPTCHA keys"
}
}
},
"auth": {
"submit": "Login",
"forgot_password": "Forgot password?",
"message": {
"user_not_found": "We can't find the user with provided credentials",
"solve_captcha": "You must resolve a CAPTCHA",
"auth_error": "Authorization error",
"data_reset": "Data is flushing right now, please wait a minute and try again"
},
"desktop": {
"header": "Desktop application login",
"step": "Step {n}",
"step1": "Transferring data",
"step2": "Opening application",
"retry": "Retry",
"cancel": "Cancel",
"open": "Open application",
"finish": "Finish",
"error": "The desktop application is not installed on your PC",
"download": "You can download it {0}",
"download_button": "here"
},
"switch_to_common": "Log in with password",
"close": "Close page",
"desktop_error": "Error happened during authorization",
"desktop_working": "Authorizing"
},
"reset": {
"forgot_password": "Forgot password?",
"reset_password": "Reset password",
"confirm_password": "Confirm password",
"step": "Step {n}",
"step_description": {
"step_1": "Enter email",
"step_2": "Check your email",
"step_3": "Enter new password",
"step_4": "Enjoy"
},
"tabs": {
"enter_email": {
"title": "Forgot password?",
"subtitle": "Enter your email and we will send a link to reset your password"
},
"check_email": {
"title": "Check your email",
"subtitle": "Check your email, and follow the link in the password reset email to complete the process"
},
"new_password": {
"title": "Enter new password",
"subtitle": "Password must be at least 6 characters"
},
"success": {
"title": "Success",
"subtitle": "Your password has been successfully changed"
}
},
"page_is_not_available": "This page is no longer available",
"go_away": "Go to login page"
},
"register": {
"title": "Register",
"subtitle": "Please fill out the fields to confirm your registration",
"register_btn": "Create account",
"success_title": "Account was created successfully",
"success_subtitle": "Go to the login page to log in to your account"
},
"settings": {
"account": "Account",
"general": "General",
"language": "Language",
"company": "Company",
"company_timezone": "Company timezone"
},
"control": {
"save": "Save",
"create": "Create",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"add": "Add",
"yes": "Yes",
"no": "No",
"back": "Back",
"submit": "Submit",
"deselect_all": "Deselect All",
"select_all": "Select All",
"clear_all": "Clear All",
"select_all_open": "Select All Open",
"select_all_closed": "Select All Closed",
"user_selected": "No users selected | 1 user selected | {count} users selected",
"element_selected": "No elements selected | 1 element selected | {count} elements selected",
"project_selected": "No projects selected | 1 project selected | {count} projects selected",
"project_selected_all": "All projects selected",
"status_selected": "No statuses selected | 1 status selected | {count} statuses selected",
"status_selected_all": "All statuses selected",
"screenshot_state_options": {
"required": "Required",
"optional": "Optional",
"forbidden": "Forbidden"
},
"day": "Day",
"week": "Week",
"month": "Month",
"range": "Range",
"today": "Today",
"ok": "Ok",
"active": "Active",
"inactive": "Inactive",
"search": "Search",
"add_time": "Add time",
"enable": "Enable",
"disable": "Disable",
"select": "Select",
"cancel": "Cancel",
"reset": "Reset",
"show_active": "Show active tasks"
},
"field": {
"password": "Password",
"name": "Name",
"full_name": "Name",
"status": "Status",
"email": "E-mail",
"user": "User",
"users": "Users",
"active": "Active",
"change_password": "Change password",
"screenshot": "Screenshot",
"screenshots": "Screenshots",
"manual_time": "Manual Time",
"screenshots_interval": "Screenshots Interval",
"computer_time_popup": "Inactivity Time Popup",
"timezone": "Timezone",
"default_role": "Default Role",
"project_roles": "Project Roles",
"task": "Task",
"tasks": "Tasks",
"project": "Project",
"projects": "Projects",
"priority": "Priority",
"created_at": "Created",
"updated_at": "Last edit",
"team": "Team",
"amount_of_tasks": "Amount of tasks",
"description": "Description",
"important": "Important",
"actions": "Actions",
"start_at": "Start at",
"end_at": "End at",
"expires_at": "Expires at",
"role": "Role",
"user_language": "User Language",
"send_invite": "Send invite",
"selected": "Selected",
"total": "Total",
"total_time": "Total time",
"total_spent": "Total Spent Time",
"time": "Time",
"auto_thin": "Automatic storage cleanup",
"screenshots_state": "Enable screenshots",
"members": "Members",
"url": "URL",
"efficiency": "Employee efficiency ratio",
"statuses": {
"any": "Any",
"active": "Active",
"disabled": "Disabled"
},
"roles": {
"admin": {
"name": "Admin",
"description": "Full access to everything"
},
"manager": {
"name": "Manager",
"description": "Full access to tasks, projects, screenshot and all users' activity"
},
"auditor": {
"name": "Auditor",
"description": "Read access to projects, create and read access to all the tasks, access to all users activity"
},
"user": {
"name": "User",
"description": "Read access to assigned projects, create and view tasks, view personal activity"
},
"any": "Any"
},
"type": "Type",
"types": {
"all": "All",
"any": "Any",
"employee": "Employee",
"client": "Client"
},
"work_time": "Working Hours",
"notice": "Notice",
"report": "Report",
"minutes": "{value} minutes",
"duration": "Duration",
"duration_value": "{0} to {1}",
"web_and_app_monitoring": "Enable app monitoring",
"parent_group": "Parent group"
},
"filter": {
"enter-single": "Enter {0}",
"enter-multiple": "Enter {0} or {1}",
"fields": {
"task_name": "task name",
"project_name": "project name",
"full_name": "full name",
"name": "name",
"email": "e-mail",
"url": "URL"
},
"full_name": "Filter by full name",
"email": "Filter by e-mail",
"task": "Filter by task name",
"project": "Filter by project name"
},
"message": {
"no_data": "No data to display",
"page_not_found": "The page you're looking for doesn't exist",
"api_error": "Looks like we have temporal problems, contact your administrator about that :(",
"page_forbidden": "Access denied",
"field_is_required": "This field is required",
"something_went_wrong": "Something went wrong",
"success": "Success",
"error": "Error",
"vulnerable_version": "You are using an outdated app version, please update to the latest one",
"update_version": "New version is available",
"report_has_been_queued": "Report has been queued and will be sent on your email"
},
"time": {
"d": "d",
"h": "h",
"m": "m",
"s": "s"
},
"tooltip": {
"task_important": "Related screenshots should not be deleted automatically",
"user_change_password": "Makes the password change on the first login mandatory",
"user_manual_time": "Allow users to manually add time",
"user_send_invite": "Email user the account's credentials. If \"Password\" field is not filled in, an email with an auto-generated password will be sent in any case ",
"user_computer_time_popup": "User's inactivity time in minutes before 'inactivity detected' modal pops up",
"user_interval_screenshot": "Screenshot creation time interval in minutes",
"color_intervals": "Lets you select colors for clocked-in time intervals from 0 to 100%",
"work_time": "Minimum amount of working hours/day",
"auto_thin": "Old screenshots will be deleted automatically",
"activity_progress": {
"not_tracked": "No activity tracked",
"overall": "Overall activity: | Overall activity: 1% | Overall activity: {percent}%",
"mouse": "Mouse: 0% | Mouse: 1% | Mouse: {percent}%",
"keyboard": "Keyboard: 0% | Keyboard: 1% | Keyboard: {percent}%",
"just_mouse": "Mouse:",
"just_keyboard": "Keyboard:"
}
},
"invite": {
"resend": "Resend invite"
},
"about": {
"modules": {
"name": "Module",
"version": "Version",
"status": "Status",
"vulnerable": "Vulnerable",
"ok": "Up to date",
"outdated": "Out of date"
},
"module_versions": "Module versions",
"module_storage": "Storage",
"no_modules": "No modules installed",
"no_storage": "No info about storage",
"storage": {
"last_thinning": "Last cleanup was",
"space": {
"left": "Free space",
"total": "Total space",
"used": "Space used"
},
"screenshots_available": "Can be removed",
"screenshots": "0 screenshots | {n} screenshot | {n} screenshots",
"thin_unavailable": "Cleanup is unavailable at this moment",
"thin_available": "Cleanup is available",
"thin": "Cleanup space"
}
},
"notification": {
"save": {
"success": {
"title": "Success",
"message": "Saved successfully"
},
"error": {
"title": "Error",
"message": "Failed to save"
}
},
"record": {
"save": {
"success": {
"title": "Success",
"message": "Record was saved successfully"
}
},
"delete": {
"confirmation": {
"title": "Delete record?",
"message": "Are you sure you want delete this record?"
},
"success": {
"title": "Success",
"message": "Record was successfully deleted"
},
"error": {
"title": "Error",
"message": "Failed to destroy the report"
}
}
},
"settings": {
"save": {
"success": {
"title": "Success",
"message": "Settings were saved successfully"
}
}
},
"screenshot": {
"save": {
"success": {
"title": "Success",
"message": "Screenshots were saved successfully"
},
"error": {
"title": "Error",
"message": "Failed to save the screenshots"
}
},
"delete": {
"success": {
"title": "Success",
"message": "Screenshots were successfully deleted"
},
"error": {
"title": "Error",
"message": "Failed to remove the screenshots"
}
}
}
}
}

View File

@@ -0,0 +1,461 @@
{
"navigation": {
"about": "О приложении",
"client-login": "Войти в приложение",
"settings": "Настройки",
"company_settings": "Настройки компании",
"logout": "Выход",
"dropdown": {
"reports": "Отчеты",
"projects": "Проекты"
}
},
"setup": {
"process": {
"info_without_docker": "Внесите изменения в файлы конфигурации для корректной работы сервиса. Обратите внимание, что пути нужно заменить на актуальные для вашего оборудования.",
"important_information": "Важная информация",
"title_supervisor": "Настройка аккаунта Supervisor",
"title_cron": "Конфигурация планировщика задач",
"end_install": "Начать пользоваться Cattr",
"button_process": "Установка...",
"config_confirmation": "Изменения в конфигурацию внесены",
"title": "Установка началась",
"subtitle": "Не закрывайте и не перезагружайте эту вкладку до окончания установки"
},
"step_description": {
"welcome": "Приветствие",
"database_settings": "Настройка базы данных",
"mail_settings": "Настройка почты",
"company_settings": "Настройка параметров компании",
"account": "Настройки аккаунта",
"permission": "Доступы",
"backend_ping": "Подключение к серверу",
"recaptcha": "Настройка reCAPTCHA"
},
"buttons": {
"next": "Дальше",
"back": "Назад",
"complete": "Завершить",
"update": "Обновить",
"connect": "Проверить соединение с базой данных",
"checked": "Включено",
"unchecked": "Выключено"
},
"header": {
"welcome": {
"title": "Добро пожаловать в Cattr",
"subtitle": "Вас приветствует мастер первоначальной установки",
"language": "Выберите язык вашей компании"
},
"backend_ping": {
"title": "Подключение к серверу",
"subtitle": "Проверка подключения к серверу",
"success": "Сервер найден",
"error": "Сервер не найден",
"process": "Проверяю...",
"status": "Статус сервера",
"server_url": "Подключение к серверу будет осуществляться по адресу: {serverUrl}",
"wrong_url": "Неправильный URL-адрес?",
"building_when": "Адрес задается при сборке",
"read_more": "Подробнее можно прочитать в {0}",
"documentation": "документации"
},
"database_settings": {
"title": "Настройка базы данных",
"subtitle": "Задайте настройки базы данных MySQL",
"success": "Подключено",
"error": "Некорректные данные",
"process": "Проверяю...",
"host": "Хост",
"database": "Название базы данных",
"username":"Имя пользователя",
"password":"Пароль",
"status": "Статус базы данных",
"docker_title": "Cattr будет запущен в официальном docker-контейнере",
"docker_subtitle": "Вам не требуется задавать параметры подключения к базе данных"
},
"mail_settings": {
"title": "Настройка почты",
"subtitle": "Задайте настройки почты",
"email": "Почта",
"password": "Пароль",
"host": "Адрес сервера",
"port": "Порт",
"encryption": "Шифрование"
},
"company_settings": {
"title": "Настройка компании",
"subtitle": "Задайте настройки компании",
"timezone": "Часовой пояс"
},
"account": {
"title": "Настройки аккаунта",
"subtitle": "Создайте аккаунта админа",
"email": "Почта",
"password": "Пароль"
},
"permission": {
"title": "Доступы",
"subtitle": "Дать доступ",
"registration_process": "Ожидайте процесса регистрации",
"registration": "Регистрация"
},
"recaptcha": {
"title": "Настройка reCAPTCHA",
"subtitle": "Подключите reCAPTCHA для защиты пользовательских аккаунтов от попыток взлома",
"get_recaptcha": "Получить ключи для reCAPTCHA"
}
}
},
"auth": {
"submit": "Войти",
"forgot_password": "Забыли пароль?",
"message": {
"user_not_found": "Пользователь с таким паролем не найден",
"solve_captcha": "Вы должны решить CAPTCHA",
"auth_error": "Ошибка авторизации",
"data_reset": "Данные сбрасываются, пожалуйста, подождите немного и попробуйте снова"
},
"desktop": {
"header": "Вход в клиентское приложение Cattr",
"step": "{n} шаг",
"step1": "Передаю данные",
"step2": "Открываю приложение",
"retry": "Попробовать еще раз",
"cancel": "Отменить",
"open": "Открыть приложение",
"finish": "Готово",
"error": "Похоже, Вы не установили клиентское приложение",
"download": "Вы можете скачать его {0}",
"download_button": "здесь"
},
"switch_to_common": "Войти с паролем",
"close": "Закрыть страницу",
"desktop_error": "В процессе авторизации произошла ошибка",
"desktop_working": "Авторизация"
},
"reset": {
"forgot_password": "Забыли пароль?",
"reset_password": "Восстановить пароль",
"confirm_password": "Подтверждение пароля",
"step": "Шаг {n}",
"step_description": {
"step_1": "Введите email",
"step_2": "Проверьте правильность email",
"step_3": "Введите новый пароль",
"step_4": "Успешно"
},
"tabs": {
"enter_email": {
"title": "Забыли пароль?",
"subtitle": "Введите адрес электронной почты, и мы вышлем на него ссылку для восстановления пароля"
},
"check_email": {
"title": "Проверьте почту",
"subtitle": "Проверьте правильность своей электронной почты и перейдите по ссылке в письме, чтобы завершить процесс"
},
"new_password": {
"title": "Введите новый пароль",
"subtitle": "Пароль должен содержать не менее 6 символов"
},
"success": {
"title": "Успешно",
"subtitle": "Ваш пароль был успешно изменен"
}
},
"page_is_not_available": "Страница недоступна",
"go_away": "Перейти на страницу авторизации"
},
"register": {
"title": "Регистрация",
"subtitle": "Заполните поля для подтверждения регистрации",
"register_btn": "Создать аккаунт",
"success_title": "Аккаунт был успешно создан",
"success_subtitle": "Перейдите на страницу авторизации, чтобы войти в аккаунт"
},
"settings": {
"account": "Аккаунт",
"general": "Общее",
"language": "Язык",
"company": "Компания",
"company_timezone": "Часовой пояс компании"
},
"control": {
"save": "Сохранить",
"create": "Создать",
"view": "Просмотр",
"edit": "Редактировать",
"delete": "Удалить",
"add": "Добавить",
"yes": "Да",
"no": "Нет",
"back": "Назад",
"submit": "Отправить",
"deselect_all": "Отменить выбор всех",
"select_all": "Выбрать все",
"clear_all": "Очистить все",
"select_all_open": "Выбрать все открытые",
"select_all_closed": "Выбрать все закрытые",
"user_selected": "Нет выбранных пользователей | Выбран 1 пользователь | Выбрано {count} пользователя(ей)",
"element_selected": "Нет выбранных элементов | Выбран 1 элемент | Выбрано {count} элемента(ов)",
"project_selected": "Нет выбранных проектов| Выбран 1 проект | Выбрано {count} проекта(ов)",
"project_selected_all": "Выбраны все проекты",
"status_selected": "Нет выбранных статусов | Выбран 1 статус | Выбрано {count} статуса(ов)",
"status_selected_all": "Выбраны все статусы",
"screenshot_state_options": {
"required": "Обязательны",
"optional": "Необязательны",
"forbidden": "Запрещены"
},
"day": "День",
"week": "Неделя",
"month": "Месяц",
"range": "Диапазон",
"today": "Сегодня",
"ok": "Ok",
"active": "Активные",
"inactive": "Неактивные",
"search": "Поиск",
"add_time": "Добавить время",
"enable": "Включить",
"disable": "Выключить",
"select": "Выбрать",
"cancel": "Закрыть",
"reset": "Сбросить",
"show_active": "Показать только активные задачи"
},
"field": {
"password": "Пароль",
"name": "Название",
"full_name": "Имя",
"status": "Статус",
"email": "E-mail",
"user": "Пользователь",
"users": "Пользователи",
"active": "Активен",
"change_password": "Сменить пароль",
"screenshot": "Скриншот",
"screenshots": "Скриншоты",
"manual_time": "Установка интервалов",
"screenshots_interval": "Интервал скриншотов",
"computer_time_popup": "Время неактивности",
"timezone": "Часовой пояс",
"default_role": "Роль по умолчанию",
"project_roles": "Роли для проектов",
"task": "Задача",
"tasks": "Задачи",
"project": "Проект",
"projects": "Проекты",
"priority": "Приоритет",
"created_at": "Создано",
"updated_at": "Изменено",
"team": "Команда",
"amount_of_tasks": "Количество задач",
"description": "Описание",
"important": "Важно",
"actions": "Действия",
"start_at": "Начало в",
"end_at": "Конец в",
"expires_at": "Истекает",
"role": "Роль",
"user_language": "Язык пользователя",
"send_invite": "Отправить приглашение",
"selected": "Выбрано",
"total": "Всего",
"total_time": "Общее время",
"total_spent": "Потраченное время",
"time": "Время",
"auto_thin": "Автоматическая очистка хранилища",
"screenshots_state": "Включить скриншоты",
"members": "Участники",
"url": "URL",
"efficiency": "Коэффициент эффективности сотрудника",
"statuses": {
"any": "Все",
"active": "Активен",
"disabled": "Отключен"
},
"roles": {
"admin": {
"name": "Администратор",
"description": "Имеет полный доступ ко всему"
},
"manager": {
"name": "Менеджер",
"description": "Имеет полный доступ к задачам, проектам, скриншотам и к просмотру активности всех пользователей"
},
"user": {
"name" : "Пользователь",
"description" : "Имеет доступ на чтение к проектам, над которыми работает, создание и просмотр своих задач, просмотр личной активности"
},
"auditor": {
"name": "Проверяющий",
"description": "Имеет доступ на чтение ко всем проектам, создание и чтение всех задач, просмотр активности всех пользователей"
},
"any": "Все"
},
"type": "Тип",
"types": {
"all": "Все",
"any": "Любой",
"employee": "Сотрудник",
"client": "Клиент"
},
"work_time": "Продолжительность рабочего дня",
"notice": "Предупреждение",
"report": "Отчет",
"minutes": "{value} минут(-ы)",
"duration": "Продолжительность",
"duration_value": "{0} по {1}",
"web_and_app_monitoring": "Мониторинг приложений",
"parent_group": "Родительская группа"
},
"filter": {
"enter-single": "Введите {0}",
"enter-multiple": "Введите {0} или {1}",
"fields": {
"task_name": "название задачи",
"project_name": "название проекта",
"full_name": "полное имя",
"name": "имя",
"email": "e-mail",
"url": "URL"
},
"full_name": "Фильтр по имени",
"email": "Фильтр по e-mail",
"task": "Фильтр по задачам",
"project": "Фильтр по проектам"
},
"message": {
"no_data": "Нет данных для отображения",
"page_not_found": "Похоже, что страница, которую вы ищете, не существует :(",
"api_error": "Похоже, что мы испытываем временные технические трудности, сообщите об этом вашему администратору :(",
"page_forbidden": "Страница не доступна",
"field_is_required": "Это поле обязательно для заполнения",
"something_went_wrong": "Что то пошло не так",
"success": "Успешно",
"error": "Ошибка",
"vulnerable_version": "Ваша текущая версия приложения устарела. Установите обновления, которые устранят известные уязвимости",
"update_version": "Доступна новая версия",
"report_has_been_queued": "Отчет заказан и будет отправлен на почту"
},
"time": {
"d": "д",
"h": "ч",
"m": "м",
"s": "с"
},
"tooltip": {
"task_important": "Соответствующие скриншоты не будут удаляться автоматически",
"user_change_password": "Обязать пользователя сменить пароль при входе",
"user_manual_time": "Дать возможность пользователю добавлять время вручную",
"user_send_invite": "Отправить данные учетной записи на почту пользователю. Если поле \"Пароль\" не заполнено, он будет сгенерирован автоматически",
"user_computer_time_popup": "Время неактивности в минутах до появления оповещения в приложении-клиенте",
"user_interval_screenshot": "Интервал создания скриншотов в минутах",
"color_intervals": "Позволяет задать цвета для интервалов рабочего дня с 0 до 100 процентов",
"work_time": "Минимальная продолжительность рабочего дня",
"auto_thin": "Старые скриншоты будут удаляться автоматически",
"activity_progress": {
"not_tracked": "Активность не отслеживалась",
"overall": "Общая активность: | Общая активность: 1% | Общая активность: {percent}%",
"mouse": "Мышь: 0% | Мышь: 1% | Мышь: {percent}%",
"keyboard": "Клавиатура: 0% | Клавиатура: 1% | Клавиатура: {percent}%",
"just_mouse": "Мышь:",
"just_keyboard": "Клавиатура:"
}
},
"invite": {
"resend": "Переотправить приглашение"
},
"about": {
"modules": {
"name": "Модуль",
"version": "Версия",
"status": "Статус",
"vulnerable": "Есть известные уязвимости",
"ok": "Обновление не требуется",
"outdated": "Требуется обновление"
},
"module_versions": "Версия модулей",
"module_storage": "Хранилище",
"no_modules": "Нет установленных модулей",
"no_storage": "Нет информации про хранилище",
"storage": {
"last_thinning": "Последняя очистка",
"space": {
"left": "Осталось",
"total": "Всего",
"used": "Использовано"
},
"screenshots_available": "Можно удалить",
"screenshots": "0 скриншотов | {n} скриншот | {n} скриншота | {n} скриншотов",
"thin_unavailable": "Очистка сейчас недоступна",
"thin_available": "Доступна очистка",
"thin": "Очистить место"
}
},
"notification": {
"save": {
"success": {
"title": "Успешно",
"message": "Сохранено успешно"
},
"error": {
"title": "Ошибка",
"message": "Не удалось сохранить"
}
},
"record": {
"save": {
"success": {
"title": "Успешно",
"message": "Запись была успешно сохранена"
}
},
"delete": {
"confirmation": {
"title": "Удалить запись?",
"message": "Вы уверены, что хотите удалить запись?"
},
"success": {
"title": "Успешно",
"message": "Запись была успешно удалена"
},
"error": {
"title": "Ошибка",
"message": "Не удалось удалить отчёт"
}
}
},
"settings": {
"save": {
"success": {
"title": "Успешно",
"message": "Настройки успешно сохранены"
}
}
},
"screenshot": {
"save": {
"success": {
"title": "Успешно",
"message": "Скриншот(-ы) успешно сохранен(-ы)"
},
"error": {
"title": "Ошибка",
"message": "Не удалось сохранить скриншот(-ы)"
}
},
"delete": {
"success": {
"title": "Успешно",
"message": "Скриншот(-ы) успешно удален(-ы)"
},
"error": {
"title": "Ошибка",
"message": "Не удалось удалить скриншот(-ы)"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
module.exports = {
/**
* @param choice {number} a choice index given by the input to $tc: `$tc('path.to.rule', choiceIndex)`
* @param choicesLength {number} an overall amount of available choices
* @returns a final choice index to select plural word by
*/
ru: function (choice, choicesLength) {
// this === VueI18n instance, so the locale property also exists here
if (choice === 0) {
return 0;
}
const teen = choice > 10 && choice < 20;
const endsWithOne = choice % 10 === 1;
if (choicesLength < 4) {
return !teen && endsWithOne ? 1 : 2;
}
if (!teen && endsWithOne) {
return 1;
}
if (!teen && choice % 10 >= 2 && choice % 10 <= 4) {
return 2;
}
return choicesLength < 4 ? 2 : 3;
},
};

View File

@@ -0,0 +1,83 @@
import '@/config/app';
import { localModuleLoader } from '@/moduleLoader';
import '@/settings';
import Vue from 'vue';
import App from '@/App.vue';
import router from '@/router';
import { store, init as routerInit } from '@/store';
import AtComponents from '@cattr/ui-kit';
import DatePicker from 'vue2-datepicker';
import moment from 'vue-moment';
import i18n from '@/i18n';
import VueLazyload from 'vue-lazyload';
import '@/plugins/vee-validate';
import '@/policies';
import Gate from '@/plugins/gate';
import vueKanban from 'vue-kanban';
import * as Sentry from '@sentry/vue';
import { BrowserTracing } from '@sentry/tracing';
import { Workbox } from 'workbox-window';
//Global components
import installGlobalComponents from './global-extension';
Vue.config.productionTip = false;
Vue.use(AtComponents);
Vue.use(moment);
Vue.use(DatePicker);
Vue.use(VueLazyload, {
lazyComponent: true,
});
Vue.use(Gate);
Vue.use(vueKanban);
installGlobalComponents(Vue);
if (process.env.NODE_ENV === 'development') {
window.system = {};
}
localModuleLoader(router);
if ('serviceWorker' in navigator) {
const wb = new Workbox('/service-worker.js');
wb.register();
}
if (
process.env.NODE_ENV !== 'development' &&
'MIX_SENTRY_DSN' in process.env &&
process.env.MIX_SENTRY_DSN !== 'undefined'
) {
Sentry.init({
Vue,
release: process.env.MIX_APP_VERSION,
environment: process.env.NODE_ENV,
dsn: process.env.MIX_SENTRY_DSN,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracePropagationTargets: [window.location.origin],
}),
],
tracesSampleRate: 0.2,
});
if ('MIX_DOCKER_VERSION' in process.env && process.env.MIX_DOCKER_VERSION !== 'undefined')
Sentry.setTag('docker', process.env.MIX_DOCKER_VERSION);
}
routerInit();
const app = new Vue({
router,
store,
i18n,
render: h => h(App),
}).$mount('#app');
export default app;

View File

@@ -0,0 +1,13 @@
<template>
<div class="auth-layout">
<slot />
</div>
</template>
<script>
export default {
name: 'auth-layout',
};
</script>
<style scoped></style>

View File

@@ -0,0 +1,22 @@
<template>
<div class="default-layout">
<Navigation />
<div class="content-wrapper">
<div class="container-fluid">
<slot></slot>
</div>
</div>
<!-- /.content-wrapper -->
</div>
</template>
<script>
import Navigation from '@/components/Navigation';
export default {
name: 'default-layout',
components: {
Navigation,
},
};
</script>

View File

@@ -0,0 +1,204 @@
import path from 'path';
import Module from '@/arch/module';
import EventEmitter from 'events';
import kebabCase from 'lodash/kebabCase';
import isObject from 'lodash/isObject';
import sortBy from 'lodash/sortBy';
import moduleRequire from '_app/generated/module.require';
import merge from 'lodash/merge';
export const moduleFilter = moduleName => true;
export const config = { moduleFilter };
let moduleCfg = require('_app/etc/modules.config.json');
try {
moduleCfg = merge(moduleCfg, require(`_app/etc/modules.${process.env.NODE_ENV}.json`));
} catch (e) {
if (process.env.NODE_ENV === 'development') {
console.log(`Skip load of modules.${process.env.NODE_ENV}.json`);
}
}
try {
moduleCfg = merge(moduleCfg, require('_app/etc/modules.local.json'));
} catch (e) {
if (process.env.NODE_ENV === 'development') {
console.log('Skip load of modules.local.json');
}
}
export const ModuleLoaderInterceptor = new EventEmitter();
const modules = {};
export function localModuleLoader(router) {
const requireModule = require.context('_modules', true, /module.init.js$/);
let moduleInitQueue = [];
requireModule.keys().forEach(fn => {
const pathData = fn.split('/');
const moduleVendor = pathData[1];
const moduleName = pathData[2];
const fullModuleName =
moduleName.search(/integration/) !== -1 && moduleName.search(/module/) !== -1
? `${moduleVendor}_${moduleName}Module`
: `${moduleVendor}_${moduleName}`;
const md = requireModule(fn);
const moduleInitData = md.ModuleConfig || { enabled: false };
const moduleEnabled =
(typeof moduleInitData.enabled !== 'undefined' ? moduleInitData.enabled : false) &&
(Object.prototype.hasOwnProperty.call(moduleCfg, fullModuleName)
? isObject(moduleCfg[fullModuleName])
? (Object.prototype.hasOwnProperty.call(moduleCfg[fullModuleName], 'type')
? moduleCfg[fullModuleName].type === 'local'
: false) &&
(Object.prototype.hasOwnProperty.call(moduleCfg[fullModuleName], 'enabled')
? moduleCfg[fullModuleName].enabled
: false) &&
(Object.prototype.hasOwnProperty.call(moduleCfg[fullModuleName], 'ref')
? moduleCfg[fullModuleName].ref === fullModuleName
: false)
: false
: false);
if (moduleEnabled) {
moduleInitQueue.push({
module: md,
order: Object.prototype.hasOwnProperty.call(moduleInitData, 'loadOrder')
? moduleInitData.loadOrder
: 999,
moduleInitData,
fullModuleName,
fn,
type: 'local',
});
}
});
// Require package modules
if (moduleRequire.length > 0) {
moduleRequire.forEach(requireFn => {
const md = requireFn();
if (!Object.prototype.hasOwnProperty.call(md, 'ModuleConfig')) {
throw new Error(
`Vendor module cannot be initialized. All vendor modules must export ModuleConfig object property.`,
);
}
if (!Object.prototype.hasOwnProperty.call(md, 'init')) {
throw new Error(
`Vendor module cannot be initialized. All vendor modules must export init function property`,
);
}
const moduleConfig = md.ModuleConfig;
if (!Object.prototype.hasOwnProperty.call(moduleConfig, 'moduleName')) {
throw new Error(
`Vendor module cannot be initialized. All vendor modules must have a name matching the pattern Vendor_ModuleName`,
);
}
if (
moduleInitQueue.findIndex(el => {
return el.fullModuleName === moduleConfig.moduleName;
}) === -1
) {
moduleInitQueue.push({
module: md,
order: Object.prototype.hasOwnProperty.call(moduleConfig, 'loadOrder')
? moduleConfig.loadOrder
: 999,
moduleInitData: moduleConfig,
fullModuleName: moduleConfig.moduleName,
type: 'package',
});
}
});
}
const internalModule = require.context('_internal', true, /module.init.js$/);
internalModule.keys().forEach(fn => {
const pathData = fn.split('/');
const moduleName = pathData[1];
const fullModuleName =
moduleName.search(/integration/) !== -1 && moduleName.search(/module/) !== -1
? `${moduleName}Module`
: `${moduleName}`;
const md = internalModule(fn);
const moduleInitData = md.ModuleConfig || { fullModuleName: moduleName };
moduleInitQueue.push({
module: md,
order: Object.prototype.hasOwnProperty.call(moduleInitData, 'loadOrder') ? moduleInitData.loadOrder : 999,
moduleInitData,
fullModuleName,
fn,
type: 'internal',
});
});
// Sort modules load order
moduleInitQueue = sortBy(moduleInitQueue, 'order');
// Initializing modules sync
moduleInitQueue.forEach(({ module, moduleInitData, fullModuleName, fn = undefined, type = 'unknown' }) => {
if (!config.moduleFilter(fullModuleName)) {
return;
}
if (process.env.NODE_ENV === 'development') {
console.log(`Initializing ${type} module ${fullModuleName}...`);
}
const moduleInstance = module.init(
new Module(
moduleInitData.routerPrefix || kebabCase(fullModuleName),
moduleInitData.moduleName || fullModuleName,
),
router,
);
if (typeof moduleInstance === 'undefined') {
throw new Error(
`Error while initializing module ${fullModuleName}: the context must be returned from init() method`,
);
}
modules[fullModuleName] = {
path: typeof fn !== 'undefined' ? path.resolve(__dirname, '..', 'modules', fn) : 'NODE_PACKAGE',
moduleInstance: moduleInstance,
};
if (process.env.NODE_ENV === 'development') {
console.info(`${fullModuleName} has been initialized`);
}
});
if (process.env.NODE_ENV === 'development') {
console.log("All modules has been initialized successfully. You can run 'system.getModuleList()'");
window.system.getModuleList = getModuleList;
}
Object.keys(modules).forEach(m => {
const mdInstance = modules[m].moduleInstance;
ModuleLoaderInterceptor.emit(m, mdInstance);
modules[m].moduleInstance = mdInstance;
router.addRoutes([...modules[m].moduleInstance.getRoutes()]);
});
// All modules loaded successfully
ModuleLoaderInterceptor.emit('loaded', router);
return modules;
}
export function getModuleList() {
return modules;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
<template>
<at-modal :value="showModal" :title="$t('control.add_new_task')" @on-cancel="cancel" @on-confirm="confirm">
<validation-observer ref="form" v-slot="{}">
<validation-provider
ref="project"
v-slot="{ errors }"
rules="required"
:name="$t('field.project')"
mode="passive"
>
<div class="input-group">
<small>{{ $t('field.project') }}</small>
<resource-select
v-model="projectId"
class="input"
:service="projectsService"
:class="{ 'at-select--error': errors.length > 0 }"
/>
<p>{{ errors[0] }}</p>
</div>
</validation-provider>
<validation-provider
ref="taskName"
v-slot="{ errors }"
rules="required"
:name="$t('field.task_name')"
mode="passive"
>
<div class="input-group">
<small>{{ $t('field.task_name') }}</small>
<at-input v-model="taskName" class="input" />
<p>{{ errors[0] }}</p>
</div>
</validation-provider>
<validation-provider
ref="taskDescription"
v-slot="{ errors }"
rules="required"
:name="$t('field.task_description')"
mode="passive"
>
<div class="input-group">
<small>{{ $t('field.task_description') }}</small>
<at-textarea v-model="taskDescription" class="input" />
<p>{{ errors[0] }}</p>
</div>
</validation-provider>
</validation-observer>
<div slot="footer">
<at-button @click="cancel">{{ $t('control.cancel') }}</at-button>
<at-button type="primary" :disabled="disableButtons" @click="confirm">{{ $t('control.save') }} </at-button>
</div>
</at-modal>
</template>
<script>
import ResourceSelect from '@/components/ResourceSelect';
import ProjectService from '@/services/resource/project.service';
import TasksService from '@/services/resource/task.service';
import { ValidationObserver, ValidationProvider } from 'vee-validate';
export default {
name: 'AddNewTaskModal',
components: {
ResourceSelect,
ValidationObserver,
ValidationProvider,
},
props: {
showModal: {
required: true,
type: Boolean,
},
disableButtons: {
default: false,
type: Boolean,
},
},
data() {
return {
projectId: '',
taskName: '',
taskDescription: '',
projectsService: new ProjectService(),
tasksService: new TasksService(),
};
},
methods: {
cancel() {
this.$refs.form.reset();
this.projectId = '';
this.taskName = '';
this.taskDescription = '';
this.$emit('cancel');
},
async confirm() {
const valid = await this.$refs.form.validate();
if (!valid) {
return;
}
const { projectId, taskName, taskDescription } = this;
this.projectId = '';
this.taskName = '';
this.taskDescription = '';
this.$emit('confirm', { projectId, taskName, taskDescription });
},
},
};
</script>
<style lang="scss" scoped>
.input-group {
margin-bottom: $layout-01;
}
.input {
margin-bottom: $spacing-02;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<at-modal :value="showModal" :title="$t('control.edit_intervals')" @on-cancel="cancel" @on-confirm="confirm">
<validation-observer ref="form" v-slot="{}">
<validation-provider
ref="project"
v-slot="{ errors }"
rules="required"
:name="$t('field.project')"
mode="passive"
>
<div class="input-group">
<small>{{ $t('field.project') }}</small>
<resource-select
v-model="projectId"
class="input"
:service="projectsService"
:class="{ 'at-select--error': errors.length > 0 }"
/>
<p>{{ errors[0] }}</p>
</div>
</validation-provider>
<validation-provider
ref="task"
v-slot="{ errors }"
rules="required"
:name="$t('field.task')"
mode="passive"
>
<div class="input-group">
<small>{{ $t('field.task') }}</small>
<at-select
v-if="enableTaskSelect"
v-model="taskId"
filterable
class="input"
:placeholder="$t('control.select')"
:class="{ 'at-select--error': errors.length > 0 }"
>
<at-option
v-for="option of tasksOptionList"
:key="option.value"
:value="option.value"
:label="option.label"
>
<div class="input__select-wrap">
<div class="flex flex-wrap flex-gap">
<at-tooltip
v-for="(user, userKey) in option.users"
:key="userKey"
:content="user.full_name"
placement="right"
class="user-tooltips"
>
<user-avatar :user="user" />
</at-tooltip>
</div>
<span>{{ option.label }}</span>
</div>
</at-option>
</at-select>
<at-input v-else class="input" disabled />
<p>{{ errors[0] }}</p>
</div>
</validation-provider>
</validation-observer>
<div slot="footer">
<at-button @click="cancel">{{ $t('control.cancel') }}</at-button>
<at-button type="primary" :disabled="disableButtons" @click="confirm">{{ $t('control.save') }} </at-button>
</div>
</at-modal>
</template>
<script>
import ResourceSelect from '@/components/ResourceSelect';
import ProjectService from '@/services/resource/project.service';
import TasksService from '@/services/resource/task.service';
import { ValidationObserver, ValidationProvider } from 'vee-validate';
import UserAvatar from '@/components/UserAvatar';
export default {
name: 'ChangeTaskModal',
components: {
ResourceSelect,
ValidationObserver,
ValidationProvider,
UserAvatar,
},
props: {
showModal: {
required: true,
type: Boolean,
},
disableButtons: {
default: false,
type: Boolean,
},
},
computed: {
enableTaskSelect() {
return !!(this.projectId && this.tasksOptionList);
},
},
data() {
return {
projectId: '',
taskId: '',
projectsService: new ProjectService(),
tasksService: new TasksService(),
tasksOptionList: [],
};
},
methods: {
cancel() {
this.$refs.form.reset();
this.projectId = '';
this.taskId = '';
this.$emit('cancel');
},
async confirm() {
const valid = await this.$refs.form.validate();
if (!valid) {
return;
}
const { taskId } = this;
this.projectId = '';
this.taskId = '';
this.$emit('confirm', taskId);
},
},
watch: {
async projectId(projectId) {
try {
const taskList = (
await this.tasksService.getWithFilters({ where: { project_id: projectId }, with: ['users'] })
).data.data;
this.tasksOptionList = taskList.map(option => ({
value: option.id,
label: option['task_name'],
users: option.users,
}));
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'Request to tasks is canceled');
}
}
requestAnimationFrame(() => {
if (Object.prototype.hasOwnProperty.call(this.$refs, 'project') && this.$refs.project) {
this.$refs.project.reset();
}
if (Object.prototype.hasOwnProperty.call(this.$refs, 'task') && this.$refs.project) {
this.$refs.task.reset();
}
});
},
},
};
</script>
<style lang="scss" scoped>
.input-group {
margin-bottom: $layout-01;
}
.input {
margin-bottom: $spacing-02;
&__select-wrap {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
span {
padding-left: 10px;
}
.flex {
max-width: 40%;
}
}
}
</style>

View File

@@ -0,0 +1,557 @@
<template>
<div class="canvas-wrapper">
<div
v-show="hoverPopup.show && !clickPopup.show"
:style="{
left: `${hoverPopup.x - 30}px`,
bottom: `${height() - hoverPopup.y + 50}px`,
}"
class="popup"
>
<div v-if="hoverPopup.event">
{{ hoverPopup.event.task_name }}
({{ hoverPopup.event.project_name }})
</div>
<div v-if="hoverPopup.event">
{{ formatDuration(hoverPopup.event.duration) }}
</div>
<a :style="{ left: `${hoverPopup.borderX}px` }" class="corner"></a>
</div>
<div
v-show="clickPopup.show"
:style="{
left: `${clickPopup.x - 30}px`,
bottom: `${height() - clickPopup.y + 50}px`,
}"
class="popup"
>
<template v-if="clickPopup.event">
<div>
<Screenshot
:disableModal="true"
:lazyImage="false"
:project="{ id: clickPopup.event.project_id, name: clickPopup.event.project_name }"
:interval="clickPopup.event"
:showText="false"
:task="{ id: clickPopup.event.task_id, name: clickPopup.event.task_name }"
:user="clickPopup.event"
@click="showPopup"
/>
</div>
<div>
<router-link :to="`/tasks/view/${clickPopup.event.task_id}`">
{{ clickPopup.event.task_name }}
</router-link>
<router-link :to="`/projects/view/${clickPopup.event.project_id}`">
({{ clickPopup.event.project_name }})
</router-link>
</div>
</template>
<a :style="{ left: `${clickPopup.borderX}px` }" class="corner" />
</div>
<ScreenshotModal
:project="modal.project"
:interval="modal.interval"
:show="modal.show"
:showNavigation="true"
:task="modal.task"
:user="modal.user"
@close="onHide"
@remove="onRemove"
@showNext="showNext"
@showPrevious="showPrevious"
/>
<div ref="canvas" class="canvas" @pointerdown="onDown">
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
<div :style="{ width: `${totalWidth}px` }" />
</div>
</div>
</div>
</template>
<script>
import Screenshot from '@/components/Screenshot';
import ScreenshotModal from '@/components/ScreenshotModal';
import IntervalService from '@/services/resource/time-interval.service';
import { formatDurationString } from '@/utils/time';
import moment from 'moment-timezone';
import { mapGetters } from 'vuex';
import { SVG } from '@svgdotjs/svg.js';
let intervalService = new IntervalService();
const titleHeight = 20;
const subtitleHeight = 20;
const rowHeight = 65;
const columns = 24;
const minColumnWidth = 42;
const popupWidth = 270;
const canvasPadding = 20;
const defaultCornerOffset = 15;
export default {
name: 'TeamDayGraph',
components: {
Screenshot,
ScreenshotModal,
},
props: {
users: {
type: Array,
required: true,
},
start: {
type: String,
required: true,
},
},
data() {
return {
hoverPopup: {
show: false,
x: 0,
y: 0,
event: null,
borderX: 0,
},
clickPopup: {
show: false,
x: 0,
y: 0,
event: null,
intervalID: null,
borderX: 0,
},
modal: {
show: false,
project: null,
task: null,
user: null,
interval: null,
},
totalWidth: 0,
scrollPos: 0,
};
},
computed: {
...mapGetters('dashboard', ['intervals', 'timezone']),
...mapGetters('user', ['companyData']),
},
mounted() {
this.draw = SVG();
this.onResize();
window.addEventListener('resize', this.onResize);
window.addEventListener('mousedown', this.onClick);
window.addEventListener('keydown', this.onKeyDown);
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize);
window.removeEventListener('mousedown', this.onClick);
window.removeEventListener('keydown', this.onKeyDown);
},
methods: {
formatDuration: formatDurationString,
showPopup() {
this.modal = {
show: true,
project: { id: this.clickPopup.event.project_id, name: this.clickPopup.event.project_name },
user: this.clickPopup.event,
task: { id: this.clickPopup.event.task_id, task_name: this.clickPopup.event.task_name },
interval: this.clickPopup.event,
};
},
onHide() {
this.modal = {
...this.modal,
show: false,
};
this.$emit('selectedIntervals', null);
},
onKeyDown(e) {
if (!this.modal.show) {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
this.showPrevious();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
this.showNext();
}
},
showPrevious() {
const intervals = this.intervals[this.modal.user.user_id];
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
if (currentIndex > 0) {
const interval = intervals[currentIndex - 1];
if (interval) {
this.modal.interval = interval;
this.modal.user = interval;
this.modal.project = { id: interval.project_id, name: interval.project_name };
this.modal.task = { id: interval.task_id, name: interval.task_name };
}
}
},
showNext() {
const intervals = this.intervals[this.modal.user.user_id];
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
if (currentIndex < intervals.length - 1) {
const interval = intervals[currentIndex + 1];
if (interval) {
this.modal.interval = interval;
this.modal.user = interval;
this.modal.project = { id: interval.project_id, name: interval.project_name };
this.modal.task = { id: interval.task_id, name: interval.task_name };
}
}
},
height() {
return this.users.length * rowHeight;
},
canvasWidth() {
return this.$refs.canvas.clientWidth;
},
columnWidth() {
return Math.max(minColumnWidth, this.canvasWidth() / columns);
},
async contentWidth() {
await this.$nextTick();
this.totalWidth = columns * this.columnWidth();
return this.totalWidth;
},
onDown(e) {
this.$refs.canvas.addEventListener('pointermove', this.onMove);
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
},
async maxScrollX() {
return (await this.contentWidth()) - this.canvasWidth();
},
async scrollCanvas(movementX, setScroll = true) {
const canvas = this.$refs.canvas;
const clientWidth = canvas.clientWidth;
const entireWidth = await this.contentWidth();
const height = this.height();
const newScrollPos = this.scrollPos - movementX;
if (newScrollPos <= 0) {
this.scrollPos = 0;
} else if (newScrollPos >= entireWidth - clientWidth) {
this.scrollPos = entireWidth - clientWidth;
} else {
this.scrollPos = newScrollPos;
}
setScroll ? await this.setScroll() : null;
this.draw.viewbox(this.scrollPos, 20, clientWidth, height);
},
async onMove(e) {
this.$refs.canvas.setPointerCapture(e.pointerId);
await this.scrollCanvas(e.movementX);
},
onUp(e) {
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
},
onCancel(e) {
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
},
onScroll(e) {
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
},
async setScroll(x = null) {
await this.$nextTick();
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
},
async drawGrid() {
if (typeof this.draw === 'undefined') return;
this.draw.clear();
const canvasContainer = this.$refs.canvas;
const width = canvasContainer.clientWidth;
const height = this.height();
const columnWidth = this.columnWidth();
const draw = this.draw;
draw.addTo(canvasContainer).size(width, height + titleHeight + subtitleHeight);
if (height <= 0) {
return;
}
this.draw.viewbox(0, 20, width, height);
// Background
const rectBackground = draw
.rect(await this.contentWidth(), height - 1)
.move(0, titleHeight + subtitleHeight)
.radius(20)
.fill('#FAFAFA')
.stroke({ color: '#DFE5ED', width: 1 })
.on('mousedown', () => this.$emit('outsideClick'));
draw.add(rectBackground);
for (let column = 0; column < columns; ++column) {
const date = moment().startOf('day').add(column, 'hours');
let left = this.columnWidth() * column;
// Column headers - hours
draw.text(date.format('h'))
.move(left + columnWidth / 2, 0)
.size(columnWidth, titleHeight)
.attr({
'text-anchor': 'middle',
'font-family': 'Nunito, sans-serif',
'font-size': 15,
fill: '#151941',
});
// Column headers - am/pm
draw.text(date.format('A'))
.move(left + columnWidth / 2, titleHeight - 5)
.size(columnWidth, subtitleHeight)
.attr({
'text-anchor': 'middle',
'font-family': 'Nunito, sans-serif',
'font-size': 10,
'font-weight': '600',
fill: '#B1B1BE',
});
// // Vertical grid lines
if (column > 0) {
draw.line(0, titleHeight + subtitleHeight, 0, height + titleHeight + subtitleHeight)
.move(left, titleHeight + subtitleHeight)
.stroke({ color: '#DFE5ED', width: 1 });
}
}
const maxLeftOffset = width - popupWidth + 2 * canvasPadding;
const clipPath = draw
.rect(await this.contentWidth(), height - 1)
.move(0, titleHeight + subtitleHeight)
.radius(20)
.attr({
absolutePositioned: true,
});
const squaresGroup = draw.group().clipWith(clipPath);
for (const user of this.users) {
const row = this.users.indexOf(user);
const top = row * rowHeight + titleHeight + subtitleHeight;
// Horizontal grid lines
if (row > 0) {
draw.line(0, 0, await this.contentWidth(), 0)
.move(0, top)
.stroke({ color: '#DFE5ED', width: 1 });
}
// Intervals
if (Object.prototype.hasOwnProperty.call(this.intervals, user.id)) {
this.intervals[user.id].forEach(event => {
const leftOffset =
moment
.tz(event.start_at, this.companyData.timezone)
.tz(this.timezone)
.diff(moment.tz(this.start, this.timezone).startOf('day'), 'hours', true) % 24;
const widthIntrevals =
((Math.max(event.duration, 60) + 120) * this.columnWidth()) / 60 / 60;
const rectInterval = draw
.rect(widthIntrevals, rowHeight / 2)
.move(Math.floor(leftOffset * this.columnWidth()), top + rowHeight / 4)
.radius(2)
.stroke({ color: 'transparent', width: 0 })
.attr({
cursor: 'pointer',
hoverCursor: 'pointer',
fill: event.is_manual == '1' ? '#c4b52d' : '#2DC48D',
});
rectInterval.on('mouseover', e => {
const popupY = rectInterval.bbox().y - rectInterval.bbox().height;
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
const rectMiddleX = rectInterval.rbox().cx - defaultCornerOffset / 2;
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
const left =
rectMiddleX > canvasRight
? canvasRight - defaultCornerOffset / 2
: rectMiddleX < minLeft
? minLeft - defaultCornerOffset / 2
: rectMiddleX;
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
const popupX = left > maxRight ? maxRight : left < minLeft ? minLeft : left;
const arrowX = defaultCornerOffset + left - popupX;
this.hoverPopup = {
show: true,
x: popupX,
y: popupY,
event,
borderX: arrowX,
};
});
rectInterval.on('mouseout', e => {
this.hoverPopup = {
...this.hoverPopup,
show: false,
};
});
rectInterval.on('mousedown', e => {
this.$emit('selectedIntervals', event);
const popupY = rectInterval.bbox().y - rectInterval.bbox().height;
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
const rectMiddleX = rectInterval.rbox().cx - defaultCornerOffset / 2;
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
const left =
rectMiddleX > canvasRight
? canvasRight - defaultCornerOffset / 2
: rectMiddleX < minLeft
? minLeft - defaultCornerOffset / 2
: rectMiddleX;
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
const popupX = left > maxRight ? maxRight : left < minLeft ? minLeft : left;
const arrowX = defaultCornerOffset + left - popupX;
this.clickPopup = {
show: true,
x: popupX,
y: popupY,
event,
borderX: arrowX,
};
e.stopPropagation();
});
squaresGroup.add(rectInterval);
});
}
}
},
onResize: function () {
const canvasContainer = this.$refs.canvas;
const width = canvasContainer.clientWidth;
const height = this.height();
this.draw.size(width, height);
this.setScroll(0);
this.drawGrid();
},
onClick(e) {
if (e.button !== 0 || (e.target && e.target.closest('.popup'))) {
return;
}
this.clickPopup = {
...this.clickPopup,
show: false,
};
},
async onRemove() {
try {
await intervalService.deleteItem(this.modal.interval.id);
this.$Notify({
type: 'success',
title: this.$t('notification.screenshot.delete.success.title'),
message: this.$t('notification.screenshot.delete.success.message'),
});
this.onHide();
} catch (e) {
this.$Notify({
type: 'error',
title: this.$t('notification.screenshot.delete.error.title'),
message: this.$t('notification.screenshot.delete.error.message'),
});
}
},
},
watch: {
start() {
this.setScroll(0);
},
users() {
this.onResize();
},
intervals() {
this.drawGrid();
},
timezone() {
this.drawGrid();
},
},
};
</script>
<style lang="scss" scoped>
.popup {
background: #ffffff;
border: 0;
border-radius: 20px;
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
display: block;
padding: 10px;
position: absolute;
text-align: center;
width: 270px;
z-index: 3;
& .corner {
border-left: 15px solid transparent;
border-right: 15px solid transparent;
border-top: 10px solid #ffffff;
bottom: -10px;
content: ' ';
display: block;
height: 0;
left: 15px;
position: absolute;
width: 0;
z-index: 1;
}
}
.canvas {
position: relative;
user-select: none;
touch-action: pan-y;
cursor: move;
&::v-deep canvas {
box-sizing: content-box;
}
.scrollbar-top {
position: absolute;
left: 0;
top: -1.5rem;
width: 100%;
height: 10px;
overflow-x: auto;
}
.scrollbar-top {
& > div {
height: 1px;
}
&::-webkit-scrollbar {
height: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-button {
display: none;
}
&::-webkit-scrollbar-thumb {
background: #2e2ef9;
border-radius: 3px;
}
}
}
@media (max-width: 720px) {
.canvas {
.scrollbar-top {
top: -1rem;
}
}
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div class="team_sidebar">
<div class="row team_sidebar__heading">
<div class="col-12">
<span
:class="{ 'team_sidebar__heading-active': this.sort === 'user' }"
class="team_sidebar__heading-toggle"
@click="selectColumn('user')"
>{{ $t('dashboard.user') }}
<template v-if="this.sort === 'user'">
<i v-if="this.sortDir === 'asc'" class="icon icon-chevron-down"></i>
<i v-else class="icon icon-chevron-up"></i>
</template>
</span>
</div>
<div class="col-12 flex-end">
<span
:class="{ 'team_sidebar__heading-active': this.sort === 'worked' }"
class="team_sidebar__heading-toggle"
@click="selectColumn('worked')"
>{{ $t('dashboard.worked') }}
<template v-if="this.sort === 'worked'">
<i v-if="this.sortDir === 'desc'" class="icon icon-chevron-down"></i>
<i v-else class="icon icon-chevron-up"></i>
</template>
</span>
</div>
</div>
<div v-for="(user, key) in users" :key="key" class="row team_sidebar__user_wrapper">
<div class="col-12 row team_sidebar__user_row">
<UserAvatar :user="user" />
<div class="team_sidebar__user_info col-24">
<div class="team_sidebar__user_name">{{ user.full_name }}</div>
<div class="team_sidebar__user_task">
<router-link
v-if="user.last_interval"
:to="`/tasks/view/${user.last_interval.task_id}`"
:title="user.last_interval.task_name"
target="_blank"
>
{{ user.last_interval.project_name }}
</router-link>
</div>
</div>
</div>
<div class="col-12 flex-end team_sidebar__user_worked">
{{ formatDurationString(user.worked) }}
</div>
</div>
</div>
</template>
<script>
import { formatDurationString } from '@/utils/time';
import { mapGetters } from 'vuex';
import UserAvatar from '@/components/UserAvatar';
export default {
name: 'TeamSidebar',
components: { UserAvatar },
props: {
sort: {
type: String,
required: true,
},
sortDir: {
type: String,
required: true,
},
users: {
type: Array,
required: true,
},
},
computed: {
...mapGetters('dashboard', ['intervals']),
},
methods: {
formatDurationString,
selectColumn(column) {
this.$emit('sort', column);
},
},
};
</script>
<style lang="scss" scoped>
.team_sidebar {
&__heading {
font-weight: 600;
color: #b1b1be;
padding-right: 9px;
&-active {
color: #59566e;
padding-right: 14px;
}
&-toggle {
cursor: pointer;
display: inline-block;
margin-bottom: 15px;
position: relative;
.icon {
position: absolute;
top: 50%;
right: -3px;
transform: translateY(-46%);
}
}
}
&__user {
&_name {
font-size: 10pt;
font-weight: 500;
color: #151941;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&_row {
height: 65px;
flex-wrap: nowrap;
align-items: center;
}
&_worked {
color: #59566e;
font-weight: 600;
display: flex;
align-items: center;
white-space: nowrap;
}
&_task {
font-size: 9pt;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&_info {
margin-top: 0;
}
}
@media (max-width: 780px) {
.team_sidebar {
&__heading {
display: grid;
grid-template-columns: 100%;
grid-template-rows: repeat(2, calc(39px / 2));
font-size: 0.8rem;
& > div {
max-width: 100%;
justify-self: start;
}
}
&__user_wrapper {
height: 65px;
display: grid;
grid-template-rows: 3fr 1fr;
grid-template-columns: 100%;
}
&__user_task {
display: none;
}
&__user_worked {
max-width: 100%;
align-self: flex-end;
font-size: 0.6rem;
}
&__user_row {
max-width: 80%;
height: auto;
align-self: end;
}
}
.hidden {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,380 @@
<template>
<div ref="canvas" class="canvas" @pointerdown="onDown">
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
<div :style="{ width: `${totalWidth}px` }" />
</div>
</div>
</template>
<script>
import moment from 'moment';
import { formatDurationString } from '@/utils/time';
import { mapGetters } from 'vuex';
import { SVG } from '@svgdotjs/svg.js';
const defaultColorConfig = [
{
start: 0,
end: 0.75,
color: '#ffb6c2',
},
{
start: 0.76,
end: 1,
color: '#93ecda',
},
{
start: 1,
end: 0,
color: '#3cd7b6',
isOverTime: true,
},
];
const titleHeight = 20;
const subtitleHeight = 20;
const rowHeight = 65;
const minColumnWidth = 85;
export default {
name: 'TeamTableGraph',
props: {
start: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
users: {
type: Array,
required: true,
},
timePerDay: {
type: Object,
required: true,
},
},
data() {
return {
lastPosX: 0,
offsetX: 0,
totalWidth: 0,
scrollPos: 0,
};
},
computed: {
...mapGetters('user', ['companyData']),
workingHours() {
return 'work_time' in this.companyData && this.companyData.work_time ? this.companyData.work_time : 7;
},
colorRules() {
return this.companyData.color ? this.companyData.color : defaultColorConfig;
},
columns() {
const start = moment(this.start, 'YYYY-MM-DD');
const end = moment(this.end, 'YYYY-MM-DD');
return end.diff(start, 'days') + 1;
},
},
mounted() {
this.draw = SVG();
this.onResize();
window.addEventListener('resize', this.onResize);
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize);
},
methods: {
height() {
return this.users.length * rowHeight;
},
canvasWidth() {
return this.$refs.canvas.clientWidth;
},
columnWidth() {
return Math.max(minColumnWidth, this.canvasWidth() / this.columns);
},
async contentWidth() {
await this.$nextTick();
this.totalWidth = this.columns * this.columnWidth();
return this.totalWidth;
},
isDateWithinRange(dateString, startDate, endDate) {
const date = new Date(dateString);
return date >= new Date(startDate) && date <= new Date(endDate);
},
getColor(progress) {
let color = '#3cd7b6';
this.colorRules.forEach(el => {
if ('isOverTime' in el && progress > el.start) {
color = el.color;
} else if (progress >= el.start && progress <= el.end) {
color = el.color;
}
});
return color;
},
onDown(e) {
this.$refs.canvas.addEventListener('pointermove', this.onMove);
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
},
async maxScrollX() {
return (await this.contentWidth()) - this.canvasWidth();
},
async scrollCanvas(movementX, setScroll = true) {
const canvas = this.$refs.canvas;
const clientWidth = canvas.clientWidth;
const entireWidth = await this.contentWidth();
const height = this.height();
const newScrollPos = this.scrollPos - movementX;
if (newScrollPos <= 0) {
this.scrollPos = 0;
} else if (newScrollPos >= entireWidth - clientWidth) {
this.scrollPos = entireWidth - clientWidth;
} else {
this.scrollPos = newScrollPos;
}
setScroll ? await this.setScroll() : null;
this.draw.viewbox(this.scrollPos, 20, clientWidth, height);
},
async onMove(e) {
this.$refs.canvas.setPointerCapture(e.pointerId);
await this.scrollCanvas(e.movementX);
},
onUp(e) {
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
},
onCancel(e) {
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
},
onScroll(e) {
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
},
async setScroll(x = null) {
await this.$nextTick();
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
},
formatDuration: formatDurationString,
drawGrid: async function () {
if (typeof this.draw === 'undefined') return;
this.draw.clear();
const draw = this.draw;
const canvasContainer = this.$refs.canvas;
const width = canvasContainer.clientWidth;
const columnWidth = this.columnWidth();
const height = this.height();
if (height <= 0) {
return;
}
const start = moment(this.start, 'YYYY-MM-DD');
const cursor = (await this.contentWidth()) > this.canvasWidth() ? 'move' : 'default';
draw.addTo(canvasContainer).size(width, height + titleHeight + subtitleHeight);
draw.viewbox(0, 20, width, height);
// Background
draw.rect((await this.contentWidth()) - 1, height - 1)
.move(0, titleHeight + subtitleHeight)
.radius(20)
.fill('#fafafa')
.stroke({ color: '#dfe5ed', width: 1 })
.attr({
cursor: cursor,
hoverCursor: cursor,
})
.on('mousedown', () => this.$emit('outsideClick'));
for (let column = 0; column < this.columns; ++column) {
const date = start.clone().locale(this.$i18n.locale).add(column, 'days');
let left = this.columnWidth() * column;
let halfColumnWidth = this.columnWidth() / 2;
// Column headers - day
draw.text(date.locale(this.$i18n.locale).format('D'))
.move(left + halfColumnWidth, 0)
.size(columnWidth, titleHeight)
.font({
family: 'Nunito, sans-serif',
size: 15,
fill: '#151941',
})
.attr({
'text-anchor': 'middle',
cursor: cursor,
hoverCursor: cursor,
});
// Column headers - am/pm
draw.text(date.format('dddd').toUpperCase())
.move(left + halfColumnWidth, titleHeight - 5)
.size(columnWidth, subtitleHeight)
.font({
family: 'Nunito, sans-serif',
size: 10,
weight: '600',
fill: '#b1b1be',
})
.attr({
'text-anchor': 'middle',
cursor: cursor,
hoverCursor: cursor,
});
// Vertical grid lines
if (column > 0) {
draw.line(0, titleHeight + subtitleHeight, 0, height + titleHeight + subtitleHeight)
.move(left, titleHeight + subtitleHeight)
.stroke({ color: '#DFE5ED', width: 1 })
.attr({
cursor: cursor,
hoverCursor: cursor,
});
}
}
const filteredData = {};
for (let key in this.timePerDay) {
const innerObject = this.timePerDay[key];
const filteredInnerObject = {};
for (let dateKey in innerObject) {
if (this.isDateWithinRange(dateKey, this.start, this.end)) {
filteredInnerObject[dateKey] = innerObject[dateKey];
}
}
filteredData[key] = filteredInnerObject;
}
const clipPath = draw
.rect((await this.contentWidth()) - 1, height - 1)
.move(0, titleHeight + subtitleHeight)
.radius(20)
.attr({
absolutePositioned: true,
});
const squaresGroup = draw.group().clipWith(clipPath);
for (const [row, user] of this.users.entries()) {
const top = row * rowHeight + titleHeight + subtitleHeight;
const userTime = filteredData[user.id];
if (userTime) {
for (const day of Object.keys(userTime)) {
const column = -start.diff(day, 'days');
const duration = userTime[day];
const left = (column * (await this.contentWidth())) / this.columns;
const total = 60 * 60 * this.workingHours;
const progress = duration / total;
const height = Math.ceil(Math.min(progress, 1) * (rowHeight - 1));
const color = this.getColor(progress);
const rect = draw
.rect(this.columnWidth(), height + 1)
.move(left, Math.floor(top + (rowHeight - height)) - 1)
.fill(color)
.stroke({ width: 0 })
.attr({
cursor: cursor,
hoverCursor: cursor,
});
squaresGroup.add(rect);
// Time label
draw.text(this.formatDuration(duration))
.move(this.columnWidth() / 2 + left, top + 22)
.size(this.columnWidth(), rowHeight)
.font({
family: 'Nunito, sans-serif',
size: 15,
weight: '600',
fill: '#151941',
})
.attr({
'text-anchor': 'middle',
cursor: cursor,
hoverCursor: cursor,
});
}
}
// Horizontal grid lines
if (row > 0) {
draw.line(0, 0, await this.contentWidth(), 0)
.move(0, top)
.stroke({ color: '#dfe5ed', width: 1 })
.attr({
cursor: cursor,
hoverCursor: cursor,
});
}
}
},
onResize: function () {
const canvasContainer = this.$refs.canvas;
const width = canvasContainer.clientWidth;
const height = this.height();
this.draw.size(width, height);
this.setScroll(0);
this.drawGrid();
},
},
watch: {
start() {
this.setScroll(0);
},
end() {
this.setScroll(0);
},
users() {
this.onResize();
},
timePerDay() {
this.drawGrid();
},
},
};
</script>
<style lang="scss" scoped>
.canvas {
user-select: none;
touch-action: pan-y;
height: 100%;
position: relative;
width: 100%;
}
.scrollbar-top {
position: absolute;
left: 0;
top: -1.5rem;
width: 100%;
height: 10px;
overflow-x: auto;
}
.scrollbar-top {
& > div {
height: 1px;
}
&::-webkit-scrollbar {
height: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-button {
display: none;
}
&::-webkit-scrollbar-thumb {
background: #2e2ef9;
border-radius: 3px;
}
}
@media (max-width: 720px) {
.canvas {
.scrollbar-top {
top: -1rem;
}
}
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<div>
<transition name="slide-up">
<div v-if="intervals.length" class="time-interval-edit-panel">
<div class="container-fluid">
<div class="row flex-middle flex-between">
<div class="time-interval-edit-panel__time col-4">
{{ $t('field.selected') }}:
<strong>{{ formattedTotalTime }}</strong>
</div>
<div class="time-interval-edit-panel__buttons col-12 flex flex-end">
<at-button
:disabled="disabledButtons"
class="time-interval-edit-panel__btn"
@click="openAddNewTaskModal"
>
{{ $t('control.add_new_task') }}
</at-button>
<at-button
:disabled="disabledButtons"
class="time-interval-edit-panel__btn"
@click="openChangeTaskModal"
>
{{ $t('control.edit_intervals') }}
</at-button>
<at-button
:disabled="disabledButtons"
class="time-interval-edit-panel__btn"
type="error"
@click="deleteTimeIntervals"
>
<i class="icon icon-trash" />
{{ $t('control.delete') }}
</at-button>
<div class="divider" />
<at-button class="time-interval-edit-panel__btn" @click="$emit('close')">
{{ $t('control.cancel') }}
</at-button>
</div>
</div>
</div>
</div>
</transition>
<div class="modals">
<template v-if="showAddNewTaskModal">
<AddNewTaskModal
:disableButtons="disabledButtons"
:showModal="showAddNewTaskModal"
@cancel="onAddNewTaskModalCancel"
@confirm="onAddNewTaskModalConfirm"
/>
</template>
<template v-if="showChangeTaskModal">
<ChangeTaskModal
:disableButtons="disabledButtons"
:showModal="showChangeTaskModal"
@cancel="onChangeTaskModalCancel"
@confirm="onChangeTaskModalConfirm"
/>
</template>
</div>
</div>
</template>
<script>
import moment from 'moment';
import { mapGetters } from 'vuex';
import AddNewTaskModal from './AddNewTaskModal';
import ChangeTaskModal from './ChangeTaskModal';
import TasksService from '@/services/resource/task.service';
import TimeIntervalsService from '@/services/resource/time-interval.service';
export default {
name: 'TimeIntervalEdit',
components: {
AddNewTaskModal,
ChangeTaskModal,
},
props: {
intervals: {
type: Array,
},
},
computed: {
...mapGetters('user', ['user']),
showAddNewTaskModal() {
return this.modal === 'addNewTask';
},
showChangeTaskModal() {
return this.modal === 'changeTask';
},
formattedTotalTime() {
return moment
.utc(this.intervals.reduce((total, curr) => total + curr.duration * 1000, 0))
.format('HH:mm:ss');
},
},
data() {
return {
tasksService: new TasksService(),
timeIntervalsService: new TimeIntervalsService(),
modal: '',
disabledButtons: false,
};
},
methods: {
async saveTimeIntervals(data) {
try {
this.disabledButtons = true;
await this.timeIntervalsService.bulkEdit(data);
this.$Notify({
type: 'success',
title: this.$t('notification.screenshot.save.success.title'),
message: this.$t('notification.screenshot.save.success.message'),
});
this.$emit('edit');
this.modal = '';
this.disabledButtons = false;
} catch (e) {
this.$Notify({
type: 'error',
title: this.$t('notification.screenshot.save.error.title'),
message: this.$t('notification.screenshot.save.error.message'),
});
this.disabledButtons = false;
}
},
async deleteTimeIntervals() {
try {
this.disabledButtons = true;
await this.timeIntervalsService.bulkDelete({
intervals: this.intervals.map(el => el.id),
});
this.$Notify({
type: 'success',
title: this.$t('notification.screenshot.delete.success.title'),
message: this.$t('notification.screenshot.delete.success.message'),
});
this.$emit('remove', this.intervals);
this.disabledButtons = false;
} catch (e) {
console.log(e);
this.$Notify({
type: 'error',
title: this.$t('notification.screenshot.delete.error.title'),
message: this.$t('notification.screenshot.delete.error.message'),
});
this.disabledButtons = false;
}
},
async createTask(projectId, taskName, taskDescription) {
try {
this.disabledButtons = true;
const taskResponse = await this.tasksService.save(
{
project_id: projectId,
task_name: taskName,
description: taskDescription,
user_id: this.user.id,
active: true,
priority_id: 2,
},
true,
);
const task = taskResponse.data.res;
const intervals = this.intervals.map(i => ({
id: i.id,
task_id: task.id,
}));
await this.timeIntervalsService.bulkEdit({ intervals });
this.$Notify({
type: 'success',
title: this.$t('notification.screenshot.save.success.title'),
message: this.$t('notification.screenshot.save.success.message'),
});
this.$emit('edit');
this.modal = '';
this.disabledButtons = false;
} catch (e) {
this.$Notify({
type: 'error',
title: this.$t('notification.screenshot.save.error.title'),
message: this.$t('notification.screenshot.save.error.message'),
});
this.disabledButtons = false;
}
},
openAddNewTaskModal() {
this.modal = 'addNewTask';
},
openChangeTaskModal() {
this.modal = 'changeTask';
},
onAddNewTaskModalConfirm({ projectId, taskName, taskDescription }) {
this.createTask(projectId, taskName, taskDescription);
},
onChangeTaskModalConfirm(taskId) {
const intervals = this.intervals.map(i => ({ id: i.id, task_id: taskId }));
this.saveTimeIntervals({ intervals });
},
onAddNewTaskModalCancel() {
this.modal = '';
},
onChangeTaskModalCancel() {
this.modal = '';
},
},
};
</script>
<style lang="scss" scoped>
.time-interval-edit-panel {
border-top: 1px solid $gray-4;
padding: 15px 0;
position: fixed;
z-index: 999;
background-color: #fff;
bottom: 0;
right: 0;
left: 0;
&__buttons {
gap: $layout-01;
}
}
@media (max-width: 790px) {
.time-interval-edit-panel {
&__time {
flex-basis: 100%;
max-width: 100%;
}
&__buttons {
flex-basis: 100%;
max-width: 100%;
flex-wrap: wrap;
}
}
}
@media (max-width: 720px) {
.divider {
display: none;
}
.modals ::v-deep .at-modal {
max-width: 100%;
}
}
.divider {
background-color: $gray-4;
width: 1px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<div ref="canvas" class="canvas"></div>
</template>
<script>
import moment from 'moment';
import { formatDurationString } from '@/utils/time';
import { SVG } from '@svgdotjs/svg.js';
import debounce from 'lodash/debounce';
const headerHeight = 20;
const columns = 7;
const rowHeight = 120;
export default {
name: 'TimelineCalendarGraph',
props: {
start: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
timePerDay: {
type: Object,
required: true,
},
},
mounted() {
this.draw = SVG();
window.addEventListener('resize', this.onResize);
this.drawGrid();
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize);
},
methods: {
formatDuration: formatDurationString,
drawGrid: debounce(
function () {
if (typeof this.draw === 'undefined') return;
this.draw.clear();
const startOfMonth = moment(this.start, 'YYYY-MM-DD').startOf('month');
const endOfMonth = moment(this.start, 'YYYY-MM-DD').endOf('month');
const firstDay = startOfMonth.clone().startOf('isoWeek');
const lastDay = endOfMonth.clone().endOf('isoWeek');
const canvasContainer = this.$refs.canvas;
const width = canvasContainer.clientWidth;
const columnWidth = width / 7;
const rows = lastDay.diff(firstDay, 'weeks') + 1;
const draw = this.draw;
draw.addTo(canvasContainer).size(width, headerHeight + rowHeight * 6);
draw.rect(width - 2, rows * rowHeight - 1)
.move(1, headerHeight)
.radius(20)
.fill('#FAFAFA')
.stroke({ color: '#dfe5ed', width: 1 });
for (let column = 0; column < columns; column++) {
const date = firstDay.clone().locale(this.$i18n.locale).add(column, 'days');
const dateFormat = window.matchMedia('(max-width: 880px)').matches ? 'ddd' : 'dddd';
draw.text(date.format(dateFormat).toUpperCase())
.move(column * columnWidth + columnWidth / 2, -5)
.width(columnWidth)
.height(headerHeight)
.attr({
'text-anchor': 'middle',
'font-family': 'Nunito, sans-serif',
'font-size': 10,
'font-weight': 600,
fill: '#2E2EF9',
});
}
this.drawCells(draw, firstDay, columnWidth, rows, width, lastDay);
this.drawGridLines(draw, rows, width, columnWidth);
},
30,
{ maxWait: 50 },
),
drawGridLines(draw, rows, width, columnWidth) {
for (let row = 1; row < rows; row++) {
draw.line(1, row * rowHeight + headerHeight, width - 1, row * rowHeight + headerHeight).stroke({
color: '#DFE5ED',
width: 1,
});
}
for (let column = 1; column < columns; column++) {
draw.line(
column * columnWidth,
headerHeight,
column * columnWidth,
headerHeight + rowHeight * rows - 1,
).stroke({
color: '#DFE5ED',
width: 1,
});
}
},
drawCells(draw, firstDay, columnWidth, rows, width, lastDay) {
const squaresGroup = draw.group();
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
const date = firstDay
.clone()
.locale(this.$i18n.locale)
.add(row * columns + column, 'days');
const cellLeft = column * columnWidth;
const cellTop = headerHeight + row * rowHeight;
const isInSelection = date.diff(this.start) >= 0 && date.diff(this.end) <= 0;
const { timePerDay } = this;
if (isInSelection) {
const square = draw.rect(columnWidth - 2, rowHeight - 2).attr({
fill: '#F4F4FF',
x: cellLeft + 1,
y: cellTop + 1,
});
squaresGroup.add(square);
const line = draw
.line(
column * columnWidth,
(row + 1) * rowHeight + headerHeight - 2,
(column + 1) * columnWidth,
(row + 1) * rowHeight + headerHeight - 2,
)
.stroke({
color: '#2E2EF9',
width: 3,
});
squaresGroup.add(line);
}
draw.text(date.format('D'))
.move((column + 1) * columnWidth - 10, row * rowHeight + headerHeight + 5)
.attr({
'text-anchor': 'end',
'font-family': 'Nunito, sans-serif',
'font-size': 12,
'font-weight': isInSelection ? 600 : 400,
fill: isInSelection ? '#2E2EF9' : '#868495',
});
const dateKey = date.format('YYYY-MM-DD');
if (timePerDay[dateKey]) {
draw.text(this.formatDuration(timePerDay[dateKey]))
.move(cellLeft, cellTop + rowHeight - 30)
.attr({
'text-anchor': 'inherit',
'font-family': 'Nunito, sans-serif',
'font-weight': isInSelection ? 600 : 400,
'my-text-type': 'time',
fill: '#59566E',
});
}
}
}
let clip = draw.clip();
clip.add(
draw
.rect(width - 4, (lastDay.diff(firstDay, 'weeks') + 1) * rowHeight - 1.5)
.move(2, headerHeight)
.radius(20),
);
squaresGroup.clipWith(clip);
},
onResize: function () {
this.drawGrid();
},
},
watch: {
start() {
this.onResize();
},
end() {
this.onResize();
},
timePerDay() {
this.drawGrid();
},
},
};
</script>
<style lang="scss" scoped>
.canvas ::v-deep svg {
user-select: none;
width: 100%;
text[my-text-type='time'] {
font-size: 0.9rem;
transform: translateX(13px);
}
@media (max-width: 980px) {
text[my-text-type='time'] {
font-size: 0.7rem;
transform: translateX(7px);
}
}
@media (max-width: 430px) {
text[my-text-type='time'] {
font-size: 0.6rem;
transform: translateX(3px);
}
}
}
</style>

View File

@@ -0,0 +1,577 @@
<template>
<div class="canvas-wrapper">
<div
v-show="hoverPopup.show && !clickPopup.show"
:style="{
left: `${hoverPopup.x - 30}px`,
bottom: `${hoverPopup.y}px`,
}"
class="popup"
>
<div v-if="hoverPopup.event">
{{ hoverPopup.event.task_name }}
({{ hoverPopup.event.project_name }})
</div>
<div v-if="hoverPopup.event">
{{ formatDuration(hoverPopup.event.duration) }}
</div>
<a :style="{ left: `${hoverPopup.borderX}px` }" class="corner"></a>
</div>
<div
v-show="clickPopup.show"
:data-offset="`${clickPopup.borderX}px`"
:style="{
left: `${clickPopup.x - 30}px`,
bottom: `${clickPopup.y}px`,
}"
class="popup"
>
<Screenshot
v-if="clickPopup.event && screenshotsEnabled"
:disableModal="true"
:lazyImage="false"
:project="{ id: clickPopup.event.project_id, name: clickPopup.event.project_name }"
:interval="clickPopup.event"
:showText="false"
:task="{ id: clickPopup.event.task_id, name: clickPopup.event.task_name }"
:user="clickPopup.event"
@click="showPopup"
/>
<div v-if="clickPopup.event">
<router-link :to="`/tasks/view/${clickPopup.event.task_id}`">
{{ clickPopup.event.task_name }}
</router-link>
<router-link :to="`/projects/view/${clickPopup.event.project_id}`">
({{ clickPopup.event.project_name }})
</router-link>
</div>
<a :style="{ left: `${clickPopup.borderX}px` }" class="corner" />
</div>
<ScreenshotModal
:project="modal.project"
:interval="modal.interval"
:show="modal.show"
:showNavigation="true"
:task="modal.task"
:user="modal.user"
@close="onHide"
@remove="onRemove"
@showNext="showNext"
@showPrevious="showPrevious"
/>
<div ref="canvas" class="canvas" @pointerdown="onDown">
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
<div :style="{ width: `${totalWidth}px` }" />
</div>
</div>
</div>
</template>
<script>
import moment from 'moment-timezone';
import { formatDurationString } from '@/utils/time';
import Screenshot from '@/components/Screenshot';
import ScreenshotModal from '@/components/ScreenshotModal';
import IntervalService from '@/services/resource/time-interval.service';
import { mapGetters } from 'vuex';
import { SVG } from '@svgdotjs/svg.js';
const titleHeight = 20;
const subtitleHeight = 20;
const timelineHeight = 80;
const columns = 24;
const minColumnWidth = 37;
const popupWidth = 270;
const canvasPadding = 20;
const defaultCornerOffset = 15;
export default {
name: 'TimelineDayGraph',
props: {
start: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
events: {
type: Array,
required: true,
},
timezone: {
type: String,
required: true,
},
},
components: {
Screenshot,
ScreenshotModal,
},
computed: {
...mapGetters('dashboard', ['tasks', 'intervals']),
...mapGetters('user', ['user', 'companyData']),
...mapGetters('screenshots', { screenshotsEnabled: 'enabled' }),
height() {
return timelineHeight + titleHeight + subtitleHeight;
},
tasks() {
if (!this.user) {
return {};
}
const userIntervals = this.intervals[this.user.id];
if (!userIntervals) {
return {};
}
return userIntervals.intervals
.map(interval => interval.task)
.reduce((obj, task) => ({ ...obj, [task.id]: task }), {});
},
projects() {
return Object.keys(this.tasks)
.map(taskID => this.tasks[taskID])
.reduce((projects, task) => ({ ...projects, [task.project_id]: task.project }), {});
},
},
data() {
return {
hoverPopup: {
show: false,
x: 0,
y: 0,
event: null,
borderX: 0,
},
clickPopup: {
show: false,
x: 0,
y: 0,
event: null,
borderX: 0,
},
intervalService: new IntervalService(),
modal: {
interval: null,
project: null,
task: null,
show: false,
},
scrollPos: 0,
totalWidth: 0,
};
},
mounted() {
this.draw = SVG();
this.onResize();
window.addEventListener('resize', this.onResize);
window.addEventListener('mousedown', this.onClick);
window.addEventListener('keydown', this.onKeyDown);
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize);
window.removeEventListener('mousedown', this.onClick);
window.removeEventListener('keydown', this.onKeyDown);
},
methods: {
formatDuration: formatDurationString,
showPopup() {
this.modal = {
show: true,
project: { id: this.clickPopup.event.project_id, name: this.clickPopup.event.project_name },
user: this.clickPopup.event,
task: { id: this.clickPopup.event.task_id, name: this.clickPopup.event.task_name },
interval: this.clickPopup.event,
};
},
onHide() {
this.modal.show = false;
},
onKeyDown(e) {
if (e.key === 'ArrowLeft') {
e.preventDefault();
this.showPrevious();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
this.showNext();
}
},
showPrevious() {
const intervals = this.intervals[this.modal.user.user_id];
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
if (currentIndex > 0) {
const interval = intervals[currentIndex - 1];
if (interval) {
this.modal.interval = interval;
this.modal.user = interval;
this.modal.project = { id: interval.project_id, name: interval.project_name };
this.modal.task = { id: interval.task_id, name: interval.task_name };
}
}
},
showNext() {
const intervals = this.intervals[this.modal.user.user_id];
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
if (currentIndex < intervals.length - 1) {
const interval = intervals[currentIndex + 1];
if (interval) {
this.modal.interval = interval;
this.modal.user = interval;
this.modal.project = { id: interval.project_id, name: interval.project_name };
this.modal.task = { id: interval.task_id, name: interval.task_name };
}
}
},
canvasWidth() {
return this.$refs.canvas.clientWidth;
},
columnWidth() {
return Math.max(minColumnWidth, this.canvasWidth() / columns);
},
async contentWidth() {
await this.$nextTick();
this.totalWidth = columns * this.columnWidth();
return this.totalWidth;
},
onDown(e) {
this.$refs.canvas.addEventListener('pointermove', this.onMove);
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
},
async scrollCanvas(movementX, setScroll = true) {
const canvas = this.$refs.canvas;
const clientWidth = canvas.clientWidth;
const entireWidth = await this.contentWidth();
const height = this.height;
const newScrollPos = this.scrollPos - movementX;
if (newScrollPos <= 0) {
this.scrollPos = 0;
} else if (newScrollPos >= entireWidth - clientWidth) {
this.scrollPos = entireWidth - clientWidth;
} else {
this.scrollPos = newScrollPos;
}
setScroll ? await this.setScroll() : null;
this.draw.viewbox(this.scrollPos, 0, clientWidth, height);
},
async onMove(e) {
this.$refs.canvas.setPointerCapture(e.pointerId);
await this.scrollCanvas(e.movementX);
},
onUp(e) {
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
},
onCancel(e) {
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
},
onScroll(e) {
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
},
async setScroll(x = null) {
await this.$nextTick();
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
},
drawGrid: async function () {
if (typeof this.draw === 'undefined') return;
this.draw.clear();
const draw = this.draw;
// const width = draw.width();
const canvasContainer = this.$refs.canvas;
const width = canvasContainer.clientWidth;
const columnWidth = this.columnWidth();
draw.addTo(canvasContainer).size(width, this.height);
this.draw.viewbox(0, 0, width, this.height);
// Background
this.draw
.rect(await this.contentWidth(), timelineHeight - 1)
.move(0, titleHeight + subtitleHeight)
.radius(20)
.fill('#fafafa')
.stroke({ color: '#dfe5ed', width: 1 })
.on('mousedown', () => this.$emit('outsideClick'));
const maxLeftOffset = width - popupWidth + 2 * canvasPadding;
const minLeftOffset = canvasPadding / 2;
const clipPath = draw
.rect(await this.contentWidth(), timelineHeight - 1)
.move(0, titleHeight + subtitleHeight)
.radius(20)
.attr({
absolutePositioned: true,
});
const squaresGroup = draw.group().clipWith(clipPath);
for (let i = 0; i < columns; ++i) {
const date = moment().startOf('day').add(i, 'hours');
const left = columnWidth * i;
// Column header - hour
draw.text(date.format('h'))
.move(left + columnWidth / 2, 0)
.addClass('text-center')
.width(columnWidth)
.height(titleHeight)
.attr({
'text-anchor': 'middle',
'font-family': 'Nunito, sans-serif',
'font-size': 15,
fill: '#151941',
});
// Column header - am/pm
draw.text(function (add) {
add.tspan(date.format('A')).newLine();
})
.move(left + columnWidth / 2, titleHeight - 5)
.attr({
'text-anchor': 'middle',
'font-family': 'Nunito, sans-serif',
'font-size': 10,
'font-weight': 600,
fill: '#b1b1be',
});
// Vertical grid line
if (i > 0) {
const line = draw
.line(0, 0, 0, timelineHeight)
.move(left, titleHeight + subtitleHeight)
.stroke({
color: '#dfe5ed',
width: 1,
});
squaresGroup.add(line);
}
}
// Intervals
this.events.forEach(event => {
const leftOffset =
moment
.tz(event.start_at, this.companyData.timezone)
.tz(this.timezone)
.diff(moment.tz(this.start, this.timezone).startOf('day'), 'hours', true) % 24;
const width = ((Math.max(event.duration, 60) + 120) * columnWidth) / 60 / 60;
const rectInterval = draw
.rect(width, 30)
.move(Math.floor(leftOffset * columnWidth), titleHeight + subtitleHeight + 22)
.radius(3)
.attr({
'text-anchor': 'inherit',
'font-family': 'Nunito, sans-serif',
'font-size': 15,
'font-weight': 600,
fill: event.is_manual == '1' ? '#c4b52d' : '#2dc48d',
})
.stroke({
color: 'transparent',
width: 0,
})
.css({
cursor: 'pointer',
'pointer-events': 'auto',
})
.width(width)
.height(30);
rectInterval.on('mouseover', e => {
const popupY =
document.body.getBoundingClientRect().height - rectInterval.rbox().y + defaultCornerOffset;
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
const rectMiddleX = rectInterval.rbox().cx;
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
const left =
rectMiddleX > canvasRight
? canvasRight - defaultCornerOffset / 2
: rectMiddleX < minLeft
? minLeft
: rectMiddleX;
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
const popupX =
left > maxRight ? maxRight : left <= minLeft ? minLeft + defaultCornerOffset : left;
const arrowX = defaultCornerOffset + left - popupX;
this.hoverPopup = {
show: true,
x: popupX,
y: popupY,
event,
borderX: arrowX,
};
});
rectInterval.on('mouseout', e => {
this.hoverPopup = {
...this.hoverPopup,
show: false,
};
});
rectInterval.on('mousedown', e => {
this.$emit('selectedIntervals', event);
const { left: canvasLeft, right: canvasRight } = this.$refs.canvas.getBoundingClientRect();
const rectBox = rectInterval.rbox();
const popupY =
document.body.getBoundingClientRect().height - rectInterval.rbox().y + defaultCornerOffset;
const rectMiddleX = rectBox.cx;
// Determine initial left position within canvas bounds
const left =
rectMiddleX > canvasRight
? canvasRight - defaultCornerOffset / 2
: Math.max(rectMiddleX, canvasLeft);
// Calculate maximum allowed position for popup's left
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
const popupX = left > maxRight ? maxRight : Math.max(left, canvasLeft + defaultCornerOffset);
// Calculate the position for the arrow in the popup
const arrowX = defaultCornerOffset + left - popupX;
this.clickPopup = {
show: true,
x: popupX,
y: popupY,
event,
borderX: arrowX,
};
e.stopPropagation();
});
draw.add(rectInterval);
});
},
onClick(e) {
if (
(e.target &&
e.target.parentElement &&
!e.target.parentElement.classList.contains(this.draw.node.classList) &&
!e.target.closest('.time-interval-edit-panel') &&
!e.target.closest('.screenshot') &&
!e.target.closest('.modal') &&
!e.target.closest('.at-modal') &&
!e.target.closest('.popup')) ||
(e.target.closest('.time-interval-edit-panel') &&
e.target.closest('.time-interval-edit-panel__btn') &&
e.target.closest('.at-btn--error')) ||
(e.target.closest('.modal') && e.target.closest('.modal-remove'))
) {
if (this.clickPopup.show) {
this.clickPopup.show = false;
}
}
},
onResize: function () {
this.drawGrid();
},
async onRemove() {
try {
await this.intervalService.deleteItem(this.modal.interval.id);
this.$Notify({
type: 'success',
title: this.$t('notification.screenshot.delete.success.title'),
message: this.$t('notification.screenshot.delete.success.message'),
});
this.onHide();
this.$emit('selectedIntervals', null);
this.$emit('remove', [this.modal.interval]);
} catch (e) {
this.$Notify({
type: 'error',
title: this.$t('notification.screenshot.delete.error.title'),
message: this.$t('notification.screenshot.delete.error.message'),
});
}
},
},
watch: {
events() {
this.drawGrid();
},
},
};
</script>
<style lang="scss" scoped>
.popup {
background: #ffffff;
border: 0;
border-radius: 20px;
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
display: block;
padding: 10px;
position: absolute;
text-align: center;
width: 270px;
z-index: 3;
& .corner {
border-left: 15px solid transparent;
border-right: 15px solid transparent;
border-top: 10px solid #ffffff;
bottom: -10px;
content: ' ';
display: block;
height: 0;
left: 15px;
position: absolute;
width: 0;
z-index: 1;
}
}
.canvas {
position: relative;
user-select: none;
touch-action: pan-y;
cursor: move;
&::v-deep canvas {
box-sizing: content-box;
}
}
.scrollbar-top {
position: absolute;
left: 0;
top: -1rem;
width: 100%;
height: 10px;
overflow-x: auto;
}
.scrollbar-top {
& > div {
height: 1px;
}
&::-webkit-scrollbar {
height: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-button {
display: none;
}
&::-webkit-scrollbar-thumb {
background: #2e2ef9;
border-radius: 3px;
}
}
@media (max-width: 1110px) {
.scrollbar-top {
top: -0.5rem;
}
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<div class="screenshots">
<h3 class="screenshots__title">{{ $t('field.screenshots') }}</h3>
<at-checkbox-group v-model="selectedIntervals">
<div class="row">
<div
v-for="(interval, index) in intervals[this.user.id]"
:key="interval.id"
class="col-xs-8 col-4 col-xl-3 screenshots__item"
>
<div class="screenshot" :index="index" @click.shift.prevent.stop="onShiftClick(index)">
<Screenshot
:disableModal="true"
:project="{ id: interval.project_id, name: interval.project_name }"
:interval="interval"
:task="interval.task"
:user="user"
:timezone="timezone"
@click="showPopup(interval, $event)"
/>
<div @click="onCheckboxClick(index)">
<at-checkbox class="screenshot__checkbox" :label="interval.id" />
</div>
</div>
</div>
<ScreenshotModal
:project="modal.project"
:interval="modal.interval"
:show="modal.show"
:showNavigation="true"
:task="modal.task"
:user="modal.user"
@close="onHide"
@remove="onRemove"
@showNext="showNext"
@showPrevious="showPrevious"
/>
</div>
</at-checkbox-group>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Screenshot from '@/components/Screenshot';
import ScreenshotModal from '@/components/ScreenshotModal';
import TimeIntervalService from '@/services/resource/time-interval.service';
export default {
name: 'TimelineScreenshots',
components: {
Screenshot,
ScreenshotModal,
},
data() {
return {
intervalsService: new TimeIntervalService(),
selectedIntervals: [],
modal: {
interval: null,
project: null,
task: null,
show: false,
user: null,
},
firstSelectedCheckboxIndex: null,
};
},
computed: {
...mapGetters('dashboard', ['tasks', 'intervals', 'timezone']),
...mapGetters('user', ['user']),
projects() {
return Object.keys(this.tasks)
.map(taskID => this.tasks[taskID])
.reduce((projects, task) => ({ ...projects, [task.project_id]: task.project }), {});
},
},
mounted() {
window.addEventListener('keydown', this.onKeyDown);
},
beforeDestroy() {
window.removeEventListener('keydown', this.onKeyDown);
},
methods: {
onShiftClick(index) {
if (this.firstSelectedCheckboxIndex === null) {
this.firstSelectedCheckboxIndex = index;
}
this.selectedIntervals = this.intervals[this.user.id]
.slice(
Math.min(index, this.firstSelectedCheckboxIndex),
Math.max(index, this.firstSelectedCheckboxIndex) + 1,
)
.map(i => i.id);
},
onCheckboxClick(index) {
if (this.firstSelectedCheckboxIndex === null) {
this.firstSelectedCheckboxIndex = index;
}
},
onKeyDown(e) {
if (e.key === 'ArrowLeft') {
e.preventDefault();
this.showPrevious();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
this.showNext();
}
},
showPopup(interval, e) {
if (e.shiftKey) {
return;
}
if (typeof interval !== 'object' || interval.id === null) {
return;
}
this.modal = {
show: true,
project: { id: interval.project_id, name: interval.project_name },
user: interval,
task: { id: interval.task_id, task_name: interval.task_name },
interval,
};
},
onHide() {
this.modal.show = false;
},
showPrevious() {
const intervals = this.intervals[this.modal.user.user_id];
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
if (currentIndex > 0) {
const interval = intervals[currentIndex - 1];
if (interval) {
this.modal.interval = interval;
this.modal.user = interval;
this.modal.project = { id: interval.project_id, name: interval.project_name };
this.modal.task = { id: interval.task_id, name: interval.task_name };
}
}
},
showNext() {
const intervals = this.intervals[this.modal.user.user_id];
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
if (currentIndex < intervals.length - 1) {
const interval = intervals[currentIndex + 1];
if (interval) {
this.modal.interval = interval;
this.modal.user = interval;
this.modal.project = { id: interval.project_id, name: interval.project_name };
this.modal.task = { id: interval.task_id, name: interval.task_name };
}
}
},
async onRemove(intervalID) {
try {
await this.intervalsService.deleteItem(intervalID);
this.$emit('on-remove', [this.modal.interval]);
this.$Notify({
type: 'success',
title: this.$t('notification.screenshot.delete.success.title'),
message: this.$t('notification.screenshot.delete.success.message'),
});
this.modal.show = false;
} catch (e) {
this.$Notify({
type: 'error',
title: this.$t('notification.screenshot.delete.error.title'),
message: this.$t('notification.screenshot.delete.error.message'),
});
}
},
clearSelectedIntervals() {
this.selectedIntervals = [];
},
},
watch: {
selectedIntervals(intervalIds) {
if (intervalIds.length === 0) {
this.firstSelectedCheckboxIndex = null;
}
this.$emit(
'onSelectedIntervals',
this.intervals[this.user.id].filter(i => intervalIds.indexOf(i.id) !== -1),
);
},
},
};
</script>
<style lang="scss" scoped>
.screenshots {
&__title {
color: #b1b1be;
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
margin-top: 37px;
}
&__item {
margin-bottom: $layout-01;
}
}
.screenshot {
position: relative;
margin-bottom: $layout-01;
&__checkbox {
left: -5px;
position: absolute;
top: -5px;
z-index: 0;
}
&::v-deep {
.screenshot__image {
img {
height: 100px;
}
}
}
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<div>
<div class="total-time">
<h5>{{ $t('dashboard.total_time') }}:</h5>
<h5>
<Skeleton :loading="isDataLoading" width="50px">{{ totalTime }} </Skeleton>
</h5>
</div>
<div v-for="project in userProjects" :key="project.id" class="project">
<div class="project__header">
<Skeleton :loading="isDataLoading" width="100%" height="15px">
<div class="project__title">
<span class="project__name" :title="project.name">
<router-link class="task__title-link" :to="`/projects/view/${project.id}`">
{{ project.name }}
</router-link>
</span>
<span class="project__duration">
{{ formatDurationString(project.durationAtSelectedPeriod) }}
</span>
</div>
<!-- /.project-title -->
</Skeleton>
</div>
<!-- /.project-header -->
<ul class="task-list">
<li
v-for="task in getVisibleTasks(project.id)"
:key="task.id"
class="task"
:class="{ 'task-active': activeTask === task.id }"
>
<Skeleton :loading="isDataLoading" width="100%" height="15px">
<h3 class="task__title" :title="task.name">
<router-link class="task__title-link" :to="`/tasks/view/${task.id}`">
{{ task.name }}
</router-link>
</h3>
<div class="task__progress">
<at-progress
class="task__progressbar"
status="success"
:stroke-width="5"
:percent="getPercentForTaskInProject(task, project)"
></at-progress>
<span class="task__duration">
{{ formatDurationString(task.durationAtSelectedPeriod) }}
</span>
</div>
</Skeleton>
</li>
</ul>
<template v-if="getAllTasks(project.id).length > 3">
<at-button
v-if="!isExpanded(project.id)"
class="project__expand"
type="text"
@click.prevent="expand(project.id)"
>
{{ $t('projects.show-more') }}
</at-button>
<at-button v-else class="project__shrink" type="text" @click.prevent="shrink(project.id)">
{{ $t('projects.show-less') }}
</at-button>
</template>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { formatDurationString } from '@/utils/time';
import { Skeleton } from 'vue-loading-skeleton';
export default {
name: 'TimelineSidebar',
components: {
Skeleton,
},
props: {
activeTask: {
type: Number,
},
isDataLoading: {
type: Boolean,
default: false,
},
startDate: {
type: String,
},
endDate: {
type: String,
},
},
data() {
return {
expandedProjects: [],
};
},
computed: {
...mapGetters('dashboard', ['timePerProject']),
...mapGetters('user', ['user']),
userProjects() {
if (!this.user || !this.user.id) {
return [];
}
if (!this.timePerProject[this.user.id]) {
return [];
}
return Object.values(this.timePerProject[this.user.id]);
},
totalTime() {
const sum = (totalTime, project) => (totalTime += project.durationAtSelectedPeriod);
return formatDurationString(this.userProjects.reduce(sum, 0));
},
},
methods: {
isExpanded(projectID) {
return this.expandedProjects.indexOf(+projectID) !== -1;
},
expand(projectID) {
this.expandedProjects.push(+projectID);
},
shrink(projectID) {
this.expandedProjects = this.expandedProjects.filter(proj => +proj !== +projectID);
},
getAllTasks(projectID) {
return Object.values(this.timePerProject[this.user.id][projectID].tasks);
},
getVisibleTasks(projectID) {
const tasks = this.getAllTasks(projectID);
return this.isExpanded(projectID) ? tasks : tasks.slice(0, 3);
},
getPercentForTaskInProject(task, project) {
return (100 * task.durationAtSelectedPeriod) / project.durationAtSelectedPeriod;
},
formatDurationString,
},
};
</script>
<style lang="scss" scoped>
.total-time {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
margin-bottom: $spacing-05;
}
.project {
&__header {
padding: 0 20px;
margin-bottom: 5px;
}
&__title {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: baseline;
color: #151941;
font-size: 20px;
font-weight: bold;
white-space: nowrap;
}
&__name {
overflow: hidden;
text-overflow: ellipsis;
}
&__duration {
float: right;
margin-left: 0.5em;
font-size: 15px;
}
&__expand,
&__shrink {
display: block;
color: #b1b1be;
padding: 0;
margin: 5px 0 0 20px;
&::v-deep .at-btn__text {
font-size: 14px;
}
}
&:not(:last-child) {
margin-bottom: 35px;
}
}
.task-list {
list-style: none;
}
.task {
color: #b1b1be;
padding: 5px 20px;
&::v-deep {
.at-progress-bar {
padding-right: 0;
}
.at-progress-bar__wraper {
background: #e0dfed;
}
.at-progress--success .at-progress-bar__inner {
background: #2dc38d;
}
.at-progress__text {
display: none;
}
}
&__title {
color: inherit;
white-space: nowrap;
overflow: hidden;
font-size: 15px;
font-weight: 600;
text-overflow: ellipsis;
}
&__title-link {
color: inherit;
}
&__active {
background: #f4f4ff;
color: #151941;
border-left: 3px solid #2e2ef9;
&::v-deep {
.at-progress-bar__wraper {
background: #b1b1be;
}
}
}
&__progress {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
&__progressbar {
flex: 1;
}
&__duration {
margin-left: 1em;
color: #59566e;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
}
</style>

View File

@@ -0,0 +1,24 @@
{
"navigation": {
"dashboard": "Dashboard"
},
"dashboard": {
"timeline": "Timeline",
"team": "Team",
"user": "User",
"worked": "Worked",
"total_time": "Total time"
},
"field": {
"task_name": "Name",
"task_description": "Description"
},
"control": {
"add_new_task": "Add new task",
"edit_intervals": "Change task"
},
"projects": {
"show-more": "Show more",
"show-less": "Show less"
}
}

View File

@@ -0,0 +1,24 @@
{
"navigation": {
"dashboard": "Обзор"
},
"dashboard": {
"timeline": "Личный",
"team": "Командный",
"user": "Пользователь",
"worked": "Отработано",
"total_time": "Общее время"
},
"field": {
"task_name": "Название",
"task_description": "Описание"
},
"control": {
"add_new_task": "Добавить новую задачу",
"edit_intervals": "Изменить задачу"
},
"projects": {
"show-more": "Показать ещё",
"show-less": "Показать меньше"
}
}

View File

@@ -0,0 +1,70 @@
import Vue from 'vue';
import storeModule from './storeModule';
import './policies';
export const ModuleConfig = {
routerPrefix: 'dashboard',
loadOrder: 20,
moduleName: 'Dashboard',
};
export function init(context) {
context.addRoute({
path: '/dashboard',
alias: '/',
name: 'dashboard',
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard.vue'),
meta: {
auth: true,
},
children: [
{
path: '',
beforeEnter: (to, from, next) => {
if (
Vue.prototype.$can('viewTeamTab', 'dashboard') &&
(!localStorage.getItem('dashboard.tab') || localStorage.getItem('dashboard.tab') === 'team')
) {
return next({ name: 'dashboard.team' });
}
return next({ name: 'dashboard.timeline' });
},
},
{
path: 'timeline',
alias: '/timeline',
name: 'dashboard.timeline',
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard/Timeline.vue'),
meta: {
auth: true,
},
},
{
path: 'team',
alias: '/team',
name: 'dashboard.team',
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard/Team.vue'),
meta: {
checkPermission: () => Vue.prototype.$can('viewTeamTab', 'dashboard'),
},
},
],
});
context.addNavbarEntry({
label: 'navigation.dashboard',
to: {
path: '/dashboard',
},
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
context.registerVuexModule(storeModule);
return context;
}

View File

@@ -0,0 +1,11 @@
import { hasRole } from '@/utils/user';
export default class DashboardPolicy {
static viewTeamTab(user) {
return user.can_view_team_tab;
}
static viewManualTime(user) {
return hasRole(user, 'admin') || !!user.manual_time;
}
}

View File

@@ -0,0 +1,6 @@
import { store } from '@/store';
import DashboardPolicy from './dashboard.policy';
store.dispatch('policies/registerPolicies', {
dashboard: DashboardPolicy,
});

View File

@@ -0,0 +1,164 @@
import axios from 'axios';
import ReportService from '@/services/report.service';
import moment from 'moment';
export default class DashboardService extends ReportService {
constructor(context, taskService, userService) {
super();
this.context = context;
this.taskService = taskService;
this.userService = userService;
}
downloadReport(startAt, endAt, users, projects, userTimezone, format, sortCol, sortDir) {
return axios.post(
'report/dashboard/download',
{
start_at: startAt,
end_at: endAt,
users,
projects,
user_timezone: userTimezone,
sort_column: sortCol,
sort_direction: sortDir,
},
{
headers: { Accept: format },
},
);
}
getReport(startAt, endAt, users, projects, userTimezone) {
return axios.post('report/dashboard', {
users,
projects,
start_at: startAt,
end_at: endAt,
user_timezone: userTimezone,
});
}
unloadIntervals() {
this.context.commit('setIntervals', []);
}
load(userIDs, projectIDs, startAt, endAt, userTimezone) {
this.getReport(startAt, endAt, userIDs, projectIDs, userTimezone)
.then(response => {
if (!response) {
return;
}
const data = response.data.data;
this.context.commit('setIntervals', data);
if (!data) {
return;
}
const uniqueProjectIDs = new Set();
const uniqueTaskIDs = new Set();
Object.keys(data).forEach(userID => {
const userIntervals = data[userID];
userIntervals.forEach(interval => {
uniqueProjectIDs.add(interval.project_id);
uniqueTaskIDs.add(interval.task_id);
});
});
const promises = [];
const taskIDs = [...uniqueTaskIDs];
if (taskIDs.length) {
promises.push(this.loadTasks(taskIDs));
}
return Promise.all(promises);
})
.then(() => {
return this.loadUsers();
})
.then(() =>
this.context.commit(
'setUsers',
this.context.state.users.map(u => {
if (Object.prototype.hasOwnProperty.call(this.context.state.intervals, u.id)) {
const lastInterval = this.context.state.intervals[u.id].slice(-1)[0];
if (
Math.abs(
moment(lastInterval.end_at).diff(
moment().subtract(u.screenshot_interval || 1, 'minutes'),
'seconds',
),
) < 10
) {
return {
...u,
last_interval: lastInterval,
};
}
}
return { ...u, last_interval: null };
}),
),
)
.catch(e => {
if (!axios.isCancel(e)) {
throw e;
}
});
}
loadUsers() {
return this.userService
.getAll({ headers: { 'X-Paginate': 'false' } })
.then(response => {
this.context.commit('setUsers', response);
return response;
})
.catch(e => {
if (!axios.isCancel(e)) {
throw e;
}
});
}
/**
* @returns {Promise<AxiosResponse<T>>}
* @param taskIDs
* @param action
*/
loadTasks(taskIDs) {
return this.taskService
.getWithFilters({
id: ['=', taskIDs],
with: 'project',
})
.then(response => {
if (typeof response !== 'undefined') {
const { data } = response;
const tasks = data.data.reduce((tasks, task) => {
tasks[task.id] = task;
return tasks;
}, {});
this.context.commit('setTasks', tasks);
return tasks;
}
})
.catch(e => {
if (!axios.isCancel(e)) {
throw e;
}
});
}
sendInvites(emails) {
return axios.post(`register/create`, emails);
}
}

View File

@@ -0,0 +1,153 @@
import moment from 'moment-timezone';
import TasksService from '@/services/resource/task.service';
import UserService from '@/services/resource/user.service';
import DashboardService from '_internal/Dashboard/services/dashboard.service';
import _ from 'lodash';
import Vue from 'vue';
const state = {
service: null,
intervals: {},
tasks: {},
users: [],
timezone: moment.tz.guess(),
};
const getters = {
service: state => state.service,
intervals: state => state.intervals,
tasks: state => state.tasks,
users: state => state.users,
timePerProject: (state, getters) => {
return Object.keys(getters.intervals).reduce((result, userID) => {
const userEvents = getters.intervals[userID];
if (!userEvents) {
return result;
}
const projects = userEvents.reduce((projects, event) => {
if (!projects[event.project_id]) {
projects[event.project_id] = {
id: event.project_id,
name: event.project_name,
duration: event.duration,
tasks: {},
durationAtSelectedPeriod: event.durationAtSelectedPeriod,
};
} else {
projects[event.project_id].duration += event.duration;
projects[event.project_id].durationAtSelectedPeriod += event.durationAtSelectedPeriod;
}
if (!projects[event.project_id].tasks[event.task_id]) {
projects[event.project_id].tasks[event.task_id] = {
id: event.task_id,
name: event.task_name,
duration: event.duration,
durationAtSelectedPeriod: event.durationAtSelectedPeriod,
};
} else {
projects[event.project_id].tasks[event.task_id].duration += event.duration;
projects[event.project_id].tasks[event.task_id].durationAtSelectedPeriod +=
event.durationAtSelectedPeriod;
}
return projects;
}, {});
return {
...result,
[userID]: projects,
};
}, {});
},
timePerDay: (state, getters) => {
return Object.keys(getters.intervals).reduce((result, userID) => {
const userEvents = getters.intervals[userID];
if (!userEvents) {
return result;
}
const userTimePerDay = userEvents.reduce((result, event) => {
return _.mergeWith({}, result, event.durationByDay, _.add);
}, {});
return {
...result,
[userID]: userTimePerDay,
};
}, {});
},
timezone: state => state.timezone,
};
const mutations = {
setService(state, service) {
state.service = service;
},
setIntervals(state, intervals) {
state.intervals = intervals;
},
addInterval(state, interval) {
if (Array.isArray(state.intervals)) {
state.intervals = {};
}
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
Vue.set(state.intervals, interval.user_id, []);
}
state.intervals[interval.user_id].push(interval);
},
updateInterval(state, interval) {
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
return;
}
const index = state.intervals[interval.user_id].findIndex(item => +item.id === +interval.id);
if (index !== -1) {
state.intervals[interval.user_id].splice(index, 1, interval);
}
},
removeInterval(state, interval) {
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
return;
}
const index = state.intervals[interval.user_id].findIndex(item => +item.id === +interval.id);
if (index !== -1) {
state.intervals[interval.user_id].splice(index, 1);
}
},
removeIntervalById(state, id) {
for (const userId in state.intervals) {
const index = state.intervals[userId].findIndex(item => +item.id === +id);
if (index !== -1) {
state.intervals[userId].splice(index, 1);
break;
}
}
},
setTasks(state, tasks) {
state.tasks = tasks;
},
setUsers(state, users) {
state.users = users;
},
setTimezone(state, timezone) {
state.timezone = timezone;
},
};
const actions = {
init(context) {
context.commit('setService', new DashboardService(context, new TasksService(), new UserService()));
},
};
export default {
state,
getters,
mutations,
actions,
};

View File

@@ -0,0 +1,47 @@
<template>
<div class="dashboard">
<div class="dashboard__routes">
<h1 class="dashboard__link">
<router-link :to="{ name: 'dashboard.timeline' }">{{ $t('dashboard.timeline') }}</router-link>
</h1>
<h1 v-if="$can('viewTeamTab', 'dashboard')" class="dashboard__link">
<router-link :to="{ name: 'dashboard.team' }">{{ $t('dashboard.team') }}</router-link>
</h1>
</div>
<div class="dashboard__content-wrapper">
<router-view :key="$route.fullPath" />
</div>
</div>
</template>
<script>
export default {
name: 'Index',
};
</script>
<style lang="scss" scoped>
.dashboard {
&__routes {
margin-bottom: 1em;
display: flex;
}
&__link {
margin-right: $layout-03;
font-size: 1.8rem;
&:last-child {
margin-right: initial;
}
a {
color: #b1b1be;
}
.router-link-active {
color: #2e2ef9;
}
}
}
</style>

View File

@@ -0,0 +1,454 @@
<template>
<div class="team">
<div class="controls-row flex-between">
<div class="flex">
<Calendar
:sessionStorageKey="sessionStorageKey"
class="controls-row__item"
@change="onCalendarChange"
/>
<UserSelect class="controls-row__item" @change="onUsersChange" />
<ProjectSelect class="controls-row__item" @change="onProjectsChange" />
<TimezonePicker :value="timezone" class="controls-row__item" @onTimezoneChange="onTimezoneChange" />
</div>
<div class="flex">
<router-link
v-if="$can('viewManualTime', 'dashboard')"
class="controls-row__item"
to="/time-intervals/new"
>
<at-button class="controls-row__btn" icon="icon-edit">{{ $t('control.add_time') }}</at-button>
</router-link>
<ExportDropdown
class="export controls-row__item controls-row__btn"
position="left"
trigger="hover"
@export="onExport"
/>
</div>
</div>
<div class="at-container">
<div class="at-container__inner">
<div class="row">
<div class="col-8 col-lg-6">
<TeamSidebar
:sort="sort"
:sortDir="sortDir"
:users="graphUsers"
class="sidebar"
@sort="onSort"
/>
</div>
<div class="col-16 col-lg-18">
<TeamDayGraph
v-if="type === 'day'"
:users="graphUsers"
:start="start"
class="graph"
@selectedIntervals="onSelectedIntervals"
/>
<TeamTableGraph
v-else
:end="end"
:start="start"
:timePerDay="timePerDay"
:users="graphUsers"
class="graph"
/>
</div>
<TimeIntervalEdit
v-if="selectedIntervals.length"
:intervals="selectedIntervals"
@close="clearIntervals"
@edit="load"
@remove="onBulkRemove"
/>
</div>
<preloader v-if="isDataLoading" :is-transparent="true" class="team__loader" />
</div>
</div>
</div>
</template>
<script>
import Calendar from '@/components/Calendar';
import ExportDropdown from '@/components/ExportDropdown';
import Preloader from '@/components/Preloader';
import ProjectSelect from '@/components/ProjectSelect';
import TimezonePicker from '@/components/TimezonePicker';
import UserSelect from '@/components/UserSelect';
import ProjectService from '@/services/resource/project.service';
import { getDateToday, getEndOfDayInTimezone, getStartOfDayInTimezone } from '@/utils/time';
import DashboardReportService from '_internal/Dashboard/services/dashboard.service';
import cloneDeep from 'lodash/cloneDeep';
import throttle from 'lodash/throttle';
import moment from 'moment';
import { mapGetters, mapMutations } from 'vuex';
import TeamDayGraph from '../../components/TeamDayGraph';
import TeamSidebar from '../../components/TeamSidebar';
import TeamTableGraph from '../../components/TeamTableGraph';
import TimeIntervalEdit from '../../components/TimeIntervalEdit';
const updateInterval = 60 * 1000;
export default {
name: 'Team',
components: {
Calendar,
UserSelect,
ProjectSelect,
TeamSidebar,
TeamDayGraph,
TeamTableGraph,
TimezonePicker,
ExportDropdown,
TimeIntervalEdit,
Preloader,
},
data() {
const today = this.getDateToday();
const sessionStorageKey = 'amazingcat.session.storage.team';
return {
type: 'day',
start: today,
end: today,
userIDs: [],
projectIDs: [],
sort: localStorage.getItem('team.sort') || 'user',
sortDir: localStorage.getItem('team.sort-dir') || 'asc',
projectService: new ProjectService(),
reportService: new DashboardReportService(),
showExportModal: false,
selectedIntervals: [],
sessionStorageKey: sessionStorageKey,
isDataLoading: false,
};
},
async created() {
localStorage['dashboard.tab'] = 'team';
await this.load();
this.updateHandle = setInterval(() => {
if (!this.updatedWithWebsockets) {
this.load(false);
}
this.updatedWithWebsockets = false;
}, updateInterval);
this.updatedWithWebsockets = false;
},
beforeDestroy() {
clearInterval(this.updateHandle);
this.service.unloadIntervals();
},
computed: {
...mapGetters('dashboard', ['intervals', 'timePerDay', 'users', 'timezone', 'service']),
...mapGetters('user', ['user']),
graphUsers() {
return this.users
.filter(user => this.userIDs.includes(user.id))
.map(user => ({ ...user, worked: this.getWorked(user.id) }))
.sort((a, b) => {
let order = 0;
if (this.sort === 'user') {
const aName = a.full_name.toUpperCase();
const bName = b.full_name.toUpperCase();
order = aName.localeCompare(bName);
} else if (this.sort === 'worked') {
const aWorked = a.worked || 0;
const bWorked = b.worked || 0;
order = aWorked - bWorked;
}
return this.sortDir === 'asc' ? order : -order;
});
},
},
methods: {
getDateToday,
getStartOfDayInTimezone,
getEndOfDayInTimezone,
...mapMutations({
setTimezone: 'dashboard/setTimezone',
removeInterval: 'dashboard/removeInterval',
addInterval: 'dashboard/addInterval',
updateInterval: 'dashboard/updateInterval',
removeIntervalById: 'dashboard/removeIntervalById',
}),
load: throttle(async function (withLoadingIndicator = true) {
this.isDataLoading = withLoadingIndicator;
if (!this.userIDs.length || !this.projectIDs.length) {
this.isDataLoading = false;
return;
}
const startAt = this.getStartOfDayInTimezone(this.start, this.timezone);
const endAt = this.getEndOfDayInTimezone(this.end, this.timezone);
await this.service.load(this.userIDs, this.projectIDs, startAt, endAt, this.timezone);
this.isDataLoading = false;
}, 1000),
onCalendarChange({ type, start, end }) {
this.type = type;
this.start = start;
this.end = end;
this.service.unloadIntervals();
this.load();
},
onUsersChange(userIDs) {
this.userIDs = [...userIDs];
this.load();
},
onProjectsChange(projectIDs) {
this.projectIDs = [...projectIDs];
this.load();
},
onTimezoneChange(timezone) {
this.setTimezone(timezone);
},
onSort(column) {
if (column === this.sort) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sort = column;
// Sort users ascending and time descending by default
this.sortDir = column === 'user' ? 'asc' : 'desc';
}
localStorage['team.sort'] = this.sort;
localStorage['team.sort-dir'] = this.sortDir;
},
async onExport(format) {
const { data } = await this.reportService.downloadReport(
this.getStartOfDayInTimezone(this.start, this.timezone),
this.getEndOfDayInTimezone(this.end, this.timezone),
this.userIDs,
this.projectIDs,
this.timezone,
format,
this.sort,
this.sortDir,
);
window.open(data.data.url, '_blank');
},
onSelectedIntervals(event) {
this.selectedIntervals = event ? [event] : [];
},
onBulkRemove(intervals) {
const totalIntervals = cloneDeep(this.intervals);
intervals.forEach(interval => {
const userIntervals = cloneDeep(totalIntervals[interval.user_id]).filter(
userInterval => interval.id !== userInterval.id,
);
const deletedDuration = moment(interval.end_at).diff(interval.start_at, 'seconds');
userIntervals.duration -= deletedDuration;
totalIntervals[interval.user_id] = userIntervals;
});
this.$store.commit('dashboard/setIntervals', totalIntervals);
this.clearIntervals();
},
clearIntervals() {
this.selectedIntervals = [];
},
// for send invites to new users
async getModalInvite() {
let modal;
try {
modal = await this.$Modal.prompt({
title: this.$t('invite.label'),
content: this.$t('invite.content'),
});
} catch {
return;
}
if (!modal.value) {
this.$Message.error(this.$t('invite.message.error'));
return;
}
const emails = modal.value.split(',');
// eslint-disable-next-line no-useless-escape
const regex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
const validation = {
isError: false,
emails: [],
};
for (let i = 0; i < emails.length; i++) {
let email = emails[i].replace(' ', '');
if (regex.exec(email) == null) {
validation.isError = true;
validation.emails.push(email);
}
}
if (!validation.isError) {
this.reportService.sendInvites({ emails }).then(({ data }) => {
this.$Message.success('Success');
});
} else {
this.$Message.error(this.$t('invite.message.valid') + validation.emails);
}
},
getWorked(userId) {
return Object.prototype.hasOwnProperty.call(this.intervals, userId)
? this.intervals[userId].reduce((acc, el) => acc + el.durationAtSelectedPeriod, 0)
: 0;
},
},
mounted() {
const channel = this.$echo.private(`intervals.${this.user.id}`);
channel.listen(`.intervals.create`, data => {
const startAt = moment.tz(data.model.start_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
const endAt = moment.tz(data.model.end_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
if (startAt > this.end || endAt < this.start) {
return;
}
this.addInterval(data.model);
this.updatedWithWebsockets = true;
});
channel.listen(`.intervals.edit`, data => {
this.updateInterval(data.model);
this.updatedWithWebsockets = true;
});
channel.listen(`.intervals.destroy`, data => {
if (typeof data.model === 'number') {
this.removeIntervalById(data.model);
} else {
this.removeInterval(data.model);
}
this.updatedWithWebsockets = true;
});
},
watch: {
timezone() {
this.service.unloadIntervals();
this.load();
},
},
};
</script>
<style lang="scss" scoped>
.at-container {
&__inner {
position: relative;
}
}
.team__loader {
z-index: 0;
border-radius: 20px;
&::v-deep {
align-items: baseline;
.lds-ellipsis {
position: sticky;
top: 25px;
}
}
}
.timeline-type {
margin-left: 10px;
border-radius: 5px;
.at-btn:first-child {
border-radius: 5px 0 0 5px;
}
.at-btn:last-child {
border-radius: 0 5px 5px 0;
}
&-btn {
border: 1px solid #eeeef5;
color: #b1b1be;
font-size: 15px;
font-weight: 500;
height: 40px;
&.active {
color: #ffffff;
background: #2e2ef9;
}
}
}
@media (max-width: 1320px) {
.controls-row {
flex-direction: column;
align-items: start;
padding-right: 1px; // fix horizontal scroll caused by download btn padding
&__item {
margin: 0;
margin-bottom: $spacing-03;
}
.calendar {
&::v-deep .input {
width: unset;
}
}
& > div:first-child {
display: grid;
grid-template-columns: repeat(auto-fit, 250px);
width: 100%;
column-gap: $spacing-03;
}
& > div:last-child {
align-self: flex-end;
column-gap: $spacing-03;
}
}
}
@media (max-width: 720px) {
.at-container {
&__inner {
padding: 1rem;
}
}
}
.export {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
&::v-deep .at-btn__text {
color: #2e2ef9;
font-size: 25px;
}
}
.button-invite {
color: #618fea;
}
</style>

View File

@@ -0,0 +1,422 @@
<template>
<div class="timeline">
<div class="at-container sidebar">
<TimelineSidebar
:active-task="activeTask"
:isDataLoading="isDataLoading"
:startDate="start"
:endDate="end"
/>
</div>
<div class="controls-row flex-between">
<div class="flex">
<Calendar
class="controls-row__item"
:range="false"
:sessionStorageKey="sessionStorageKey"
@change="onCalendarChange"
/>
<TimezonePicker class="controls-row__item" :value="timezone" @onTimezoneChange="onTimezoneChange" />
</div>
<div class="flex">
<router-link
v-if="$can('viewManualTime', 'dashboard')"
to="/time-intervals/new"
class="controls-row__item"
>
<at-button class="controls-row__btn" icon="icon-edit">
{{ $t('control.add_time') }}
</at-button>
</router-link>
<ExportDropdown
class="export-btn dropdown controls-row__btn controls-row__item"
position="left-top"
trigger="hover"
@export="onExport"
/>
</div>
</div>
<div class="at-container intervals">
<TimelineDayGraph
v-if="type === 'day'"
class="graph"
:start="start"
:end="end"
:events="userEvents"
:timezone="timezone"
@selectedIntervals="onIntervalsSelect"
@remove="onBulkRemove"
/>
<TimelineCalendarGraph v-else class="graph" :start="start" :end="end" :timePerDay="userTimePerDay" />
<TimelineScreenshots
v-if="type === 'day' && intervals && Object.keys(intervals).length"
ref="timelineScreenshots"
@on-remove="recalculateStatistic"
@onSelectedIntervals="setSelectedIntervals"
/>
<preloader v-if="isDataLoading" class="timeline__loader" :is-transparent="true" />
<TimeIntervalEdit
:intervals="selectedIntervals"
@remove="onBulkRemove"
@edit="loadData"
@close="clearIntervals"
/>
</div>
</div>
</template>
<script>
import moment from 'moment';
import debounce from 'lodash/debounce';
import { mapGetters, mapMutations } from 'vuex';
import Calendar from '@/components/Calendar';
import TimelineSidebar from '../../components/TimelineSidebar';
import TimelineDayGraph from '../../components/TimelineDayGraph';
import TimelineCalendarGraph from '../../components/TimelineCalendarGraph';
import TimelineScreenshots from '../../components/TimelineScreenshots';
import TimezonePicker from '@/components/TimezonePicker';
import DashboardService from '_internal/Dashboard/services/dashboard.service';
import { getDateToday } from '@/utils/time';
import { getStartOfDayInTimezone, getEndOfDayInTimezone } from '@/utils/time';
import ExportDropdown from '@/components/ExportDropdown';
import cloneDeep from 'lodash/cloneDeep';
import TimeIntervalEdit from '../../components/TimeIntervalEdit';
import Preloader from '@/components/Preloader';
const updateInterval = 60 * 1000;
const dashboardService = new DashboardService();
export default {
name: 'Timeline',
components: {
Calendar,
TimelineSidebar,
TimelineDayGraph,
TimelineCalendarGraph,
TimelineScreenshots,
TimezonePicker,
ExportDropdown,
TimeIntervalEdit,
Preloader,
},
data() {
const today = this.getDateToday();
const sessionStorageKey = 'amazingcat.session.storage.timeline';
return {
type: 'day',
start: today,
end: today,
datepickerDateStart: '',
datepickerDateEnd: '',
activeTask: +localStorage.getItem('timeline.active-task') || 0,
showExportModal: false,
selectedIntervals: [],
sessionStorageKey: sessionStorageKey,
isDataLoading: false,
};
},
created() {
localStorage['dashboard.tab'] = 'timeline';
this.loadData();
this.updateHandle = setInterval(() => {
if (!this.updatedWithWebsockets) {
this.loadData(false);
}
this.updatedWithWebsockets = false;
}, updateInterval);
this.updatedWithWebsockets = false;
},
mounted() {
const channel = this.$echo.private(`intervals.${this.user.id}`);
channel.listen(`.intervals.create`, data => {
const startAt = moment.tz(data.model.start_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
const endAt = moment.tz(data.model.end_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
if (startAt > this.end || endAt < this.start) {
return;
}
this.addInterval(data.model);
this.updatedWithWebsockets = true;
});
channel.listen(`.intervals.edit`, data => {
this.updateInterval(data.model);
this.updatedWithWebsockets = true;
});
channel.listen(`.intervals.destroy`, data => {
if (typeof data.model === 'number') {
this.removeIntervalById(data.model);
} else {
this.removeInterval(data.model);
}
this.updatedWithWebsockets = true;
});
},
beforeDestroy() {
clearInterval(this.updateHandle);
this.service.unloadIntervals();
this.$echo.leave(`intervals.${this.user.id}`);
},
computed: {
...mapGetters('dashboard', ['service', 'intervals', 'timePerDay', 'timePerProject', 'timezone']),
...mapGetters('user', ['user']),
userEvents() {
if (!this.user || !this.user.id || !this.intervals[this.user.id]) {
return [];
}
return this.intervals[this.user.id];
},
userTimePerDay() {
if (!this.user || !this.user.id || !this.timePerDay[this.user.id]) {
return {};
}
return this.timePerDay[this.user.id];
},
},
methods: {
getDateToday,
getStartOfDayInTimezone,
getEndOfDayInTimezone,
...mapMutations({
setTimezone: 'dashboard/setTimezone',
removeInterval: 'dashboard/removeInterval',
addInterval: 'dashboard/addInterval',
updateInterval: 'dashboard/updateInterval',
removeIntervalById: 'dashboard/removeIntervalById',
}),
loadData: debounce(async function (withLoadingIndicator = true) {
this.isDataLoading = withLoadingIndicator;
if (!this.user || !this.user.id) {
this.isDataLoading = false;
return;
}
const userIDs = [this.user.id];
const startAt = this.getStartOfDayInTimezone(this.start, this.timezone);
const endAt = this.getEndOfDayInTimezone(this.end, this.timezone);
await this.service.load(userIDs, null, startAt, endAt, this.timezone);
this.isDataLoading = false;
}, 350),
onCalendarChange({ type, start, end }) {
this.type = type;
this.start = start;
this.end = end;
this.service.unloadIntervals();
this.loadData();
},
onIntervalsSelect(event) {
this.selectedIntervals = event ? [event] : [];
},
async onExport(format) {
const { data } = await dashboardService.downloadReport(
this.getStartOfDayInTimezone(this.start, this.timezone),
this.getEndOfDayInTimezone(this.end, this.timezone),
[this.user.id],
this.projectIDs,
this.timezone,
format,
);
window.open(data.data.url, '_blank');
},
onBulkRemove(intervals) {
const totalIntervals = cloneDeep(this.intervals);
intervals.forEach(interval => {
const userIntervals = cloneDeep(totalIntervals[interval.user_id]).filter(
userInterval => interval.id !== userInterval.id,
);
const deletedDuration = moment(interval.end_at).diff(interval.start_at, 'seconds');
userIntervals.duration -= deletedDuration;
totalIntervals[interval.user_id] = userIntervals;
});
this.$store.commit('dashboard/setIntervals', totalIntervals);
this.clearIntervals();
},
onTimezoneChange(timezone) {
this.setTimezone(timezone);
},
recalculateStatistic(intervals) {
this.onBulkRemove(intervals);
},
setSelectedIntervals(intervalIds) {
this.selectedIntervals = intervalIds;
},
clearIntervals() {
if (this.$refs.timelineScreenshots) {
this.$refs.timelineScreenshots.clearSelectedIntervals();
}
this.selectedIntervals = [];
},
},
watch: {
user() {
this.loadData();
},
timezone() {
this.service.unloadIntervals();
this.loadData();
},
},
};
</script>
<style lang="scss" scoped>
.at-container::v-deep {
.modal-screenshot {
a {
max-height: inherit;
img {
max-height: inherit;
object-fit: fill;
}
}
}
}
.at-container {
padding: 1em;
}
.timeline {
display: grid;
grid-template-columns: 300px 1fr 1fr;
column-gap: 0.5rem;
&__loader {
z-index: 0;
border-radius: 20px;
}
}
.timeline-type {
margin-left: 10px;
border-radius: 5px;
.at-btn:first-child {
border-radius: 5px 0 0 5px;
}
.at-btn:last-child {
border-radius: 0 5px 5px 0;
}
&-btn {
border: 1px solid #eeeef5;
color: #b1b1be;
font-size: 15px;
font-weight: 500;
height: 40px;
&.active {
color: #ffffff;
background: #2e2ef9;
}
}
}
.sidebar {
padding: 30px 0;
grid-column: 1 / 2;
grid-row: 1 / 3;
max-height: fit-content;
margin-bottom: 0.5rem;
}
.controls-row {
z-index: 1;
position: relative;
grid-column: 2 / 4;
padding-right: 1px; // fix horizontal scroll caused by download btn padding
}
.intervals {
grid-column: 2 / 4;
}
@media (max-width: 1300px) {
.timeline {
grid-template-columns: 250px 1fr 1fr;
}
}
@media (max-width: 1110px) {
.canvas {
padding-top: 0.3rem;
}
.at-container {
padding: 0.5rem;
}
.sidebar {
padding: 15px 0;
}
.controls-row {
flex-direction: column;
align-items: start;
//padding-right: 1px; // fix horizontal scroll caused by download btn padding
&__item {
margin: 0;
margin-bottom: $spacing-03;
}
.calendar {
&::v-deep .input {
width: unset;
}
}
& > div:first-child {
display: grid;
grid-template-columns: repeat(auto-fit, 250px);
width: 100%;
column-gap: $spacing-03;
}
& > div:last-child {
align-self: flex-end;
column-gap: $spacing-03;
}
}
}
@media (max-width: 790px) {
.intervals {
grid-column: 1/4;
}
.controls-row {
& > div:last-child {
align-self: start;
}
}
}
@media (max-width: 560px) {
.controls-row {
grid-column: 1/4;
grid-row: 1;
& > div:last-child {
align-self: end;
}
}
.sidebar {
grid-row: 2;
}
}
.graph {
width: 100%;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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