first commit
This commit is contained in:
119
resources/frontend/core/App.vue
Normal file
119
resources/frontend/core/App.vue
Normal 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>
|
||||
9
resources/frontend/core/arch/builder.js
Normal file
9
resources/frontend/core/arch/builder.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export default class Builder {
|
||||
routerConfig = {};
|
||||
|
||||
constructor(moduleContext) {
|
||||
this.moduleContext = moduleContext;
|
||||
this.routerPrefix =
|
||||
(moduleContext.getRouterPrefix().startsWith('/') ? '' : '/') + moduleContext.getRouterPrefix();
|
||||
}
|
||||
}
|
||||
62
resources/frontend/core/arch/builder/crud.js
Normal file
62
resources/frontend/core/arch/builder/crud.js
Normal 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;
|
||||
}
|
||||
}
|
||||
12
resources/frontend/core/arch/builder/crud/abstractCrud.js
Normal file
12
resources/frontend/core/arch/builder/crud/abstractCrud.js
Normal 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);
|
||||
}
|
||||
}
|
||||
64
resources/frontend/core/arch/builder/crud/edit.js
Normal file
64
resources/frontend/core/arch/builder/crud/edit.js
Normal 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`;
|
||||
}
|
||||
}
|
||||
56
resources/frontend/core/arch/builder/crud/new.js
Normal file
56
resources/frontend/core/arch/builder/crud/new.js
Normal 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`;
|
||||
}
|
||||
}
|
||||
53
resources/frontend/core/arch/builder/crud/view.js
Normal file
53
resources/frontend/core/arch/builder/crud/view.js
Normal 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`;
|
||||
}
|
||||
}
|
||||
86
resources/frontend/core/arch/builder/grid.js
Normal file
86
resources/frontend/core/arch/builder/grid.js
Normal 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;
|
||||
}
|
||||
}
|
||||
24
resources/frontend/core/arch/builder/navbar.js
Normal file
24
resources/frontend/core/arch/builder/navbar.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
74
resources/frontend/core/arch/builder/sections.js
Normal file
74
resources/frontend/core/arch/builder/sections.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
331
resources/frontend/core/arch/module.js
Normal file
331
resources/frontend/core/arch/module.js
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
resources/frontend/core/assets/login.svg
LFS
Normal file
BIN
resources/frontend/core/assets/login.svg
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/assets/logo.png
LFS
Normal file
BIN
resources/frontend/core/assets/logo.png
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/assets/logo.svg
LFS
Normal file
BIN
resources/frontend/core/assets/logo.svg
LFS
Normal file
Binary file not shown.
158
resources/frontend/core/components/AppImage.vue
Normal file
158
resources/frontend/core/components/AppImage.vue
Normal 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>
|
||||
37
resources/frontend/core/components/BackButton.vue
Normal file
37
resources/frontend/core/components/BackButton.vue
Normal 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>
|
||||
712
resources/frontend/core/components/Calendar.vue
Normal file
712
resources/frontend/core/components/Calendar.vue
Normal 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>
|
||||
72
resources/frontend/core/components/ColorInput.vue
Normal file
72
resources/frontend/core/components/ColorInput.vue
Normal 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>
|
||||
71
resources/frontend/core/components/ExportDropdown.vue
Normal file
71
resources/frontend/core/components/ExportDropdown.vue
Normal 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>
|
||||
350
resources/frontend/core/components/GroupSelect.vue
Normal file
350
resources/frontend/core/components/GroupSelect.vue
Normal 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>
|
||||
35
resources/frontend/core/components/LanguageSelector.vue
Normal file
35
resources/frontend/core/components/LanguageSelector.vue
Normal 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>
|
||||
65
resources/frontend/core/components/ListBox.vue
Normal file
65
resources/frontend/core/components/ListBox.vue
Normal 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>
|
||||
259
resources/frontend/core/components/MultiSelect.vue
Normal file
259
resources/frontend/core/components/MultiSelect.vue
Normal 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>
|
||||
216
resources/frontend/core/components/Navigation.vue
Normal file
216
resources/frontend/core/components/Navigation.vue
Normal 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>
|
||||
66
resources/frontend/core/components/NavigationMenuItem.vue
Normal file
66
resources/frontend/core/components/NavigationMenuItem.vue
Normal 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>
|
||||
109
resources/frontend/core/components/Preloader.vue
Normal file
109
resources/frontend/core/components/Preloader.vue
Normal 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>
|
||||
123
resources/frontend/core/components/PrioritySelect.vue
Normal file
123
resources/frontend/core/components/PrioritySelect.vue
Normal 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>
|
||||
78
resources/frontend/core/components/ProjectSelect.vue
Normal file
78
resources/frontend/core/components/ProjectSelect.vue
Normal 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>
|
||||
62
resources/frontend/core/components/RenderableField.vue
Normal file
62
resources/frontend/core/components/RenderableField.vue
Normal 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>
|
||||
91
resources/frontend/core/components/ResourceSelect.vue
Normal file
91
resources/frontend/core/components/ResourceSelect.vue
Normal 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>
|
||||
73
resources/frontend/core/components/RoleSelect.vue
Normal file
73
resources/frontend/core/components/RoleSelect.vue
Normal 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>
|
||||
234
resources/frontend/core/components/Screenshot.vue
Normal file
234
resources/frontend/core/components/Screenshot.vue
Normal 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>
|
||||
308
resources/frontend/core/components/ScreenshotModal.vue
Normal file
308
resources/frontend/core/components/ScreenshotModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
83
resources/frontend/core/components/StatusSelect.vue
Normal file
83
resources/frontend/core/components/StatusSelect.vue
Normal 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>
|
||||
166
resources/frontend/core/components/StorageManagementTab.vue
Normal file
166
resources/frontend/core/components/StorageManagementTab.vue
Normal 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>
|
||||
80
resources/frontend/core/components/TeamAvatars.vue
Normal file
80
resources/frontend/core/components/TeamAvatars.vue
Normal 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>
|
||||
146
resources/frontend/core/components/TimezonePicker.vue
Normal file
146
resources/frontend/core/components/TimezonePicker.vue
Normal 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>
|
||||
89
resources/frontend/core/components/UserAvatar.vue
Normal file
89
resources/frontend/core/components/UserAvatar.vue
Normal 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>
|
||||
462
resources/frontend/core/components/UserSelect.vue
Normal file
462
resources/frontend/core/components/UserSelect.vue
Normal 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>
|
||||
38
resources/frontend/core/components/VueMarkdown.vue
Normal file
38
resources/frontend/core/components/VueMarkdown.vue
Normal 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>
|
||||
@@ -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>
|
||||
226
resources/frontend/core/components/global/CustomModal/dialog.js
Normal file
226
resources/frontend/core/components/global/CustomModal/dialog.js
Normal 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;
|
||||
11
resources/frontend/core/config/app.js
Normal file
11
resources/frontend/core/config/app.js
Normal 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;
|
||||
BIN
resources/frontend/core/fonts/aticon.eot
LFS
Normal file
BIN
resources/frontend/core/fonts/aticon.eot
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/fonts/aticon.svg
LFS
Normal file
BIN
resources/frontend/core/fonts/aticon.svg
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/fonts/aticon.ttf
LFS
Normal file
BIN
resources/frontend/core/fonts/aticon.ttf
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/fonts/aticon.woff
LFS
Normal file
BIN
resources/frontend/core/fonts/aticon.woff
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/fonts/feather.eot
LFS
Normal file
BIN
resources/frontend/core/fonts/feather.eot
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/fonts/feather.svg
LFS
Normal file
BIN
resources/frontend/core/fonts/feather.svg
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/fonts/feather.ttf
LFS
Normal file
BIN
resources/frontend/core/fonts/feather.ttf
LFS
Normal file
Binary file not shown.
BIN
resources/frontend/core/fonts/feather.woff
LFS
Normal file
BIN
resources/frontend/core/fonts/feather.woff
LFS
Normal file
Binary file not shown.
44
resources/frontend/core/global-extension.js
Normal file
44
resources/frontend/core/global-extension.js
Normal 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;
|
||||
28
resources/frontend/core/helpers/common.js
Normal file
28
resources/frontend/core/helpers/common.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
117
resources/frontend/core/helpers/httpInterceptor.js
Normal file
117
resources/frontend/core/helpers/httpInterceptor.js
Normal 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);
|
||||
},
|
||||
};
|
||||
9
resources/frontend/core/helpers/sloganGenerator.js
Normal file
9
resources/frontend/core/helpers/sloganGenerator.js
Normal 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)];
|
||||
};
|
||||
74
resources/frontend/core/i18n/index.js
Normal file
74
resources/frontend/core/i18n/index.js
Normal 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;
|
||||
462
resources/frontend/core/i18n/locales/en.json
Normal file
462
resources/frontend/core/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
461
resources/frontend/core/i18n/locales/ru.json
Normal file
461
resources/frontend/core/i18n/locales/ru.json
Normal 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": "Не удалось удалить скриншот(-ы)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
resources/frontend/core/i18n/pluralizationRules.js
Normal file
29
resources/frontend/core/i18n/pluralizationRules.js
Normal 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;
|
||||
},
|
||||
};
|
||||
83
resources/frontend/core/index.js
Normal file
83
resources/frontend/core/index.js
Normal 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;
|
||||
13
resources/frontend/core/layouts/AuthLayout.vue
Normal file
13
resources/frontend/core/layouts/AuthLayout.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'auth-layout',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
22
resources/frontend/core/layouts/DefaultLayout.vue
Normal file
22
resources/frontend/core/layouts/DefaultLayout.vue
Normal 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>
|
||||
204
resources/frontend/core/moduleLoader.js
Normal file
204
resources/frontend/core/moduleLoader.js
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div ref="container"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Svg, SVG } from '@svgdotjs/svg.js';
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
const daysOfWeek = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
|
||||
const cellWidth = 100 / daysOfWeek.length;
|
||||
const cellHeight = 32;
|
||||
|
||||
export default {
|
||||
props: {
|
||||
tasksByDay: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
created() {
|
||||
window.addEventListener('resize', this.resize);
|
||||
},
|
||||
mounted() {
|
||||
const { container } = this.$refs;
|
||||
this.svg = SVG().addTo(container);
|
||||
|
||||
this.resize();
|
||||
this.draw();
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
},
|
||||
watch: {
|
||||
tasksByDay() {
|
||||
this.resize();
|
||||
this.draw();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resize: throttle(function () {
|
||||
const { container } = this.$refs;
|
||||
|
||||
const weeks = Math.ceil(this.tasksByDay.length / 7);
|
||||
const rows = 1 + 2 * weeks;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = rows * cellHeight;
|
||||
|
||||
this.svg.viewbox(0, 0, width, height);
|
||||
}, 100),
|
||||
draw: throttle(function () {
|
||||
const borderColor = '#eeeef5';
|
||||
const blockColor = '#fff';
|
||||
const textColor = 'rgb(63, 83, 110)';
|
||||
|
||||
/** @type {Svg} */
|
||||
const svg = this.svg;
|
||||
svg.clear();
|
||||
|
||||
const group = svg.group();
|
||||
group.clipWith(svg.rect('100%', '100%').rx(20).ry(20));
|
||||
|
||||
let horizontalOffset = 0;
|
||||
let verticalOffset = 0;
|
||||
|
||||
const drawHeader = () => {
|
||||
for (const day of daysOfWeek) {
|
||||
const rect = group
|
||||
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
|
||||
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
|
||||
.fill(blockColor)
|
||||
.stroke(borderColor);
|
||||
|
||||
group
|
||||
.text(add => add.tspan(this.$t(`calendar.days.${day}`)))
|
||||
.font({ anchor: 'middle', size: 16 })
|
||||
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
|
||||
.fill(textColor)
|
||||
.clipWith(rect.clone());
|
||||
|
||||
horizontalOffset += cellWidth;
|
||||
}
|
||||
|
||||
verticalOffset += cellHeight;
|
||||
};
|
||||
|
||||
const drawDays = () => {
|
||||
horizontalOffset = 0;
|
||||
|
||||
const days = this.tasksByDay;
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const { date, day, tasks } = days[i];
|
||||
|
||||
const onClick = () => this.$emit('show-tasks-modal', { date, tasks });
|
||||
|
||||
const rect = group
|
||||
.rect(`${cellWidth.toFixed(2)}%`, 2 * cellHeight)
|
||||
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
|
||||
.fill(blockColor)
|
||||
.stroke(borderColor)
|
||||
.on('click', onClick);
|
||||
|
||||
const text = group
|
||||
.text(add => add.tspan(day.toString()))
|
||||
.font({ anchor: 'middle', size: 16 })
|
||||
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
|
||||
.fill(textColor)
|
||||
.clipWith(rect.clone())
|
||||
.on('click', onClick);
|
||||
|
||||
if (tasks.length > 0) {
|
||||
rect.attr('cursor', 'pointer');
|
||||
text.attr('cursor', 'pointer');
|
||||
group
|
||||
.circle(10)
|
||||
.attr('cx', `${(horizontalOffset + cellWidth / 2).toFixed(2)}%`)
|
||||
.attr('cy', verticalOffset + cellHeight * 1.5)
|
||||
.fill('rgb(177, 177, 190)')
|
||||
.on('click', onClick)
|
||||
.attr('cursor', 'pointer');
|
||||
}
|
||||
|
||||
if (i % daysOfWeek.length === daysOfWeek.length - 1) {
|
||||
horizontalOffset = 0;
|
||||
verticalOffset += 2 * cellHeight;
|
||||
} else {
|
||||
horizontalOffset += cellWidth;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drawHeader();
|
||||
drawDays();
|
||||
}, 100),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<div class="calendar">
|
||||
<div ref="container" class="calendar__svg"></div>
|
||||
|
||||
<div
|
||||
v-show="hoverPopup.show"
|
||||
:style="{
|
||||
left: `${hoverPopup.x}px`,
|
||||
top: `${hoverPopup.y}px`,
|
||||
}"
|
||||
class="calendar__popup popup"
|
||||
>
|
||||
<template v-if="hoverPopup.task">
|
||||
<p class="popup__row">
|
||||
<span class="popup__key">{{ $t('calendar.task.name') }}</span>
|
||||
<span class="popup__value">{{ hoverPopup.task.task_name }}</span>
|
||||
</p>
|
||||
|
||||
<p class="popup__row">
|
||||
<span class="popup__key">{{ $t('calendar.task.status') }}</span>
|
||||
<span class="popup__value">{{ hoverPopup.task.status.name }}</span>
|
||||
</p>
|
||||
|
||||
<p class="popup__row">
|
||||
<span class="popup__key">{{ $t('calendar.task.priority') }}</span>
|
||||
<span class="popup__value">{{ hoverPopup.task.priority.name }}</span>
|
||||
</p>
|
||||
|
||||
<p class="popup__row">
|
||||
<span class="popup__key">{{ $t('calendar.task.estimate') }}</span>
|
||||
<span class="popup__value">{{ formatDuration(hoverPopup.task.estimate) }}</span>
|
||||
</p>
|
||||
|
||||
<p class="popup__row">
|
||||
<span class="popup__key">{{ $t('calendar.task.total_spent_time') }}</span>
|
||||
<span class="popup__value">{{ formatDuration(hoverPopup.task.total_spent_time) }}</span>
|
||||
</p>
|
||||
|
||||
<p class="popup__row">
|
||||
<span class="popup__key">{{ $t('calendar.task.start_date') }}</span>
|
||||
<span class="popup__value">{{ formatDate(hoverPopup.task.start_date) }}</span>
|
||||
</p>
|
||||
|
||||
<p class="popup__row">
|
||||
<span class="popup__key">{{ $t('calendar.task.due_date') }}</span>
|
||||
<span class="popup__value">{{ formatDate(hoverPopup.task.due_date) }}</span>
|
||||
</p>
|
||||
|
||||
<p class="popup__row">
|
||||
<span class="popup__key">{{ $t('calendar.task.forecast_completion_date') }}</span>
|
||||
<span class="popup__value">{{ formatDate(hoverPopup.task.forecast_completion_date) }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Svg, SVG } from '@svgdotjs/svg.js';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
const msInDay = 24 * 60 * 60 * 1000;
|
||||
const daysOfWeek = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
const months = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
];
|
||||
|
||||
const cellWidth = 100 / daysOfWeek.length;
|
||||
const cellHeight = 32;
|
||||
|
||||
const maxTasks = 5;
|
||||
|
||||
export default {
|
||||
props: {
|
||||
tasksByWeek: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
showAll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hoverPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
task: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
window.addEventListener('resize', this.resize);
|
||||
},
|
||||
mounted() {
|
||||
const { container } = this.$refs;
|
||||
this.svg = SVG().addTo(container);
|
||||
|
||||
this.resize();
|
||||
this.draw();
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
},
|
||||
watch: {
|
||||
tasksByWeek() {
|
||||
this.resize();
|
||||
this.draw();
|
||||
},
|
||||
showAll() {
|
||||
this.resize();
|
||||
this.draw();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatDuration(value) {
|
||||
return value !== null ? formatDurationString(value) : '—';
|
||||
},
|
||||
formatDate(value) {
|
||||
return value !== null ? value : '—';
|
||||
},
|
||||
resize: throttle(function () {
|
||||
const { container } = this.$refs;
|
||||
|
||||
const weeks = this.tasksByWeek.length;
|
||||
const tasks = this.tasksByWeek.reduce(
|
||||
(acc, item) => acc + (this.showAll ? item.tasks.length : Math.min(maxTasks, item.tasks.length)),
|
||||
0,
|
||||
);
|
||||
|
||||
const rows = 1 + weeks + tasks;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = rows * cellHeight;
|
||||
|
||||
this.svg.viewbox(0, 0, width, height);
|
||||
}, 100),
|
||||
draw: throttle(function () {
|
||||
const backgroundColor = '#fafafa';
|
||||
const borderColor = '#eeeef5';
|
||||
const blockColor = '#fff';
|
||||
const textColor = 'rgb(63, 83, 110)';
|
||||
|
||||
/** @type {Svg} */
|
||||
const svg = this.svg;
|
||||
svg.clear();
|
||||
|
||||
const group = svg.group();
|
||||
group.clipWith(svg.rect('100%', '100%').rx(20).ry(20));
|
||||
|
||||
let horizontalOffset = 0;
|
||||
let verticalOffset = 0;
|
||||
|
||||
const drawHeader = () => {
|
||||
for (const day of daysOfWeek) {
|
||||
const rect = group
|
||||
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
|
||||
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
|
||||
.fill(blockColor)
|
||||
.stroke(borderColor);
|
||||
|
||||
group
|
||||
.text(add => add.tspan(this.$t(`calendar.days.${day}`)))
|
||||
.font({ anchor: 'middle', size: 16 })
|
||||
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
|
||||
.fill(textColor)
|
||||
.clipWith(rect.clone());
|
||||
|
||||
horizontalOffset += cellWidth;
|
||||
}
|
||||
|
||||
verticalOffset += cellHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string[]} days
|
||||
*/
|
||||
const drawDaysRow = days => {
|
||||
horizontalOffset = 0;
|
||||
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const { month, day } = days[i];
|
||||
const rect = group
|
||||
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
|
||||
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
|
||||
.fill(i < 5 ? blockColor : backgroundColor)
|
||||
.stroke(borderColor);
|
||||
|
||||
const dayText =
|
||||
day === 1 ? `${this.$t(`calendar.months.${months[month - 1]}`)} ${day}` : day.toString();
|
||||
group
|
||||
.text(add => add.tspan(dayText).dmove(-8, 0))
|
||||
.font({ anchor: 'end', size: 16 })
|
||||
.amove(`${(horizontalOffset + cellWidth).toFixed(2)}%`, verticalOffset + cellHeight / 2)
|
||||
.fill(textColor)
|
||||
.clipWith(rect.clone());
|
||||
|
||||
horizontalOffset += cellWidth;
|
||||
}
|
||||
|
||||
group.line(0, verticalOffset, '100%', verticalOffset).stroke({ width: 1, color: '#C5D9E8' });
|
||||
group
|
||||
.line(0, verticalOffset + cellHeight - 1, '100%', verticalOffset + cellHeight - 1)
|
||||
.stroke({ width: 1, color: '#C5D9E8' });
|
||||
|
||||
verticalOffset += cellHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} task
|
||||
* @param {number} startWeekDay
|
||||
* @param {number} endWeekDay
|
||||
*/
|
||||
const drawTaskRow = (task, startWeekDay, endWeekDay) => {
|
||||
const width = cellWidth * (endWeekDay - startWeekDay + 1);
|
||||
horizontalOffset = cellWidth * startWeekDay;
|
||||
|
||||
group.rect(`100%`, cellHeight).move(0, verticalOffset).fill(backgroundColor).stroke(borderColor);
|
||||
|
||||
const taskHorizontalPadding = 0.2;
|
||||
const taskVerticalPadding = 3;
|
||||
|
||||
const popupWidth = 420;
|
||||
const popupHeight = 220;
|
||||
const onClick = () => {
|
||||
this.$router.push(`/tasks/view/${task.id}`);
|
||||
};
|
||||
const onMouseOver = event => {
|
||||
const rectBBox = rect.bbox();
|
||||
const popupX =
|
||||
event.clientX < this.$refs.container.clientWidth - popupWidth
|
||||
? event.clientX
|
||||
: event.clientX - popupWidth - 40;
|
||||
|
||||
const popupY =
|
||||
rectBBox.y + this.$refs.container.getBoundingClientRect().y <
|
||||
window.innerHeight - popupHeight
|
||||
? rectBBox.y
|
||||
: rectBBox.y - popupHeight;
|
||||
|
||||
this.hoverPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
task,
|
||||
};
|
||||
};
|
||||
|
||||
const onMouseOut = event => {
|
||||
this.hoverPopup = {
|
||||
...this.hoverPopup,
|
||||
show: false,
|
||||
};
|
||||
};
|
||||
|
||||
const rect = group
|
||||
.rect(`${width - 2 * taskHorizontalPadding}%`, cellHeight - 2 * taskVerticalPadding)
|
||||
.move(`${horizontalOffset + taskHorizontalPadding}%`, verticalOffset + taskVerticalPadding)
|
||||
.fill(blockColor)
|
||||
.stroke(borderColor)
|
||||
.on('mouseover', event => {
|
||||
onMouseOver(event);
|
||||
event.target.style.cursor = 'pointer';
|
||||
})
|
||||
.on('mouseout', onMouseOut)
|
||||
.on('click', onClick);
|
||||
|
||||
let pxOffset = 0;
|
||||
if (new Date(task.due_date).getTime() + msInDay < new Date().getTime()) {
|
||||
pxOffset += 2 * taskVerticalPadding;
|
||||
group
|
||||
.rect(cellHeight - 4 * taskVerticalPadding, cellHeight - 4 * taskVerticalPadding)
|
||||
.move(
|
||||
`${horizontalOffset + taskHorizontalPadding}%`,
|
||||
verticalOffset + 2 * taskVerticalPadding,
|
||||
)
|
||||
.transform({ translateX: pxOffset })
|
||||
.fill('#FF5569')
|
||||
.stroke(borderColor)
|
||||
.rx(4)
|
||||
.ry(4)
|
||||
.on('mouseover', event => {
|
||||
onMouseOver(event);
|
||||
event.target.style.cursor = 'pointer';
|
||||
})
|
||||
.on('mouseout', onMouseOut)
|
||||
.on('click', onClick);
|
||||
|
||||
pxOffset += cellHeight - 4 * taskVerticalPadding;
|
||||
}
|
||||
|
||||
if (task.estimate !== null && Number(task.total_spent_time) > Number(task.estimate)) {
|
||||
pxOffset += 2 * taskVerticalPadding;
|
||||
group
|
||||
.rect(cellHeight - 4 * taskVerticalPadding, cellHeight - 4 * taskVerticalPadding)
|
||||
.move(
|
||||
`${horizontalOffset + taskHorizontalPadding}%`,
|
||||
verticalOffset + 2 * taskVerticalPadding,
|
||||
)
|
||||
.transform({ translateX: pxOffset })
|
||||
.fill('#FFC82C')
|
||||
.stroke(borderColor)
|
||||
.rx(4)
|
||||
.ry(4)
|
||||
.on('mouseover', event => {
|
||||
onMouseOver(event);
|
||||
event.target.style.cursor = 'pointer';
|
||||
})
|
||||
.on('mouseout', onMouseOut)
|
||||
.on('click', onClick);
|
||||
|
||||
pxOffset += cellHeight - 4 * taskVerticalPadding;
|
||||
}
|
||||
|
||||
group
|
||||
.text(add => add.tspan(task.task_name).dmove(8, 0))
|
||||
.font({ anchor: 'start', size: 16 })
|
||||
.amove(`${horizontalOffset + taskHorizontalPadding}%`, verticalOffset + cellHeight / 2)
|
||||
.transform({ translateX: pxOffset })
|
||||
.fill(textColor)
|
||||
.clipWith(rect.clone())
|
||||
.on('mouseover', event => {
|
||||
onMouseOver(event);
|
||||
event.target.style.cursor = 'pointer';
|
||||
})
|
||||
.on('mouseout', onMouseOut)
|
||||
.on('click', onClick);
|
||||
verticalOffset += cellHeight;
|
||||
};
|
||||
|
||||
drawHeader();
|
||||
|
||||
for (const { days, tasks } of this.tasksByWeek) {
|
||||
drawDaysRow(days);
|
||||
|
||||
for (const { task, start_week_day, end_week_day } of this.showAll
|
||||
? tasks
|
||||
: tasks.slice(0, maxTasks)) {
|
||||
drawTaskRow(task, start_week_day, end_week_day);
|
||||
}
|
||||
}
|
||||
}, 100),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.calendar {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
position: relative;
|
||||
|
||||
&__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__popup {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
border: 0;
|
||||
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
|
||||
|
||||
position: absolute;
|
||||
display: block;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.popup {
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div v-if="visible" class="tasks-modal">
|
||||
<div class="tasks-modal__header">
|
||||
<h4 class="tasks-modal__title">{{ $t('calendar.tasks', { date: formattedDate }) }}</h4>
|
||||
<at-button @click="close"><i class="icon icon-x"></i></at-button>
|
||||
</div>
|
||||
|
||||
<ul class="tasks-modal__list">
|
||||
<li v-for="task of tasks" :key="task.id" class="tasks-modal__item">
|
||||
<router-link class="tasks-modal__link" :to="`/tasks/view/${task.id}`">
|
||||
{{ task.task_name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
|
||||
const MODAL_VISIBLE_CLASS = 'modal-visible';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
date: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
tasks: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
visible(value) {
|
||||
if (value) {
|
||||
document.body.classList.add(MODAL_VISIBLE_CLASS);
|
||||
} else {
|
||||
document.body.classList.remove(MODAL_VISIBLE_CLASS);
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.body.classList.remove(MODAL_VISIBLE_CLASS);
|
||||
},
|
||||
computed: {
|
||||
visible() {
|
||||
return this.tasks.length > 0;
|
||||
},
|
||||
formattedDate() {
|
||||
return moment(this.date).format('LL');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tasks-modal {
|
||||
background: #fff;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
padding: 0.75em 24px;
|
||||
|
||||
z-index: 10;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
body.modal-visible {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
43
resources/frontend/core/modules/Calendar/locales/en.json
Normal file
43
resources/frontend/core/modules/Calendar/locales/en.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"navigation": {
|
||||
"calendar": "Calendar"
|
||||
},
|
||||
"calendar": {
|
||||
"days": {
|
||||
"monday": "Mon",
|
||||
"tuesday": "Tue",
|
||||
"wednesday": "Wed",
|
||||
"thursday": "Thu",
|
||||
"friday": "Fri",
|
||||
"saturday": "Sat",
|
||||
"sunday": "Sun"
|
||||
},
|
||||
"months": {
|
||||
"january": "Jan.",
|
||||
"february": "Feb.",
|
||||
"april": "Apr.",
|
||||
"march": "Mar.",
|
||||
"may": "May.",
|
||||
"june": "Jun.",
|
||||
"july": "Jul.",
|
||||
"august": "Aug.",
|
||||
"september": "Sep.",
|
||||
"october": "Oct.",
|
||||
"november": "Nov.",
|
||||
"december": "Dec."
|
||||
},
|
||||
"task": {
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"priority": "Priority",
|
||||
"estimate": "Estimate",
|
||||
"total_spent_time": "Total spent time",
|
||||
"start_date": "Start date",
|
||||
"due_date": "Due date",
|
||||
"forecast_completion_date": "Forecast completion date"
|
||||
},
|
||||
"tasks": "Tasks {date}",
|
||||
"show_all": "Show all",
|
||||
"show_first": "Show first 5 tasks"
|
||||
}
|
||||
}
|
||||
43
resources/frontend/core/modules/Calendar/locales/ru.json
Normal file
43
resources/frontend/core/modules/Calendar/locales/ru.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"navigation": {
|
||||
"calendar": "Календарь"
|
||||
},
|
||||
"calendar": {
|
||||
"days": {
|
||||
"monday": "Пн.",
|
||||
"tuesday": "Вт.",
|
||||
"wednesday": "Ср.",
|
||||
"thursday": "Чт.",
|
||||
"friday": "Пт.",
|
||||
"saturday": "Сб.",
|
||||
"sunday": "Вс."
|
||||
},
|
||||
"months": {
|
||||
"january": "Янв.",
|
||||
"february": "Февр.",
|
||||
"april": "Апр.",
|
||||
"march": "Март",
|
||||
"may": "Май",
|
||||
"june": "Июнь",
|
||||
"july": "Июль",
|
||||
"august": "Авг.",
|
||||
"september": "Сент.",
|
||||
"october": "Окт.",
|
||||
"november": "Нояб.",
|
||||
"december": "Дек."
|
||||
},
|
||||
"task": {
|
||||
"name": "Название",
|
||||
"status": "Статус",
|
||||
"priority": "Приоритет",
|
||||
"estimate": "Оценка времени",
|
||||
"total_spent_time": "Всего потрачено времени",
|
||||
"start_date": "Дата начала",
|
||||
"due_date": "Дата завершения",
|
||||
"forecast_completion_date": "Прогнозируемая дата завершения"
|
||||
},
|
||||
"tasks": "Задачи {date}",
|
||||
"show_all": "Показать всё",
|
||||
"show_first": "Показать первые 5 задач"
|
||||
}
|
||||
}
|
||||
25
resources/frontend/core/modules/Calendar/module.init.js
Normal file
25
resources/frontend/core/modules/Calendar/module.init.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export const ModuleConfig = {
|
||||
routerPrefix: 'calendar',
|
||||
loadOrder: 30,
|
||||
moduleName: 'Calendar',
|
||||
};
|
||||
|
||||
export function init(context) {
|
||||
context.addRoute({
|
||||
path: '/calendar',
|
||||
name: context.getModuleRouteName() + '.index',
|
||||
component: () => import(/* webpackChunkName: "calendar" */ './views/Calendar.vue'),
|
||||
});
|
||||
|
||||
context.addNavbarEntry({
|
||||
label: 'navigation.calendar',
|
||||
to: { path: '/calendar' },
|
||||
});
|
||||
|
||||
context.addLocalizationData({
|
||||
en: require('./locales/en'),
|
||||
ru: require('./locales/ru'),
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import axios from '@/config/app';
|
||||
|
||||
export default class CalendarService {
|
||||
/**
|
||||
* @param {string|Date} startAt
|
||||
* @param {string|Date} endAt
|
||||
* @param {null|number|number[]} projectId
|
||||
*/
|
||||
get(startAt, endAt, projectId = null) {
|
||||
return axios.post('tasks/calendar', { start_at: startAt, end_at: endAt, project_id: projectId });
|
||||
}
|
||||
}
|
||||
165
resources/frontend/core/modules/Calendar/views/Calendar.vue
Normal file
165
resources/frontend/core/modules/Calendar/views/Calendar.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="calendar">
|
||||
<h1 class="page-title">{{ $t('navigation.calendar') }}</h1>
|
||||
|
||||
<div class="controls-row">
|
||||
<DatePicker
|
||||
class="controls-row__item"
|
||||
:day="false"
|
||||
:week="false"
|
||||
:range="false"
|
||||
initialTab="month"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
|
||||
<ProjectSelect class="controls-row__item" @change="onProjectsChange" />
|
||||
|
||||
<at-button class="controls-row__item show-all" @click="onShowAllClick">{{
|
||||
showAll ? $t('calendar.show_first') : $t('calendar.show_all')
|
||||
}}</at-button>
|
||||
</div>
|
||||
|
||||
<div class="at-container">
|
||||
<calendar-view
|
||||
class="svg-container svg-container__desktop"
|
||||
:tasks-by-week="tasksByWeek"
|
||||
:show-all="showAll"
|
||||
/>
|
||||
|
||||
<calendar-mobile-view
|
||||
class="svg-container svg-container__mobile"
|
||||
:tasks-by-day="tasksByDay"
|
||||
@show-tasks-modal="showTasksModal"
|
||||
/>
|
||||
|
||||
<tasks-modal :date="modal.date" :tasks="modal.tasks" @close="hideTasksModal" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import DatePicker from '@/components/Calendar';
|
||||
import ProjectSelect from '@/components/ProjectSelect';
|
||||
import CalendarMobileView from '../components/CalendarMobileView.vue';
|
||||
import CalendarView from '../components/CalendarView.vue';
|
||||
import TasksModal from '../components/TasksModal.vue';
|
||||
import CalendarService from '../services/calendar.service';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
const ISO8601_DATE_FORMAT = 'YYYY-MM-DD';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CalendarMobileView,
|
||||
CalendarView,
|
||||
DatePicker,
|
||||
TasksModal,
|
||||
ProjectSelect,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
start: moment().startOf('month').format(ISO8601_DATE_FORMAT),
|
||||
end: moment().endOf('month').format(ISO8601_DATE_FORMAT),
|
||||
projects: [],
|
||||
|
||||
tasksByDay: [],
|
||||
tasksByWeek: [],
|
||||
|
||||
modal: {
|
||||
date: moment().format(ISO8601_DATE_FORMAT),
|
||||
tasks: [],
|
||||
},
|
||||
|
||||
showAll: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.service = new CalendarService();
|
||||
this.load = debounce(this.load, 300);
|
||||
this.load();
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
const response = await this.service.get(this.start, this.end, this.projects);
|
||||
const { tasks, tasks_by_day, tasks_by_week } = response.data.data;
|
||||
|
||||
this.tasksByDay = tasks_by_day.map(day => ({
|
||||
...day,
|
||||
tasks: day.task_ids.map(task_id => tasks[task_id]),
|
||||
}));
|
||||
|
||||
this.tasksByWeek = tasks_by_week.map(week => ({
|
||||
...week,
|
||||
tasks: week.tasks.map(item => ({
|
||||
...item,
|
||||
task: tasks[item.task_id],
|
||||
})),
|
||||
}));
|
||||
},
|
||||
onDateChange({ start, end }) {
|
||||
this.start = moment(start).startOf('month').format(ISO8601_DATE_FORMAT);
|
||||
this.end = moment(end).endOf('month').format(ISO8601_DATE_FORMAT);
|
||||
this.load();
|
||||
},
|
||||
onProjectsChange(projects) {
|
||||
this.projects = projects;
|
||||
this.load();
|
||||
},
|
||||
showTasksModal({ date, tasks }) {
|
||||
this.modal.date = date;
|
||||
this.modal.tasks = tasks;
|
||||
},
|
||||
hideTasksModal() {
|
||||
this.modal.tasks = [];
|
||||
},
|
||||
onShowAllClick() {
|
||||
this.showAll = !this.showAll;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.show-all {
|
||||
display: none;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.svg-container {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__mobile {
|
||||
display: flex;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__desktop {
|
||||
display: none;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&::v-deep svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&::v-deep text {
|
||||
dominant-baseline: central;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<at-modal :value="showModal" :title="$t('control.add_new_task')" @on-cancel="cancel" @on-confirm="confirm">
|
||||
<validation-observer ref="form" v-slot="{}">
|
||||
<validation-provider
|
||||
ref="project"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.project')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.project') }}</small>
|
||||
|
||||
<resource-select
|
||||
v-model="projectId"
|
||||
class="input"
|
||||
:service="projectsService"
|
||||
:class="{ 'at-select--error': errors.length > 0 }"
|
||||
/>
|
||||
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
|
||||
<validation-provider
|
||||
ref="taskName"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.task_name')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.task_name') }}</small>
|
||||
|
||||
<at-input v-model="taskName" class="input" />
|
||||
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
|
||||
<validation-provider
|
||||
ref="taskDescription"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.task_description')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.task_description') }}</small>
|
||||
|
||||
<at-textarea v-model="taskDescription" class="input" />
|
||||
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
</validation-observer>
|
||||
|
||||
<div slot="footer">
|
||||
<at-button @click="cancel">{{ $t('control.cancel') }}</at-button>
|
||||
<at-button type="primary" :disabled="disableButtons" @click="confirm">{{ $t('control.save') }} </at-button>
|
||||
</div>
|
||||
</at-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ResourceSelect from '@/components/ResourceSelect';
|
||||
import ProjectService from '@/services/resource/project.service';
|
||||
import TasksService from '@/services/resource/task.service';
|
||||
import { ValidationObserver, ValidationProvider } from 'vee-validate';
|
||||
|
||||
export default {
|
||||
name: 'AddNewTaskModal',
|
||||
components: {
|
||||
ResourceSelect,
|
||||
ValidationObserver,
|
||||
ValidationProvider,
|
||||
},
|
||||
props: {
|
||||
showModal: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
},
|
||||
disableButtons: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
projectId: '',
|
||||
taskName: '',
|
||||
taskDescription: '',
|
||||
|
||||
projectsService: new ProjectService(),
|
||||
tasksService: new TasksService(),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
cancel() {
|
||||
this.$refs.form.reset();
|
||||
|
||||
this.projectId = '';
|
||||
this.taskName = '';
|
||||
this.taskDescription = '';
|
||||
|
||||
this.$emit('cancel');
|
||||
},
|
||||
|
||||
async confirm() {
|
||||
const valid = await this.$refs.form.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectId, taskName, taskDescription } = this;
|
||||
|
||||
this.projectId = '';
|
||||
this.taskName = '';
|
||||
this.taskDescription = '';
|
||||
|
||||
this.$emit('confirm', { projectId, taskName, taskDescription });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-group {
|
||||
margin-bottom: $layout-01;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-bottom: $spacing-02;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<at-modal :value="showModal" :title="$t('control.edit_intervals')" @on-cancel="cancel" @on-confirm="confirm">
|
||||
<validation-observer ref="form" v-slot="{}">
|
||||
<validation-provider
|
||||
ref="project"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.project')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.project') }}</small>
|
||||
|
||||
<resource-select
|
||||
v-model="projectId"
|
||||
class="input"
|
||||
:service="projectsService"
|
||||
:class="{ 'at-select--error': errors.length > 0 }"
|
||||
/>
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
|
||||
<validation-provider
|
||||
ref="task"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
:name="$t('field.task')"
|
||||
mode="passive"
|
||||
>
|
||||
<div class="input-group">
|
||||
<small>{{ $t('field.task') }}</small>
|
||||
|
||||
<at-select
|
||||
v-if="enableTaskSelect"
|
||||
v-model="taskId"
|
||||
filterable
|
||||
class="input"
|
||||
:placeholder="$t('control.select')"
|
||||
:class="{ 'at-select--error': errors.length > 0 }"
|
||||
>
|
||||
<at-option
|
||||
v-for="option of tasksOptionList"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
>
|
||||
<div class="input__select-wrap">
|
||||
<div class="flex flex-wrap flex-gap">
|
||||
<at-tooltip
|
||||
v-for="(user, userKey) in option.users"
|
||||
:key="userKey"
|
||||
:content="user.full_name"
|
||||
placement="right"
|
||||
class="user-tooltips"
|
||||
>
|
||||
<user-avatar :user="user" />
|
||||
</at-tooltip>
|
||||
</div>
|
||||
<span>{{ option.label }}</span>
|
||||
</div>
|
||||
</at-option>
|
||||
</at-select>
|
||||
<at-input v-else class="input" disabled />
|
||||
|
||||
<p>{{ errors[0] }}</p>
|
||||
</div>
|
||||
</validation-provider>
|
||||
</validation-observer>
|
||||
|
||||
<div slot="footer">
|
||||
<at-button @click="cancel">{{ $t('control.cancel') }}</at-button>
|
||||
<at-button type="primary" :disabled="disableButtons" @click="confirm">{{ $t('control.save') }} </at-button>
|
||||
</div>
|
||||
</at-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ResourceSelect from '@/components/ResourceSelect';
|
||||
import ProjectService from '@/services/resource/project.service';
|
||||
import TasksService from '@/services/resource/task.service';
|
||||
import { ValidationObserver, ValidationProvider } from 'vee-validate';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
export default {
|
||||
name: 'ChangeTaskModal',
|
||||
components: {
|
||||
ResourceSelect,
|
||||
ValidationObserver,
|
||||
ValidationProvider,
|
||||
UserAvatar,
|
||||
},
|
||||
props: {
|
||||
showModal: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
},
|
||||
disableButtons: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
enableTaskSelect() {
|
||||
return !!(this.projectId && this.tasksOptionList);
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
projectId: '',
|
||||
taskId: '',
|
||||
|
||||
projectsService: new ProjectService(),
|
||||
tasksService: new TasksService(),
|
||||
|
||||
tasksOptionList: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
cancel() {
|
||||
this.$refs.form.reset();
|
||||
|
||||
this.projectId = '';
|
||||
this.taskId = '';
|
||||
|
||||
this.$emit('cancel');
|
||||
},
|
||||
|
||||
async confirm() {
|
||||
const valid = await this.$refs.form.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskId } = this;
|
||||
|
||||
this.projectId = '';
|
||||
this.taskId = '';
|
||||
|
||||
this.$emit('confirm', taskId);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
async projectId(projectId) {
|
||||
try {
|
||||
const taskList = (
|
||||
await this.tasksService.getWithFilters({ where: { project_id: projectId }, with: ['users'] })
|
||||
).data.data;
|
||||
this.tasksOptionList = taskList.map(option => ({
|
||||
value: option.id,
|
||||
label: option['task_name'],
|
||||
users: option.users,
|
||||
}));
|
||||
} catch ({ response }) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(response ? response : 'Request to tasks is canceled');
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (Object.prototype.hasOwnProperty.call(this.$refs, 'project') && this.$refs.project) {
|
||||
this.$refs.project.reset();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(this.$refs, 'task') && this.$refs.project) {
|
||||
this.$refs.task.reset();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-group {
|
||||
margin-bottom: $layout-01;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-bottom: $spacing-02;
|
||||
|
||||
&__select-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flex {
|
||||
max-width: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,557 @@
|
||||
<template>
|
||||
<div class="canvas-wrapper">
|
||||
<div
|
||||
v-show="hoverPopup.show && !clickPopup.show"
|
||||
:style="{
|
||||
left: `${hoverPopup.x - 30}px`,
|
||||
bottom: `${height() - hoverPopup.y + 50}px`,
|
||||
}"
|
||||
class="popup"
|
||||
>
|
||||
<div v-if="hoverPopup.event">
|
||||
{{ hoverPopup.event.task_name }}
|
||||
({{ hoverPopup.event.project_name }})
|
||||
</div>
|
||||
|
||||
<div v-if="hoverPopup.event">
|
||||
{{ formatDuration(hoverPopup.event.duration) }}
|
||||
</div>
|
||||
|
||||
<a :style="{ left: `${hoverPopup.borderX}px` }" class="corner"></a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="clickPopup.show"
|
||||
:style="{
|
||||
left: `${clickPopup.x - 30}px`,
|
||||
bottom: `${height() - clickPopup.y + 50}px`,
|
||||
}"
|
||||
class="popup"
|
||||
>
|
||||
<template v-if="clickPopup.event">
|
||||
<div>
|
||||
<Screenshot
|
||||
:disableModal="true"
|
||||
:lazyImage="false"
|
||||
:project="{ id: clickPopup.event.project_id, name: clickPopup.event.project_name }"
|
||||
:interval="clickPopup.event"
|
||||
:showText="false"
|
||||
:task="{ id: clickPopup.event.task_id, name: clickPopup.event.task_name }"
|
||||
:user="clickPopup.event"
|
||||
@click="showPopup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<router-link :to="`/tasks/view/${clickPopup.event.task_id}`">
|
||||
{{ clickPopup.event.task_name }}
|
||||
</router-link>
|
||||
|
||||
<router-link :to="`/projects/view/${clickPopup.event.project_id}`">
|
||||
({{ clickPopup.event.project_name }})
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a :style="{ left: `${clickPopup.borderX}px` }" class="corner" />
|
||||
</div>
|
||||
|
||||
<ScreenshotModal
|
||||
:project="modal.project"
|
||||
:interval="modal.interval"
|
||||
:show="modal.show"
|
||||
:showNavigation="true"
|
||||
:task="modal.task"
|
||||
:user="modal.user"
|
||||
@close="onHide"
|
||||
@remove="onRemove"
|
||||
@showNext="showNext"
|
||||
@showPrevious="showPrevious"
|
||||
/>
|
||||
<div ref="canvas" class="canvas" @pointerdown="onDown">
|
||||
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
|
||||
<div :style="{ width: `${totalWidth}px` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Screenshot from '@/components/Screenshot';
|
||||
import ScreenshotModal from '@/components/ScreenshotModal';
|
||||
import IntervalService from '@/services/resource/time-interval.service';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import moment from 'moment-timezone';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { SVG } from '@svgdotjs/svg.js';
|
||||
|
||||
let intervalService = new IntervalService();
|
||||
|
||||
const titleHeight = 20;
|
||||
const subtitleHeight = 20;
|
||||
const rowHeight = 65;
|
||||
const columns = 24;
|
||||
const minColumnWidth = 42;
|
||||
const popupWidth = 270;
|
||||
const canvasPadding = 20;
|
||||
const defaultCornerOffset = 15;
|
||||
|
||||
export default {
|
||||
name: 'TeamDayGraph',
|
||||
components: {
|
||||
Screenshot,
|
||||
ScreenshotModal,
|
||||
},
|
||||
props: {
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hoverPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
event: null,
|
||||
borderX: 0,
|
||||
},
|
||||
clickPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
event: null,
|
||||
intervalID: null,
|
||||
borderX: 0,
|
||||
},
|
||||
modal: {
|
||||
show: false,
|
||||
project: null,
|
||||
task: null,
|
||||
user: null,
|
||||
interval: null,
|
||||
},
|
||||
totalWidth: 0,
|
||||
scrollPos: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['intervals', 'timezone']),
|
||||
...mapGetters('user', ['companyData']),
|
||||
},
|
||||
mounted() {
|
||||
this.draw = SVG();
|
||||
|
||||
this.onResize();
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('mousedown', this.onClick);
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('mousedown', this.onClick);
|
||||
window.removeEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
methods: {
|
||||
formatDuration: formatDurationString,
|
||||
showPopup() {
|
||||
this.modal = {
|
||||
show: true,
|
||||
project: { id: this.clickPopup.event.project_id, name: this.clickPopup.event.project_name },
|
||||
user: this.clickPopup.event,
|
||||
task: { id: this.clickPopup.event.task_id, task_name: this.clickPopup.event.task_name },
|
||||
interval: this.clickPopup.event,
|
||||
};
|
||||
},
|
||||
onHide() {
|
||||
this.modal = {
|
||||
...this.modal,
|
||||
show: false,
|
||||
};
|
||||
|
||||
this.$emit('selectedIntervals', null);
|
||||
},
|
||||
onKeyDown(e) {
|
||||
if (!this.modal.show) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
this.showPrevious();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
this.showNext();
|
||||
}
|
||||
},
|
||||
showPrevious() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
const interval = intervals[currentIndex - 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
showNext() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex < intervals.length - 1) {
|
||||
const interval = intervals[currentIndex + 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
height() {
|
||||
return this.users.length * rowHeight;
|
||||
},
|
||||
canvasWidth() {
|
||||
return this.$refs.canvas.clientWidth;
|
||||
},
|
||||
columnWidth() {
|
||||
return Math.max(minColumnWidth, this.canvasWidth() / columns);
|
||||
},
|
||||
async contentWidth() {
|
||||
await this.$nextTick();
|
||||
this.totalWidth = columns * this.columnWidth();
|
||||
return this.totalWidth;
|
||||
},
|
||||
onDown(e) {
|
||||
this.$refs.canvas.addEventListener('pointermove', this.onMove);
|
||||
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
|
||||
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
|
||||
},
|
||||
|
||||
async maxScrollX() {
|
||||
return (await this.contentWidth()) - this.canvasWidth();
|
||||
},
|
||||
async scrollCanvas(movementX, setScroll = true) {
|
||||
const canvas = this.$refs.canvas;
|
||||
const clientWidth = canvas.clientWidth;
|
||||
const entireWidth = await this.contentWidth();
|
||||
const height = this.height();
|
||||
const newScrollPos = this.scrollPos - movementX;
|
||||
if (newScrollPos <= 0) {
|
||||
this.scrollPos = 0;
|
||||
} else if (newScrollPos >= entireWidth - clientWidth) {
|
||||
this.scrollPos = entireWidth - clientWidth;
|
||||
} else {
|
||||
this.scrollPos = newScrollPos;
|
||||
}
|
||||
setScroll ? await this.setScroll() : null;
|
||||
this.draw.viewbox(this.scrollPos, 20, clientWidth, height);
|
||||
},
|
||||
async onMove(e) {
|
||||
this.$refs.canvas.setPointerCapture(e.pointerId);
|
||||
await this.scrollCanvas(e.movementX);
|
||||
},
|
||||
onUp(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onCancel(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onScroll(e) {
|
||||
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
|
||||
},
|
||||
async setScroll(x = null) {
|
||||
await this.$nextTick();
|
||||
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
|
||||
},
|
||||
async drawGrid() {
|
||||
if (typeof this.draw === 'undefined') return;
|
||||
this.draw.clear();
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const height = this.height();
|
||||
const columnWidth = this.columnWidth();
|
||||
const draw = this.draw;
|
||||
draw.addTo(canvasContainer).size(width, height + titleHeight + subtitleHeight);
|
||||
if (height <= 0) {
|
||||
return;
|
||||
}
|
||||
this.draw.viewbox(0, 20, width, height);
|
||||
// Background
|
||||
const rectBackground = draw
|
||||
.rect(await this.contentWidth(), height - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.fill('#FAFAFA')
|
||||
.stroke({ color: '#DFE5ED', width: 1 })
|
||||
.on('mousedown', () => this.$emit('outsideClick'));
|
||||
draw.add(rectBackground);
|
||||
for (let column = 0; column < columns; ++column) {
|
||||
const date = moment().startOf('day').add(column, 'hours');
|
||||
let left = this.columnWidth() * column;
|
||||
// Column headers - hours
|
||||
draw.text(date.format('h'))
|
||||
.move(left + columnWidth / 2, 0)
|
||||
.size(columnWidth, titleHeight)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 15,
|
||||
fill: '#151941',
|
||||
});
|
||||
// Column headers - am/pm
|
||||
draw.text(date.format('A'))
|
||||
.move(left + columnWidth / 2, titleHeight - 5)
|
||||
.size(columnWidth, subtitleHeight)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 10,
|
||||
'font-weight': '600',
|
||||
fill: '#B1B1BE',
|
||||
});
|
||||
|
||||
// // Vertical grid lines
|
||||
if (column > 0) {
|
||||
draw.line(0, titleHeight + subtitleHeight, 0, height + titleHeight + subtitleHeight)
|
||||
.move(left, titleHeight + subtitleHeight)
|
||||
.stroke({ color: '#DFE5ED', width: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const maxLeftOffset = width - popupWidth + 2 * canvasPadding;
|
||||
const clipPath = draw
|
||||
.rect(await this.contentWidth(), height - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.attr({
|
||||
absolutePositioned: true,
|
||||
});
|
||||
const squaresGroup = draw.group().clipWith(clipPath);
|
||||
for (const user of this.users) {
|
||||
const row = this.users.indexOf(user);
|
||||
const top = row * rowHeight + titleHeight + subtitleHeight;
|
||||
|
||||
// Horizontal grid lines
|
||||
if (row > 0) {
|
||||
draw.line(0, 0, await this.contentWidth(), 0)
|
||||
.move(0, top)
|
||||
.stroke({ color: '#DFE5ED', width: 1 });
|
||||
}
|
||||
|
||||
// Intervals
|
||||
if (Object.prototype.hasOwnProperty.call(this.intervals, user.id)) {
|
||||
this.intervals[user.id].forEach(event => {
|
||||
const leftOffset =
|
||||
moment
|
||||
.tz(event.start_at, this.companyData.timezone)
|
||||
.tz(this.timezone)
|
||||
.diff(moment.tz(this.start, this.timezone).startOf('day'), 'hours', true) % 24;
|
||||
const widthIntrevals =
|
||||
((Math.max(event.duration, 60) + 120) * this.columnWidth()) / 60 / 60;
|
||||
const rectInterval = draw
|
||||
.rect(widthIntrevals, rowHeight / 2)
|
||||
.move(Math.floor(leftOffset * this.columnWidth()), top + rowHeight / 4)
|
||||
.radius(2)
|
||||
.stroke({ color: 'transparent', width: 0 })
|
||||
.attr({
|
||||
cursor: 'pointer',
|
||||
hoverCursor: 'pointer',
|
||||
fill: event.is_manual == '1' ? '#c4b52d' : '#2DC48D',
|
||||
});
|
||||
|
||||
rectInterval.on('mouseover', e => {
|
||||
const popupY = rectInterval.bbox().y - rectInterval.bbox().height;
|
||||
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
|
||||
const rectMiddleX = rectInterval.rbox().cx - defaultCornerOffset / 2;
|
||||
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
|
||||
const left =
|
||||
rectMiddleX > canvasRight
|
||||
? canvasRight - defaultCornerOffset / 2
|
||||
: rectMiddleX < minLeft
|
||||
? minLeft - defaultCornerOffset / 2
|
||||
: rectMiddleX;
|
||||
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
|
||||
const popupX = left > maxRight ? maxRight : left < minLeft ? minLeft : left;
|
||||
const arrowX = defaultCornerOffset + left - popupX;
|
||||
this.hoverPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
event,
|
||||
borderX: arrowX,
|
||||
};
|
||||
});
|
||||
rectInterval.on('mouseout', e => {
|
||||
this.hoverPopup = {
|
||||
...this.hoverPopup,
|
||||
show: false,
|
||||
};
|
||||
});
|
||||
rectInterval.on('mousedown', e => {
|
||||
this.$emit('selectedIntervals', event);
|
||||
const popupY = rectInterval.bbox().y - rectInterval.bbox().height;
|
||||
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
|
||||
const rectMiddleX = rectInterval.rbox().cx - defaultCornerOffset / 2;
|
||||
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
|
||||
const left =
|
||||
rectMiddleX > canvasRight
|
||||
? canvasRight - defaultCornerOffset / 2
|
||||
: rectMiddleX < minLeft
|
||||
? minLeft - defaultCornerOffset / 2
|
||||
: rectMiddleX;
|
||||
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
|
||||
const popupX = left > maxRight ? maxRight : left < minLeft ? minLeft : left;
|
||||
const arrowX = defaultCornerOffset + left - popupX;
|
||||
this.clickPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
event,
|
||||
borderX: arrowX,
|
||||
};
|
||||
e.stopPropagation();
|
||||
});
|
||||
squaresGroup.add(rectInterval);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onResize: function () {
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const height = this.height();
|
||||
this.draw.size(width, height);
|
||||
this.setScroll(0);
|
||||
this.drawGrid();
|
||||
},
|
||||
onClick(e) {
|
||||
if (e.button !== 0 || (e.target && e.target.closest('.popup'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clickPopup = {
|
||||
...this.clickPopup,
|
||||
show: false,
|
||||
};
|
||||
},
|
||||
async onRemove() {
|
||||
try {
|
||||
await intervalService.deleteItem(this.modal.interval.id);
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.delete.success.title'),
|
||||
message: this.$t('notification.screenshot.delete.success.message'),
|
||||
});
|
||||
this.onHide();
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.delete.error.title'),
|
||||
message: this.$t('notification.screenshot.delete.error.message'),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
start() {
|
||||
this.setScroll(0);
|
||||
},
|
||||
users() {
|
||||
this.onResize();
|
||||
},
|
||||
intervals() {
|
||||
this.drawGrid();
|
||||
},
|
||||
timezone() {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup {
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
|
||||
display: block;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 270px;
|
||||
z-index: 3;
|
||||
& .corner {
|
||||
border-left: 15px solid transparent;
|
||||
border-right: 15px solid transparent;
|
||||
border-top: 10px solid #ffffff;
|
||||
bottom: -10px;
|
||||
content: ' ';
|
||||
display: block;
|
||||
height: 0;
|
||||
left: 15px;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
.canvas {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
touch-action: pan-y;
|
||||
cursor: move;
|
||||
&::v-deep canvas {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.scrollbar-top {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1.5rem;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.scrollbar-top {
|
||||
& > div {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #2e2ef9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.canvas {
|
||||
.scrollbar-top {
|
||||
top: -1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="team_sidebar">
|
||||
<div class="row team_sidebar__heading">
|
||||
<div class="col-12">
|
||||
<span
|
||||
:class="{ 'team_sidebar__heading-active': this.sort === 'user' }"
|
||||
class="team_sidebar__heading-toggle"
|
||||
@click="selectColumn('user')"
|
||||
>{{ $t('dashboard.user') }}
|
||||
<template v-if="this.sort === 'user'">
|
||||
<i v-if="this.sortDir === 'asc'" class="icon icon-chevron-down"></i>
|
||||
<i v-else class="icon icon-chevron-up"></i>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-12 flex-end">
|
||||
<span
|
||||
:class="{ 'team_sidebar__heading-active': this.sort === 'worked' }"
|
||||
class="team_sidebar__heading-toggle"
|
||||
@click="selectColumn('worked')"
|
||||
>{{ $t('dashboard.worked') }}
|
||||
<template v-if="this.sort === 'worked'">
|
||||
<i v-if="this.sortDir === 'desc'" class="icon icon-chevron-down"></i>
|
||||
<i v-else class="icon icon-chevron-up"></i>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(user, key) in users" :key="key" class="row team_sidebar__user_wrapper">
|
||||
<div class="col-12 row team_sidebar__user_row">
|
||||
<UserAvatar :user="user" />
|
||||
<div class="team_sidebar__user_info col-24">
|
||||
<div class="team_sidebar__user_name">{{ user.full_name }}</div>
|
||||
<div class="team_sidebar__user_task">
|
||||
<router-link
|
||||
v-if="user.last_interval"
|
||||
:to="`/tasks/view/${user.last_interval.task_id}`"
|
||||
:title="user.last_interval.task_name"
|
||||
target="_blank"
|
||||
>
|
||||
{{ user.last_interval.project_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 flex-end team_sidebar__user_worked">
|
||||
{{ formatDurationString(user.worked) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import { mapGetters } from 'vuex';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
export default {
|
||||
name: 'TeamSidebar',
|
||||
components: { UserAvatar },
|
||||
props: {
|
||||
sort: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
sortDir: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['intervals']),
|
||||
},
|
||||
methods: {
|
||||
formatDurationString,
|
||||
selectColumn(column) {
|
||||
this.$emit('sort', column);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.team_sidebar {
|
||||
&__heading {
|
||||
font-weight: 600;
|
||||
color: #b1b1be;
|
||||
padding-right: 9px;
|
||||
|
||||
&-active {
|
||||
color: #59566e;
|
||||
padding-right: 14px;
|
||||
}
|
||||
&-toggle {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -3px;
|
||||
transform: translateY(-46%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__user {
|
||||
&_name {
|
||||
font-size: 10pt;
|
||||
font-weight: 500;
|
||||
color: #151941;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&_row {
|
||||
height: 65px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&_worked {
|
||||
color: #59566e;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&_task {
|
||||
font-size: 9pt;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&_info {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 780px) {
|
||||
.team_sidebar {
|
||||
&__heading {
|
||||
display: grid;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-rows: repeat(2, calc(39px / 2));
|
||||
font-size: 0.8rem;
|
||||
& > div {
|
||||
max-width: 100%;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
&__user_wrapper {
|
||||
height: 65px;
|
||||
display: grid;
|
||||
grid-template-rows: 3fr 1fr;
|
||||
grid-template-columns: 100%;
|
||||
}
|
||||
&__user_task {
|
||||
display: none;
|
||||
}
|
||||
&__user_worked {
|
||||
max-width: 100%;
|
||||
align-self: flex-end;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
&__user_row {
|
||||
max-width: 80%;
|
||||
height: auto;
|
||||
align-self: end;
|
||||
}
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div ref="canvas" class="canvas" @pointerdown="onDown">
|
||||
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
|
||||
<div :style="{ width: `${totalWidth}px` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { SVG } from '@svgdotjs/svg.js';
|
||||
|
||||
const defaultColorConfig = [
|
||||
{
|
||||
start: 0,
|
||||
end: 0.75,
|
||||
color: '#ffb6c2',
|
||||
},
|
||||
{
|
||||
start: 0.76,
|
||||
end: 1,
|
||||
color: '#93ecda',
|
||||
},
|
||||
{
|
||||
start: 1,
|
||||
end: 0,
|
||||
color: '#3cd7b6',
|
||||
isOverTime: true,
|
||||
},
|
||||
];
|
||||
|
||||
const titleHeight = 20;
|
||||
const subtitleHeight = 20;
|
||||
const rowHeight = 65;
|
||||
const minColumnWidth = 85;
|
||||
export default {
|
||||
name: 'TeamTableGraph',
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
timePerDay: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastPosX: 0,
|
||||
offsetX: 0,
|
||||
totalWidth: 0,
|
||||
scrollPos: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', ['companyData']),
|
||||
workingHours() {
|
||||
return 'work_time' in this.companyData && this.companyData.work_time ? this.companyData.work_time : 7;
|
||||
},
|
||||
colorRules() {
|
||||
return this.companyData.color ? this.companyData.color : defaultColorConfig;
|
||||
},
|
||||
columns() {
|
||||
const start = moment(this.start, 'YYYY-MM-DD');
|
||||
const end = moment(this.end, 'YYYY-MM-DD');
|
||||
|
||||
return end.diff(start, 'days') + 1;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.draw = SVG();
|
||||
this.onResize();
|
||||
window.addEventListener('resize', this.onResize);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
},
|
||||
methods: {
|
||||
height() {
|
||||
return this.users.length * rowHeight;
|
||||
},
|
||||
canvasWidth() {
|
||||
return this.$refs.canvas.clientWidth;
|
||||
},
|
||||
columnWidth() {
|
||||
return Math.max(minColumnWidth, this.canvasWidth() / this.columns);
|
||||
},
|
||||
async contentWidth() {
|
||||
await this.$nextTick();
|
||||
this.totalWidth = this.columns * this.columnWidth();
|
||||
return this.totalWidth;
|
||||
},
|
||||
isDateWithinRange(dateString, startDate, endDate) {
|
||||
const date = new Date(dateString);
|
||||
return date >= new Date(startDate) && date <= new Date(endDate);
|
||||
},
|
||||
getColor(progress) {
|
||||
let color = '#3cd7b6';
|
||||
|
||||
this.colorRules.forEach(el => {
|
||||
if ('isOverTime' in el && progress > el.start) {
|
||||
color = el.color;
|
||||
} else if (progress >= el.start && progress <= el.end) {
|
||||
color = el.color;
|
||||
}
|
||||
});
|
||||
|
||||
return color;
|
||||
},
|
||||
onDown(e) {
|
||||
this.$refs.canvas.addEventListener('pointermove', this.onMove);
|
||||
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
|
||||
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
|
||||
},
|
||||
async maxScrollX() {
|
||||
return (await this.contentWidth()) - this.canvasWidth();
|
||||
},
|
||||
async scrollCanvas(movementX, setScroll = true) {
|
||||
const canvas = this.$refs.canvas;
|
||||
const clientWidth = canvas.clientWidth;
|
||||
const entireWidth = await this.contentWidth();
|
||||
const height = this.height();
|
||||
const newScrollPos = this.scrollPos - movementX;
|
||||
if (newScrollPos <= 0) {
|
||||
this.scrollPos = 0;
|
||||
} else if (newScrollPos >= entireWidth - clientWidth) {
|
||||
this.scrollPos = entireWidth - clientWidth;
|
||||
} else {
|
||||
this.scrollPos = newScrollPos;
|
||||
}
|
||||
setScroll ? await this.setScroll() : null;
|
||||
this.draw.viewbox(this.scrollPos, 20, clientWidth, height);
|
||||
},
|
||||
async onMove(e) {
|
||||
this.$refs.canvas.setPointerCapture(e.pointerId);
|
||||
await this.scrollCanvas(e.movementX);
|
||||
},
|
||||
onUp(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onCancel(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onScroll(e) {
|
||||
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
|
||||
},
|
||||
async setScroll(x = null) {
|
||||
await this.$nextTick();
|
||||
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
|
||||
},
|
||||
formatDuration: formatDurationString,
|
||||
drawGrid: async function () {
|
||||
if (typeof this.draw === 'undefined') return;
|
||||
this.draw.clear();
|
||||
const draw = this.draw;
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const columnWidth = this.columnWidth();
|
||||
const height = this.height();
|
||||
if (height <= 0) {
|
||||
return;
|
||||
}
|
||||
const start = moment(this.start, 'YYYY-MM-DD');
|
||||
|
||||
const cursor = (await this.contentWidth()) > this.canvasWidth() ? 'move' : 'default';
|
||||
draw.addTo(canvasContainer).size(width, height + titleHeight + subtitleHeight);
|
||||
draw.viewbox(0, 20, width, height);
|
||||
// Background
|
||||
draw.rect((await this.contentWidth()) - 1, height - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.fill('#fafafa')
|
||||
.stroke({ color: '#dfe5ed', width: 1 })
|
||||
.attr({
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
})
|
||||
.on('mousedown', () => this.$emit('outsideClick'));
|
||||
for (let column = 0; column < this.columns; ++column) {
|
||||
const date = start.clone().locale(this.$i18n.locale).add(column, 'days');
|
||||
let left = this.columnWidth() * column;
|
||||
let halfColumnWidth = this.columnWidth() / 2;
|
||||
// Column headers - day
|
||||
draw.text(date.locale(this.$i18n.locale).format('D'))
|
||||
.move(left + halfColumnWidth, 0)
|
||||
.size(columnWidth, titleHeight)
|
||||
.font({
|
||||
family: 'Nunito, sans-serif',
|
||||
size: 15,
|
||||
fill: '#151941',
|
||||
})
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
|
||||
// Column headers - am/pm
|
||||
draw.text(date.format('dddd').toUpperCase())
|
||||
.move(left + halfColumnWidth, titleHeight - 5)
|
||||
.size(columnWidth, subtitleHeight)
|
||||
.font({
|
||||
family: 'Nunito, sans-serif',
|
||||
size: 10,
|
||||
weight: '600',
|
||||
fill: '#b1b1be',
|
||||
})
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
|
||||
// Vertical grid lines
|
||||
if (column > 0) {
|
||||
draw.line(0, titleHeight + subtitleHeight, 0, height + titleHeight + subtitleHeight)
|
||||
.move(left, titleHeight + subtitleHeight)
|
||||
.stroke({ color: '#DFE5ED', width: 1 })
|
||||
.attr({
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredData = {};
|
||||
for (let key in this.timePerDay) {
|
||||
const innerObject = this.timePerDay[key];
|
||||
const filteredInnerObject = {};
|
||||
for (let dateKey in innerObject) {
|
||||
if (this.isDateWithinRange(dateKey, this.start, this.end)) {
|
||||
filteredInnerObject[dateKey] = innerObject[dateKey];
|
||||
}
|
||||
}
|
||||
filteredData[key] = filteredInnerObject;
|
||||
}
|
||||
const clipPath = draw
|
||||
.rect((await this.contentWidth()) - 1, height - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.attr({
|
||||
absolutePositioned: true,
|
||||
});
|
||||
const squaresGroup = draw.group().clipWith(clipPath);
|
||||
for (const [row, user] of this.users.entries()) {
|
||||
const top = row * rowHeight + titleHeight + subtitleHeight;
|
||||
const userTime = filteredData[user.id];
|
||||
|
||||
if (userTime) {
|
||||
for (const day of Object.keys(userTime)) {
|
||||
const column = -start.diff(day, 'days');
|
||||
const duration = userTime[day];
|
||||
const left = (column * (await this.contentWidth())) / this.columns;
|
||||
const total = 60 * 60 * this.workingHours;
|
||||
const progress = duration / total;
|
||||
const height = Math.ceil(Math.min(progress, 1) * (rowHeight - 1));
|
||||
const color = this.getColor(progress);
|
||||
const rect = draw
|
||||
.rect(this.columnWidth(), height + 1)
|
||||
.move(left, Math.floor(top + (rowHeight - height)) - 1)
|
||||
.fill(color)
|
||||
.stroke({ width: 0 })
|
||||
.attr({
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
squaresGroup.add(rect);
|
||||
|
||||
// Time label
|
||||
draw.text(this.formatDuration(duration))
|
||||
.move(this.columnWidth() / 2 + left, top + 22)
|
||||
.size(this.columnWidth(), rowHeight)
|
||||
.font({
|
||||
family: 'Nunito, sans-serif',
|
||||
size: 15,
|
||||
weight: '600',
|
||||
fill: '#151941',
|
||||
})
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Horizontal grid lines
|
||||
if (row > 0) {
|
||||
draw.line(0, 0, await this.contentWidth(), 0)
|
||||
.move(0, top)
|
||||
.stroke({ color: '#dfe5ed', width: 1 })
|
||||
.attr({
|
||||
cursor: cursor,
|
||||
hoverCursor: cursor,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onResize: function () {
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const height = this.height();
|
||||
this.draw.size(width, height);
|
||||
this.setScroll(0);
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
start() {
|
||||
this.setScroll(0);
|
||||
},
|
||||
end() {
|
||||
this.setScroll(0);
|
||||
},
|
||||
users() {
|
||||
this.onResize();
|
||||
},
|
||||
timePerDay() {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.canvas {
|
||||
user-select: none;
|
||||
touch-action: pan-y;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.scrollbar-top {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1.5rem;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.scrollbar-top {
|
||||
& > div {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #2e2ef9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.canvas {
|
||||
.scrollbar-top {
|
||||
top: -1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div>
|
||||
<transition name="slide-up">
|
||||
<div v-if="intervals.length" class="time-interval-edit-panel">
|
||||
<div class="container-fluid">
|
||||
<div class="row flex-middle flex-between">
|
||||
<div class="time-interval-edit-panel__time col-4">
|
||||
{{ $t('field.selected') }}:
|
||||
<strong>{{ formattedTotalTime }}</strong>
|
||||
</div>
|
||||
<div class="time-interval-edit-panel__buttons col-12 flex flex-end">
|
||||
<at-button
|
||||
:disabled="disabledButtons"
|
||||
class="time-interval-edit-panel__btn"
|
||||
@click="openAddNewTaskModal"
|
||||
>
|
||||
{{ $t('control.add_new_task') }}
|
||||
</at-button>
|
||||
|
||||
<at-button
|
||||
:disabled="disabledButtons"
|
||||
class="time-interval-edit-panel__btn"
|
||||
@click="openChangeTaskModal"
|
||||
>
|
||||
{{ $t('control.edit_intervals') }}
|
||||
</at-button>
|
||||
|
||||
<at-button
|
||||
:disabled="disabledButtons"
|
||||
class="time-interval-edit-panel__btn"
|
||||
type="error"
|
||||
@click="deleteTimeIntervals"
|
||||
>
|
||||
<i class="icon icon-trash" />
|
||||
{{ $t('control.delete') }}
|
||||
</at-button>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<at-button class="time-interval-edit-panel__btn" @click="$emit('close')">
|
||||
{{ $t('control.cancel') }}
|
||||
</at-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="modals">
|
||||
<template v-if="showAddNewTaskModal">
|
||||
<AddNewTaskModal
|
||||
:disableButtons="disabledButtons"
|
||||
:showModal="showAddNewTaskModal"
|
||||
@cancel="onAddNewTaskModalCancel"
|
||||
@confirm="onAddNewTaskModalConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="showChangeTaskModal">
|
||||
<ChangeTaskModal
|
||||
:disableButtons="disabledButtons"
|
||||
:showModal="showChangeTaskModal"
|
||||
@cancel="onChangeTaskModalCancel"
|
||||
@confirm="onChangeTaskModalConfirm"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AddNewTaskModal from './AddNewTaskModal';
|
||||
import ChangeTaskModal from './ChangeTaskModal';
|
||||
import TasksService from '@/services/resource/task.service';
|
||||
import TimeIntervalsService from '@/services/resource/time-interval.service';
|
||||
|
||||
export default {
|
||||
name: 'TimeIntervalEdit',
|
||||
components: {
|
||||
AddNewTaskModal,
|
||||
ChangeTaskModal,
|
||||
},
|
||||
props: {
|
||||
intervals: {
|
||||
type: Array,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', ['user']),
|
||||
showAddNewTaskModal() {
|
||||
return this.modal === 'addNewTask';
|
||||
},
|
||||
showChangeTaskModal() {
|
||||
return this.modal === 'changeTask';
|
||||
},
|
||||
formattedTotalTime() {
|
||||
return moment
|
||||
.utc(this.intervals.reduce((total, curr) => total + curr.duration * 1000, 0))
|
||||
.format('HH:mm:ss');
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tasksService: new TasksService(),
|
||||
timeIntervalsService: new TimeIntervalsService(),
|
||||
modal: '',
|
||||
disabledButtons: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async saveTimeIntervals(data) {
|
||||
try {
|
||||
this.disabledButtons = true;
|
||||
|
||||
await this.timeIntervalsService.bulkEdit(data);
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.save.success.title'),
|
||||
message: this.$t('notification.screenshot.save.success.message'),
|
||||
});
|
||||
|
||||
this.$emit('edit');
|
||||
|
||||
this.modal = '';
|
||||
this.disabledButtons = false;
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.save.error.title'),
|
||||
message: this.$t('notification.screenshot.save.error.message'),
|
||||
});
|
||||
|
||||
this.disabledButtons = false;
|
||||
}
|
||||
},
|
||||
async deleteTimeIntervals() {
|
||||
try {
|
||||
this.disabledButtons = true;
|
||||
|
||||
await this.timeIntervalsService.bulkDelete({
|
||||
intervals: this.intervals.map(el => el.id),
|
||||
});
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.delete.success.title'),
|
||||
message: this.$t('notification.screenshot.delete.success.message'),
|
||||
});
|
||||
|
||||
this.$emit('remove', this.intervals);
|
||||
this.disabledButtons = false;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.delete.error.title'),
|
||||
message: this.$t('notification.screenshot.delete.error.message'),
|
||||
});
|
||||
|
||||
this.disabledButtons = false;
|
||||
}
|
||||
},
|
||||
async createTask(projectId, taskName, taskDescription) {
|
||||
try {
|
||||
this.disabledButtons = true;
|
||||
|
||||
const taskResponse = await this.tasksService.save(
|
||||
{
|
||||
project_id: projectId,
|
||||
task_name: taskName,
|
||||
description: taskDescription,
|
||||
user_id: this.user.id,
|
||||
active: true,
|
||||
priority_id: 2,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const task = taskResponse.data.res;
|
||||
const intervals = this.intervals.map(i => ({
|
||||
id: i.id,
|
||||
task_id: task.id,
|
||||
}));
|
||||
await this.timeIntervalsService.bulkEdit({ intervals });
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.save.success.title'),
|
||||
message: this.$t('notification.screenshot.save.success.message'),
|
||||
});
|
||||
|
||||
this.$emit('edit');
|
||||
|
||||
this.modal = '';
|
||||
this.disabledButtons = false;
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.save.error.title'),
|
||||
message: this.$t('notification.screenshot.save.error.message'),
|
||||
});
|
||||
|
||||
this.disabledButtons = false;
|
||||
}
|
||||
},
|
||||
openAddNewTaskModal() {
|
||||
this.modal = 'addNewTask';
|
||||
},
|
||||
openChangeTaskModal() {
|
||||
this.modal = 'changeTask';
|
||||
},
|
||||
onAddNewTaskModalConfirm({ projectId, taskName, taskDescription }) {
|
||||
this.createTask(projectId, taskName, taskDescription);
|
||||
},
|
||||
onChangeTaskModalConfirm(taskId) {
|
||||
const intervals = this.intervals.map(i => ({ id: i.id, task_id: taskId }));
|
||||
this.saveTimeIntervals({ intervals });
|
||||
},
|
||||
onAddNewTaskModalCancel() {
|
||||
this.modal = '';
|
||||
},
|
||||
onChangeTaskModalCancel() {
|
||||
this.modal = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.time-interval-edit-panel {
|
||||
border-top: 1px solid $gray-4;
|
||||
padding: 15px 0;
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
background-color: #fff;
|
||||
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
|
||||
&__buttons {
|
||||
gap: $layout-01;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 790px) {
|
||||
.time-interval-edit-panel {
|
||||
&__time {
|
||||
flex-basis: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
&__buttons {
|
||||
flex-basis: 100%;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
.modals ::v-deep .at-modal {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
background-color: $gray-4;
|
||||
width: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div ref="canvas" class="canvas"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import { SVG } from '@svgdotjs/svg.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const headerHeight = 20;
|
||||
const columns = 7;
|
||||
const rowHeight = 120;
|
||||
|
||||
export default {
|
||||
name: 'TimelineCalendarGraph',
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
timePerDay: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.draw = SVG();
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.drawGrid();
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatDuration: formatDurationString,
|
||||
drawGrid: debounce(
|
||||
function () {
|
||||
if (typeof this.draw === 'undefined') return;
|
||||
this.draw.clear();
|
||||
|
||||
const startOfMonth = moment(this.start, 'YYYY-MM-DD').startOf('month');
|
||||
const endOfMonth = moment(this.start, 'YYYY-MM-DD').endOf('month');
|
||||
const firstDay = startOfMonth.clone().startOf('isoWeek');
|
||||
const lastDay = endOfMonth.clone().endOf('isoWeek');
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const columnWidth = width / 7;
|
||||
const rows = lastDay.diff(firstDay, 'weeks') + 1;
|
||||
const draw = this.draw;
|
||||
draw.addTo(canvasContainer).size(width, headerHeight + rowHeight * 6);
|
||||
|
||||
draw.rect(width - 2, rows * rowHeight - 1)
|
||||
.move(1, headerHeight)
|
||||
.radius(20)
|
||||
.fill('#FAFAFA')
|
||||
.stroke({ color: '#dfe5ed', width: 1 });
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const date = firstDay.clone().locale(this.$i18n.locale).add(column, 'days');
|
||||
const dateFormat = window.matchMedia('(max-width: 880px)').matches ? 'ddd' : 'dddd';
|
||||
draw.text(date.format(dateFormat).toUpperCase())
|
||||
.move(column * columnWidth + columnWidth / 2, -5)
|
||||
.width(columnWidth)
|
||||
.height(headerHeight)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 10,
|
||||
'font-weight': 600,
|
||||
fill: '#2E2EF9',
|
||||
});
|
||||
}
|
||||
|
||||
this.drawCells(draw, firstDay, columnWidth, rows, width, lastDay);
|
||||
this.drawGridLines(draw, rows, width, columnWidth);
|
||||
},
|
||||
30,
|
||||
{ maxWait: 50 },
|
||||
),
|
||||
drawGridLines(draw, rows, width, columnWidth) {
|
||||
for (let row = 1; row < rows; row++) {
|
||||
draw.line(1, row * rowHeight + headerHeight, width - 1, row * rowHeight + headerHeight).stroke({
|
||||
color: '#DFE5ED',
|
||||
width: 1,
|
||||
});
|
||||
}
|
||||
|
||||
for (let column = 1; column < columns; column++) {
|
||||
draw.line(
|
||||
column * columnWidth,
|
||||
headerHeight,
|
||||
column * columnWidth,
|
||||
headerHeight + rowHeight * rows - 1,
|
||||
).stroke({
|
||||
color: '#DFE5ED',
|
||||
width: 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
drawCells(draw, firstDay, columnWidth, rows, width, lastDay) {
|
||||
const squaresGroup = draw.group();
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const date = firstDay
|
||||
.clone()
|
||||
.locale(this.$i18n.locale)
|
||||
.add(row * columns + column, 'days');
|
||||
const cellLeft = column * columnWidth;
|
||||
const cellTop = headerHeight + row * rowHeight;
|
||||
const isInSelection = date.diff(this.start) >= 0 && date.diff(this.end) <= 0;
|
||||
const { timePerDay } = this;
|
||||
|
||||
if (isInSelection) {
|
||||
const square = draw.rect(columnWidth - 2, rowHeight - 2).attr({
|
||||
fill: '#F4F4FF',
|
||||
x: cellLeft + 1,
|
||||
y: cellTop + 1,
|
||||
});
|
||||
|
||||
squaresGroup.add(square);
|
||||
|
||||
const line = draw
|
||||
.line(
|
||||
column * columnWidth,
|
||||
(row + 1) * rowHeight + headerHeight - 2,
|
||||
(column + 1) * columnWidth,
|
||||
(row + 1) * rowHeight + headerHeight - 2,
|
||||
)
|
||||
.stroke({
|
||||
color: '#2E2EF9',
|
||||
width: 3,
|
||||
});
|
||||
squaresGroup.add(line);
|
||||
}
|
||||
draw.text(date.format('D'))
|
||||
.move((column + 1) * columnWidth - 10, row * rowHeight + headerHeight + 5)
|
||||
.attr({
|
||||
'text-anchor': 'end',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 12,
|
||||
'font-weight': isInSelection ? 600 : 400,
|
||||
fill: isInSelection ? '#2E2EF9' : '#868495',
|
||||
});
|
||||
|
||||
const dateKey = date.format('YYYY-MM-DD');
|
||||
if (timePerDay[dateKey]) {
|
||||
draw.text(this.formatDuration(timePerDay[dateKey]))
|
||||
.move(cellLeft, cellTop + rowHeight - 30)
|
||||
.attr({
|
||||
'text-anchor': 'inherit',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-weight': isInSelection ? 600 : 400,
|
||||
'my-text-type': 'time',
|
||||
fill: '#59566E',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let clip = draw.clip();
|
||||
clip.add(
|
||||
draw
|
||||
.rect(width - 4, (lastDay.diff(firstDay, 'weeks') + 1) * rowHeight - 1.5)
|
||||
.move(2, headerHeight)
|
||||
.radius(20),
|
||||
);
|
||||
squaresGroup.clipWith(clip);
|
||||
},
|
||||
onResize: function () {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
start() {
|
||||
this.onResize();
|
||||
},
|
||||
end() {
|
||||
this.onResize();
|
||||
},
|
||||
timePerDay() {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.canvas ::v-deep svg {
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
text[my-text-type='time'] {
|
||||
font-size: 0.9rem;
|
||||
transform: translateX(13px);
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
text[my-text-type='time'] {
|
||||
font-size: 0.7rem;
|
||||
transform: translateX(7px);
|
||||
}
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
text[my-text-type='time'] {
|
||||
font-size: 0.6rem;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="canvas-wrapper">
|
||||
<div
|
||||
v-show="hoverPopup.show && !clickPopup.show"
|
||||
:style="{
|
||||
left: `${hoverPopup.x - 30}px`,
|
||||
bottom: `${hoverPopup.y}px`,
|
||||
}"
|
||||
class="popup"
|
||||
>
|
||||
<div v-if="hoverPopup.event">
|
||||
{{ hoverPopup.event.task_name }}
|
||||
({{ hoverPopup.event.project_name }})
|
||||
</div>
|
||||
|
||||
<div v-if="hoverPopup.event">
|
||||
{{ formatDuration(hoverPopup.event.duration) }}
|
||||
</div>
|
||||
|
||||
<a :style="{ left: `${hoverPopup.borderX}px` }" class="corner"></a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="clickPopup.show"
|
||||
:data-offset="`${clickPopup.borderX}px`"
|
||||
:style="{
|
||||
left: `${clickPopup.x - 30}px`,
|
||||
bottom: `${clickPopup.y}px`,
|
||||
}"
|
||||
class="popup"
|
||||
>
|
||||
<Screenshot
|
||||
v-if="clickPopup.event && screenshotsEnabled"
|
||||
:disableModal="true"
|
||||
:lazyImage="false"
|
||||
:project="{ id: clickPopup.event.project_id, name: clickPopup.event.project_name }"
|
||||
:interval="clickPopup.event"
|
||||
:showText="false"
|
||||
:task="{ id: clickPopup.event.task_id, name: clickPopup.event.task_name }"
|
||||
:user="clickPopup.event"
|
||||
@click="showPopup"
|
||||
/>
|
||||
|
||||
<div v-if="clickPopup.event">
|
||||
<router-link :to="`/tasks/view/${clickPopup.event.task_id}`">
|
||||
{{ clickPopup.event.task_name }}
|
||||
</router-link>
|
||||
|
||||
<router-link :to="`/projects/view/${clickPopup.event.project_id}`">
|
||||
({{ clickPopup.event.project_name }})
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<a :style="{ left: `${clickPopup.borderX}px` }" class="corner" />
|
||||
</div>
|
||||
|
||||
<ScreenshotModal
|
||||
:project="modal.project"
|
||||
:interval="modal.interval"
|
||||
:show="modal.show"
|
||||
:showNavigation="true"
|
||||
:task="modal.task"
|
||||
:user="modal.user"
|
||||
@close="onHide"
|
||||
@remove="onRemove"
|
||||
@showNext="showNext"
|
||||
@showPrevious="showPrevious"
|
||||
/>
|
||||
<div ref="canvas" class="canvas" @pointerdown="onDown">
|
||||
<div ref="scrollbarTop" class="scrollbar-top" @scroll="onScroll">
|
||||
<div :style="{ width: `${totalWidth}px` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment-timezone';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import Screenshot from '@/components/Screenshot';
|
||||
import ScreenshotModal from '@/components/ScreenshotModal';
|
||||
import IntervalService from '@/services/resource/time-interval.service';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { SVG } from '@svgdotjs/svg.js';
|
||||
|
||||
const titleHeight = 20;
|
||||
const subtitleHeight = 20;
|
||||
const timelineHeight = 80;
|
||||
const columns = 24;
|
||||
const minColumnWidth = 37;
|
||||
const popupWidth = 270;
|
||||
const canvasPadding = 20;
|
||||
const defaultCornerOffset = 15;
|
||||
|
||||
export default {
|
||||
name: 'TimelineDayGraph',
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
events: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
timezone: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Screenshot,
|
||||
ScreenshotModal,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['tasks', 'intervals']),
|
||||
...mapGetters('user', ['user', 'companyData']),
|
||||
...mapGetters('screenshots', { screenshotsEnabled: 'enabled' }),
|
||||
height() {
|
||||
return timelineHeight + titleHeight + subtitleHeight;
|
||||
},
|
||||
tasks() {
|
||||
if (!this.user) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const userIntervals = this.intervals[this.user.id];
|
||||
if (!userIntervals) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return userIntervals.intervals
|
||||
.map(interval => interval.task)
|
||||
.reduce((obj, task) => ({ ...obj, [task.id]: task }), {});
|
||||
},
|
||||
projects() {
|
||||
return Object.keys(this.tasks)
|
||||
.map(taskID => this.tasks[taskID])
|
||||
.reduce((projects, task) => ({ ...projects, [task.project_id]: task.project }), {});
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hoverPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
event: null,
|
||||
borderX: 0,
|
||||
},
|
||||
clickPopup: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
event: null,
|
||||
borderX: 0,
|
||||
},
|
||||
intervalService: new IntervalService(),
|
||||
modal: {
|
||||
interval: null,
|
||||
project: null,
|
||||
task: null,
|
||||
show: false,
|
||||
},
|
||||
scrollPos: 0,
|
||||
totalWidth: 0,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.draw = SVG();
|
||||
this.onResize();
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('mousedown', this.onClick);
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('mousedown', this.onClick);
|
||||
window.removeEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
methods: {
|
||||
formatDuration: formatDurationString,
|
||||
showPopup() {
|
||||
this.modal = {
|
||||
show: true,
|
||||
project: { id: this.clickPopup.event.project_id, name: this.clickPopup.event.project_name },
|
||||
user: this.clickPopup.event,
|
||||
task: { id: this.clickPopup.event.task_id, name: this.clickPopup.event.task_name },
|
||||
interval: this.clickPopup.event,
|
||||
};
|
||||
},
|
||||
onHide() {
|
||||
this.modal.show = false;
|
||||
},
|
||||
onKeyDown(e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
this.showPrevious();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
this.showNext();
|
||||
}
|
||||
},
|
||||
showPrevious() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
const interval = intervals[currentIndex - 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
showNext() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex < intervals.length - 1) {
|
||||
const interval = intervals[currentIndex + 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
canvasWidth() {
|
||||
return this.$refs.canvas.clientWidth;
|
||||
},
|
||||
columnWidth() {
|
||||
return Math.max(minColumnWidth, this.canvasWidth() / columns);
|
||||
},
|
||||
async contentWidth() {
|
||||
await this.$nextTick();
|
||||
this.totalWidth = columns * this.columnWidth();
|
||||
return this.totalWidth;
|
||||
},
|
||||
onDown(e) {
|
||||
this.$refs.canvas.addEventListener('pointermove', this.onMove);
|
||||
this.$refs.canvas.addEventListener('pointerup', this.onUp, { once: true });
|
||||
this.$refs.canvas.addEventListener('pointercancel', this.onCancel, { once: true });
|
||||
},
|
||||
async scrollCanvas(movementX, setScroll = true) {
|
||||
const canvas = this.$refs.canvas;
|
||||
const clientWidth = canvas.clientWidth;
|
||||
const entireWidth = await this.contentWidth();
|
||||
const height = this.height;
|
||||
const newScrollPos = this.scrollPos - movementX;
|
||||
if (newScrollPos <= 0) {
|
||||
this.scrollPos = 0;
|
||||
} else if (newScrollPos >= entireWidth - clientWidth) {
|
||||
this.scrollPos = entireWidth - clientWidth;
|
||||
} else {
|
||||
this.scrollPos = newScrollPos;
|
||||
}
|
||||
setScroll ? await this.setScroll() : null;
|
||||
this.draw.viewbox(this.scrollPos, 0, clientWidth, height);
|
||||
},
|
||||
async onMove(e) {
|
||||
this.$refs.canvas.setPointerCapture(e.pointerId);
|
||||
await this.scrollCanvas(e.movementX);
|
||||
},
|
||||
onUp(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onCancel(e) {
|
||||
this.$refs.canvas.removeEventListener('pointermove', this.onMove);
|
||||
},
|
||||
onScroll(e) {
|
||||
this.scrollCanvas(this.scrollPos - this.$refs.scrollbarTop.scrollLeft, false);
|
||||
},
|
||||
async setScroll(x = null) {
|
||||
await this.$nextTick();
|
||||
this.$refs.scrollbarTop.scrollLeft = x ?? this.scrollPos;
|
||||
},
|
||||
drawGrid: async function () {
|
||||
if (typeof this.draw === 'undefined') return;
|
||||
this.draw.clear();
|
||||
const draw = this.draw;
|
||||
// const width = draw.width();
|
||||
const canvasContainer = this.$refs.canvas;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const columnWidth = this.columnWidth();
|
||||
draw.addTo(canvasContainer).size(width, this.height);
|
||||
this.draw.viewbox(0, 0, width, this.height);
|
||||
// Background
|
||||
this.draw
|
||||
.rect(await this.contentWidth(), timelineHeight - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.fill('#fafafa')
|
||||
.stroke({ color: '#dfe5ed', width: 1 })
|
||||
.on('mousedown', () => this.$emit('outsideClick'));
|
||||
const maxLeftOffset = width - popupWidth + 2 * canvasPadding;
|
||||
const minLeftOffset = canvasPadding / 2;
|
||||
const clipPath = draw
|
||||
.rect(await this.contentWidth(), timelineHeight - 1)
|
||||
.move(0, titleHeight + subtitleHeight)
|
||||
.radius(20)
|
||||
.attr({
|
||||
absolutePositioned: true,
|
||||
});
|
||||
const squaresGroup = draw.group().clipWith(clipPath);
|
||||
for (let i = 0; i < columns; ++i) {
|
||||
const date = moment().startOf('day').add(i, 'hours');
|
||||
const left = columnWidth * i;
|
||||
|
||||
// Column header - hour
|
||||
draw.text(date.format('h'))
|
||||
.move(left + columnWidth / 2, 0)
|
||||
.addClass('text-center')
|
||||
.width(columnWidth)
|
||||
.height(titleHeight)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 15,
|
||||
fill: '#151941',
|
||||
});
|
||||
|
||||
// Column header - am/pm
|
||||
draw.text(function (add) {
|
||||
add.tspan(date.format('A')).newLine();
|
||||
})
|
||||
.move(left + columnWidth / 2, titleHeight - 5)
|
||||
.attr({
|
||||
'text-anchor': 'middle',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 10,
|
||||
'font-weight': 600,
|
||||
fill: '#b1b1be',
|
||||
});
|
||||
|
||||
// Vertical grid line
|
||||
if (i > 0) {
|
||||
const line = draw
|
||||
.line(0, 0, 0, timelineHeight)
|
||||
.move(left, titleHeight + subtitleHeight)
|
||||
.stroke({
|
||||
color: '#dfe5ed',
|
||||
width: 1,
|
||||
});
|
||||
squaresGroup.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Intervals
|
||||
this.events.forEach(event => {
|
||||
const leftOffset =
|
||||
moment
|
||||
.tz(event.start_at, this.companyData.timezone)
|
||||
.tz(this.timezone)
|
||||
.diff(moment.tz(this.start, this.timezone).startOf('day'), 'hours', true) % 24;
|
||||
|
||||
const width = ((Math.max(event.duration, 60) + 120) * columnWidth) / 60 / 60;
|
||||
|
||||
const rectInterval = draw
|
||||
.rect(width, 30)
|
||||
.move(Math.floor(leftOffset * columnWidth), titleHeight + subtitleHeight + 22)
|
||||
.radius(3)
|
||||
.attr({
|
||||
'text-anchor': 'inherit',
|
||||
'font-family': 'Nunito, sans-serif',
|
||||
'font-size': 15,
|
||||
'font-weight': 600,
|
||||
fill: event.is_manual == '1' ? '#c4b52d' : '#2dc48d',
|
||||
})
|
||||
.stroke({
|
||||
color: 'transparent',
|
||||
width: 0,
|
||||
})
|
||||
.css({
|
||||
cursor: 'pointer',
|
||||
'pointer-events': 'auto',
|
||||
})
|
||||
.width(width)
|
||||
.height(30);
|
||||
|
||||
rectInterval.on('mouseover', e => {
|
||||
const popupY =
|
||||
document.body.getBoundingClientRect().height - rectInterval.rbox().y + defaultCornerOffset;
|
||||
const canvasRight = this.$refs.canvas.getBoundingClientRect().right;
|
||||
const rectMiddleX = rectInterval.rbox().cx;
|
||||
const minLeft = this.$refs.canvas.getBoundingClientRect().left;
|
||||
const left =
|
||||
rectMiddleX > canvasRight
|
||||
? canvasRight - defaultCornerOffset / 2
|
||||
: rectMiddleX < minLeft
|
||||
? minLeft
|
||||
: rectMiddleX;
|
||||
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
|
||||
const popupX =
|
||||
left > maxRight ? maxRight : left <= minLeft ? minLeft + defaultCornerOffset : left;
|
||||
const arrowX = defaultCornerOffset + left - popupX;
|
||||
this.hoverPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
event,
|
||||
borderX: arrowX,
|
||||
};
|
||||
});
|
||||
|
||||
rectInterval.on('mouseout', e => {
|
||||
this.hoverPopup = {
|
||||
...this.hoverPopup,
|
||||
show: false,
|
||||
};
|
||||
});
|
||||
|
||||
rectInterval.on('mousedown', e => {
|
||||
this.$emit('selectedIntervals', event);
|
||||
const { left: canvasLeft, right: canvasRight } = this.$refs.canvas.getBoundingClientRect();
|
||||
const rectBox = rectInterval.rbox();
|
||||
const popupY =
|
||||
document.body.getBoundingClientRect().height - rectInterval.rbox().y + defaultCornerOffset;
|
||||
const rectMiddleX = rectBox.cx;
|
||||
|
||||
// Determine initial left position within canvas bounds
|
||||
const left =
|
||||
rectMiddleX > canvasRight
|
||||
? canvasRight - defaultCornerOffset / 2
|
||||
: Math.max(rectMiddleX, canvasLeft);
|
||||
|
||||
// Calculate maximum allowed position for popup's left
|
||||
const maxRight = canvasRight - popupWidth + 2 * canvasPadding;
|
||||
const popupX = left > maxRight ? maxRight : Math.max(left, canvasLeft + defaultCornerOffset);
|
||||
|
||||
// Calculate the position for the arrow in the popup
|
||||
const arrowX = defaultCornerOffset + left - popupX;
|
||||
|
||||
this.clickPopup = {
|
||||
show: true,
|
||||
x: popupX,
|
||||
y: popupY,
|
||||
event,
|
||||
borderX: arrowX,
|
||||
};
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
draw.add(rectInterval);
|
||||
});
|
||||
},
|
||||
onClick(e) {
|
||||
if (
|
||||
(e.target &&
|
||||
e.target.parentElement &&
|
||||
!e.target.parentElement.classList.contains(this.draw.node.classList) &&
|
||||
!e.target.closest('.time-interval-edit-panel') &&
|
||||
!e.target.closest('.screenshot') &&
|
||||
!e.target.closest('.modal') &&
|
||||
!e.target.closest('.at-modal') &&
|
||||
!e.target.closest('.popup')) ||
|
||||
(e.target.closest('.time-interval-edit-panel') &&
|
||||
e.target.closest('.time-interval-edit-panel__btn') &&
|
||||
e.target.closest('.at-btn--error')) ||
|
||||
(e.target.closest('.modal') && e.target.closest('.modal-remove'))
|
||||
) {
|
||||
if (this.clickPopup.show) {
|
||||
this.clickPopup.show = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
onResize: function () {
|
||||
this.drawGrid();
|
||||
},
|
||||
async onRemove() {
|
||||
try {
|
||||
await this.intervalService.deleteItem(this.modal.interval.id);
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.delete.success.title'),
|
||||
message: this.$t('notification.screenshot.delete.success.message'),
|
||||
});
|
||||
this.onHide();
|
||||
this.$emit('selectedIntervals', null);
|
||||
this.$emit('remove', [this.modal.interval]);
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.delete.error.title'),
|
||||
message: this.$t('notification.screenshot.delete.error.message'),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
events() {
|
||||
this.drawGrid();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup {
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
|
||||
display: block;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 270px;
|
||||
z-index: 3;
|
||||
& .corner {
|
||||
border-left: 15px solid transparent;
|
||||
border-right: 15px solid transparent;
|
||||
border-top: 10px solid #ffffff;
|
||||
bottom: -10px;
|
||||
content: ' ';
|
||||
display: block;
|
||||
height: 0;
|
||||
left: 15px;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
.canvas {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
touch-action: pan-y;
|
||||
cursor: move;
|
||||
&::v-deep canvas {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
}
|
||||
.scrollbar-top {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1rem;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.scrollbar-top {
|
||||
& > div {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #2e2ef9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1110px) {
|
||||
.scrollbar-top {
|
||||
top: -0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="screenshots">
|
||||
<h3 class="screenshots__title">{{ $t('field.screenshots') }}</h3>
|
||||
<at-checkbox-group v-model="selectedIntervals">
|
||||
<div class="row">
|
||||
<div
|
||||
v-for="(interval, index) in intervals[this.user.id]"
|
||||
:key="interval.id"
|
||||
class="col-xs-8 col-4 col-xl-3 screenshots__item"
|
||||
>
|
||||
<div class="screenshot" :index="index" @click.shift.prevent.stop="onShiftClick(index)">
|
||||
<Screenshot
|
||||
:disableModal="true"
|
||||
:project="{ id: interval.project_id, name: interval.project_name }"
|
||||
:interval="interval"
|
||||
:task="interval.task"
|
||||
:user="user"
|
||||
:timezone="timezone"
|
||||
@click="showPopup(interval, $event)"
|
||||
/>
|
||||
<div @click="onCheckboxClick(index)">
|
||||
<at-checkbox class="screenshot__checkbox" :label="interval.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScreenshotModal
|
||||
:project="modal.project"
|
||||
:interval="modal.interval"
|
||||
:show="modal.show"
|
||||
:showNavigation="true"
|
||||
:task="modal.task"
|
||||
:user="modal.user"
|
||||
@close="onHide"
|
||||
@remove="onRemove"
|
||||
@showNext="showNext"
|
||||
@showPrevious="showPrevious"
|
||||
/>
|
||||
</div>
|
||||
</at-checkbox-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Screenshot from '@/components/Screenshot';
|
||||
import ScreenshotModal from '@/components/ScreenshotModal';
|
||||
import TimeIntervalService from '@/services/resource/time-interval.service';
|
||||
|
||||
export default {
|
||||
name: 'TimelineScreenshots',
|
||||
components: {
|
||||
Screenshot,
|
||||
ScreenshotModal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
intervalsService: new TimeIntervalService(),
|
||||
selectedIntervals: [],
|
||||
modal: {
|
||||
interval: null,
|
||||
project: null,
|
||||
task: null,
|
||||
show: false,
|
||||
user: null,
|
||||
},
|
||||
firstSelectedCheckboxIndex: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['tasks', 'intervals', 'timezone']),
|
||||
...mapGetters('user', ['user']),
|
||||
projects() {
|
||||
return Object.keys(this.tasks)
|
||||
.map(taskID => this.tasks[taskID])
|
||||
.reduce((projects, task) => ({ ...projects, [task.project_id]: task.project }), {});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('keydown', this.onKeyDown);
|
||||
},
|
||||
methods: {
|
||||
onShiftClick(index) {
|
||||
if (this.firstSelectedCheckboxIndex === null) {
|
||||
this.firstSelectedCheckboxIndex = index;
|
||||
}
|
||||
|
||||
this.selectedIntervals = this.intervals[this.user.id]
|
||||
.slice(
|
||||
Math.min(index, this.firstSelectedCheckboxIndex),
|
||||
Math.max(index, this.firstSelectedCheckboxIndex) + 1,
|
||||
)
|
||||
.map(i => i.id);
|
||||
},
|
||||
onCheckboxClick(index) {
|
||||
if (this.firstSelectedCheckboxIndex === null) {
|
||||
this.firstSelectedCheckboxIndex = index;
|
||||
}
|
||||
},
|
||||
onKeyDown(e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
this.showPrevious();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
this.showNext();
|
||||
}
|
||||
},
|
||||
showPopup(interval, e) {
|
||||
if (e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof interval !== 'object' || interval.id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal = {
|
||||
show: true,
|
||||
project: { id: interval.project_id, name: interval.project_name },
|
||||
user: interval,
|
||||
task: { id: interval.task_id, task_name: interval.task_name },
|
||||
interval,
|
||||
};
|
||||
},
|
||||
onHide() {
|
||||
this.modal.show = false;
|
||||
},
|
||||
showPrevious() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
const interval = intervals[currentIndex - 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
showNext() {
|
||||
const intervals = this.intervals[this.modal.user.user_id];
|
||||
|
||||
const currentIndex = intervals.findIndex(x => x.id === this.modal.interval.id);
|
||||
|
||||
if (currentIndex < intervals.length - 1) {
|
||||
const interval = intervals[currentIndex + 1];
|
||||
if (interval) {
|
||||
this.modal.interval = interval;
|
||||
this.modal.user = interval;
|
||||
this.modal.project = { id: interval.project_id, name: interval.project_name };
|
||||
this.modal.task = { id: interval.task_id, name: interval.task_name };
|
||||
}
|
||||
}
|
||||
},
|
||||
async onRemove(intervalID) {
|
||||
try {
|
||||
await this.intervalsService.deleteItem(intervalID);
|
||||
|
||||
this.$emit('on-remove', [this.modal.interval]);
|
||||
|
||||
this.$Notify({
|
||||
type: 'success',
|
||||
title: this.$t('notification.screenshot.delete.success.title'),
|
||||
message: this.$t('notification.screenshot.delete.success.message'),
|
||||
});
|
||||
|
||||
this.modal.show = false;
|
||||
} catch (e) {
|
||||
this.$Notify({
|
||||
type: 'error',
|
||||
title: this.$t('notification.screenshot.delete.error.title'),
|
||||
message: this.$t('notification.screenshot.delete.error.message'),
|
||||
});
|
||||
}
|
||||
},
|
||||
clearSelectedIntervals() {
|
||||
this.selectedIntervals = [];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedIntervals(intervalIds) {
|
||||
if (intervalIds.length === 0) {
|
||||
this.firstSelectedCheckboxIndex = null;
|
||||
}
|
||||
|
||||
this.$emit(
|
||||
'onSelectedIntervals',
|
||||
this.intervals[this.user.id].filter(i => intervalIds.indexOf(i.id) !== -1),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.screenshots {
|
||||
&__title {
|
||||
color: #b1b1be;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 37px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
margin-bottom: $layout-01;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
position: relative;
|
||||
margin-bottom: $layout-01;
|
||||
|
||||
&__checkbox {
|
||||
left: -5px;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&::v-deep {
|
||||
.screenshot__image {
|
||||
img {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="total-time">
|
||||
<h5>{{ $t('dashboard.total_time') }}:</h5>
|
||||
<h5>
|
||||
<Skeleton :loading="isDataLoading" width="50px">{{ totalTime }} </Skeleton>
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div v-for="project in userProjects" :key="project.id" class="project">
|
||||
<div class="project__header">
|
||||
<Skeleton :loading="isDataLoading" width="100%" height="15px">
|
||||
<div class="project__title">
|
||||
<span class="project__name" :title="project.name">
|
||||
<router-link class="task__title-link" :to="`/projects/view/${project.id}`">
|
||||
{{ project.name }}
|
||||
</router-link>
|
||||
</span>
|
||||
<span class="project__duration">
|
||||
{{ formatDurationString(project.durationAtSelectedPeriod) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- /.project-title -->
|
||||
</Skeleton>
|
||||
</div>
|
||||
<!-- /.project-header -->
|
||||
|
||||
<ul class="task-list">
|
||||
<li
|
||||
v-for="task in getVisibleTasks(project.id)"
|
||||
:key="task.id"
|
||||
class="task"
|
||||
:class="{ 'task-active': activeTask === task.id }"
|
||||
>
|
||||
<Skeleton :loading="isDataLoading" width="100%" height="15px">
|
||||
<h3 class="task__title" :title="task.name">
|
||||
<router-link class="task__title-link" :to="`/tasks/view/${task.id}`">
|
||||
{{ task.name }}
|
||||
</router-link>
|
||||
</h3>
|
||||
|
||||
<div class="task__progress">
|
||||
<at-progress
|
||||
class="task__progressbar"
|
||||
status="success"
|
||||
:stroke-width="5"
|
||||
:percent="getPercentForTaskInProject(task, project)"
|
||||
></at-progress>
|
||||
|
||||
<span class="task__duration">
|
||||
{{ formatDurationString(task.durationAtSelectedPeriod) }}
|
||||
</span>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="getAllTasks(project.id).length > 3">
|
||||
<at-button
|
||||
v-if="!isExpanded(project.id)"
|
||||
class="project__expand"
|
||||
type="text"
|
||||
@click.prevent="expand(project.id)"
|
||||
>
|
||||
{{ $t('projects.show-more') }}
|
||||
</at-button>
|
||||
|
||||
<at-button v-else class="project__shrink" type="text" @click.prevent="shrink(project.id)">
|
||||
{{ $t('projects.show-less') }}
|
||||
</at-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { formatDurationString } from '@/utils/time';
|
||||
import { Skeleton } from 'vue-loading-skeleton';
|
||||
|
||||
export default {
|
||||
name: 'TimelineSidebar',
|
||||
components: {
|
||||
Skeleton,
|
||||
},
|
||||
props: {
|
||||
activeTask: {
|
||||
type: Number,
|
||||
},
|
||||
isDataLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
startDate: {
|
||||
type: String,
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expandedProjects: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['timePerProject']),
|
||||
...mapGetters('user', ['user']),
|
||||
userProjects() {
|
||||
if (!this.user || !this.user.id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.timePerProject[this.user.id]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.values(this.timePerProject[this.user.id]);
|
||||
},
|
||||
totalTime() {
|
||||
const sum = (totalTime, project) => (totalTime += project.durationAtSelectedPeriod);
|
||||
return formatDurationString(this.userProjects.reduce(sum, 0));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isExpanded(projectID) {
|
||||
return this.expandedProjects.indexOf(+projectID) !== -1;
|
||||
},
|
||||
expand(projectID) {
|
||||
this.expandedProjects.push(+projectID);
|
||||
},
|
||||
shrink(projectID) {
|
||||
this.expandedProjects = this.expandedProjects.filter(proj => +proj !== +projectID);
|
||||
},
|
||||
getAllTasks(projectID) {
|
||||
return Object.values(this.timePerProject[this.user.id][projectID].tasks);
|
||||
},
|
||||
getVisibleTasks(projectID) {
|
||||
const tasks = this.getAllTasks(projectID);
|
||||
return this.isExpanded(projectID) ? tasks : tasks.slice(0, 3);
|
||||
},
|
||||
getPercentForTaskInProject(task, project) {
|
||||
return (100 * task.durationAtSelectedPeriod) / project.durationAtSelectedPeriod;
|
||||
},
|
||||
formatDurationString,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.total-time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
padding: 0 20px;
|
||||
margin-bottom: $spacing-05;
|
||||
}
|
||||
|
||||
.project {
|
||||
&__header {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
color: #151941;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
float: right;
|
||||
margin-left: 0.5em;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&__expand,
|
||||
&__shrink {
|
||||
display: block;
|
||||
color: #b1b1be;
|
||||
padding: 0;
|
||||
margin: 5px 0 0 20px;
|
||||
|
||||
&::v-deep .at-btn__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.task-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.task {
|
||||
color: #b1b1be;
|
||||
padding: 5px 20px;
|
||||
|
||||
&::v-deep {
|
||||
.at-progress-bar {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.at-progress-bar__wraper {
|
||||
background: #e0dfed;
|
||||
}
|
||||
|
||||
.at-progress--success .at-progress-bar__inner {
|
||||
background: #2dc38d;
|
||||
}
|
||||
|
||||
.at-progress__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__title-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&__active {
|
||||
background: #f4f4ff;
|
||||
color: #151941;
|
||||
border-left: 3px solid #2e2ef9;
|
||||
|
||||
&::v-deep {
|
||||
.at-progress-bar__wraper {
|
||||
background: #b1b1be;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__progress {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__progressbar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
margin-left: 1em;
|
||||
color: #59566e;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
resources/frontend/core/modules/Dashboard/locales/en.json
Normal file
24
resources/frontend/core/modules/Dashboard/locales/en.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard"
|
||||
},
|
||||
"dashboard": {
|
||||
"timeline": "Timeline",
|
||||
"team": "Team",
|
||||
"user": "User",
|
||||
"worked": "Worked",
|
||||
"total_time": "Total time"
|
||||
},
|
||||
"field": {
|
||||
"task_name": "Name",
|
||||
"task_description": "Description"
|
||||
},
|
||||
"control": {
|
||||
"add_new_task": "Add new task",
|
||||
"edit_intervals": "Change task"
|
||||
},
|
||||
"projects": {
|
||||
"show-more": "Show more",
|
||||
"show-less": "Show less"
|
||||
}
|
||||
}
|
||||
24
resources/frontend/core/modules/Dashboard/locales/ru.json
Normal file
24
resources/frontend/core/modules/Dashboard/locales/ru.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"navigation": {
|
||||
"dashboard": "Обзор"
|
||||
},
|
||||
"dashboard": {
|
||||
"timeline": "Личный",
|
||||
"team": "Командный",
|
||||
"user": "Пользователь",
|
||||
"worked": "Отработано",
|
||||
"total_time": "Общее время"
|
||||
},
|
||||
"field": {
|
||||
"task_name": "Название",
|
||||
"task_description": "Описание"
|
||||
},
|
||||
"control": {
|
||||
"add_new_task": "Добавить новую задачу",
|
||||
"edit_intervals": "Изменить задачу"
|
||||
},
|
||||
"projects": {
|
||||
"show-more": "Показать ещё",
|
||||
"show-less": "Показать меньше"
|
||||
}
|
||||
}
|
||||
70
resources/frontend/core/modules/Dashboard/module.init.js
Normal file
70
resources/frontend/core/modules/Dashboard/module.init.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import Vue from 'vue';
|
||||
import storeModule from './storeModule';
|
||||
import './policies';
|
||||
|
||||
export const ModuleConfig = {
|
||||
routerPrefix: 'dashboard',
|
||||
loadOrder: 20,
|
||||
moduleName: 'Dashboard',
|
||||
};
|
||||
|
||||
export function init(context) {
|
||||
context.addRoute({
|
||||
path: '/dashboard',
|
||||
alias: '/',
|
||||
name: 'dashboard',
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard.vue'),
|
||||
meta: {
|
||||
auth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (
|
||||
Vue.prototype.$can('viewTeamTab', 'dashboard') &&
|
||||
(!localStorage.getItem('dashboard.tab') || localStorage.getItem('dashboard.tab') === 'team')
|
||||
) {
|
||||
return next({ name: 'dashboard.team' });
|
||||
}
|
||||
|
||||
return next({ name: 'dashboard.timeline' });
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'timeline',
|
||||
alias: '/timeline',
|
||||
name: 'dashboard.timeline',
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard/Timeline.vue'),
|
||||
meta: {
|
||||
auth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'team',
|
||||
alias: '/team',
|
||||
name: 'dashboard.team',
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard/Team.vue'),
|
||||
meta: {
|
||||
checkPermission: () => Vue.prototype.$can('viewTeamTab', 'dashboard'),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
context.addNavbarEntry({
|
||||
label: 'navigation.dashboard',
|
||||
to: {
|
||||
path: '/dashboard',
|
||||
},
|
||||
});
|
||||
|
||||
context.addLocalizationData({
|
||||
en: require('./locales/en'),
|
||||
ru: require('./locales/ru'),
|
||||
});
|
||||
|
||||
context.registerVuexModule(storeModule);
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { hasRole } from '@/utils/user';
|
||||
|
||||
export default class DashboardPolicy {
|
||||
static viewTeamTab(user) {
|
||||
return user.can_view_team_tab;
|
||||
}
|
||||
|
||||
static viewManualTime(user) {
|
||||
return hasRole(user, 'admin') || !!user.manual_time;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { store } from '@/store';
|
||||
import DashboardPolicy from './dashboard.policy';
|
||||
|
||||
store.dispatch('policies/registerPolicies', {
|
||||
dashboard: DashboardPolicy,
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import axios from 'axios';
|
||||
import ReportService from '@/services/report.service';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class DashboardService extends ReportService {
|
||||
constructor(context, taskService, userService) {
|
||||
super();
|
||||
this.context = context;
|
||||
|
||||
this.taskService = taskService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
downloadReport(startAt, endAt, users, projects, userTimezone, format, sortCol, sortDir) {
|
||||
return axios.post(
|
||||
'report/dashboard/download',
|
||||
{
|
||||
start_at: startAt,
|
||||
end_at: endAt,
|
||||
users,
|
||||
projects,
|
||||
user_timezone: userTimezone,
|
||||
sort_column: sortCol,
|
||||
sort_direction: sortDir,
|
||||
},
|
||||
{
|
||||
headers: { Accept: format },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getReport(startAt, endAt, users, projects, userTimezone) {
|
||||
return axios.post('report/dashboard', {
|
||||
users,
|
||||
projects,
|
||||
start_at: startAt,
|
||||
end_at: endAt,
|
||||
user_timezone: userTimezone,
|
||||
});
|
||||
}
|
||||
|
||||
unloadIntervals() {
|
||||
this.context.commit('setIntervals', []);
|
||||
}
|
||||
|
||||
load(userIDs, projectIDs, startAt, endAt, userTimezone) {
|
||||
this.getReport(startAt, endAt, userIDs, projectIDs, userTimezone)
|
||||
.then(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data.data;
|
||||
|
||||
this.context.commit('setIntervals', data);
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueProjectIDs = new Set();
|
||||
const uniqueTaskIDs = new Set();
|
||||
Object.keys(data).forEach(userID => {
|
||||
const userIntervals = data[userID];
|
||||
userIntervals.forEach(interval => {
|
||||
uniqueProjectIDs.add(interval.project_id);
|
||||
uniqueTaskIDs.add(interval.task_id);
|
||||
});
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
|
||||
const taskIDs = [...uniqueTaskIDs];
|
||||
if (taskIDs.length) {
|
||||
promises.push(this.loadTasks(taskIDs));
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then(() => {
|
||||
return this.loadUsers();
|
||||
})
|
||||
.then(() =>
|
||||
this.context.commit(
|
||||
'setUsers',
|
||||
this.context.state.users.map(u => {
|
||||
if (Object.prototype.hasOwnProperty.call(this.context.state.intervals, u.id)) {
|
||||
const lastInterval = this.context.state.intervals[u.id].slice(-1)[0];
|
||||
|
||||
if (
|
||||
Math.abs(
|
||||
moment(lastInterval.end_at).diff(
|
||||
moment().subtract(u.screenshot_interval || 1, 'minutes'),
|
||||
'seconds',
|
||||
),
|
||||
) < 10
|
||||
) {
|
||||
return {
|
||||
...u,
|
||||
last_interval: lastInterval,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ...u, last_interval: null };
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(e => {
|
||||
if (!axios.isCancel(e)) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadUsers() {
|
||||
return this.userService
|
||||
.getAll({ headers: { 'X-Paginate': 'false' } })
|
||||
.then(response => {
|
||||
this.context.commit('setUsers', response);
|
||||
return response;
|
||||
})
|
||||
.catch(e => {
|
||||
if (!axios.isCancel(e)) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<AxiosResponse<T>>}
|
||||
* @param taskIDs
|
||||
* @param action
|
||||
*/
|
||||
loadTasks(taskIDs) {
|
||||
return this.taskService
|
||||
.getWithFilters({
|
||||
id: ['=', taskIDs],
|
||||
with: 'project',
|
||||
})
|
||||
.then(response => {
|
||||
if (typeof response !== 'undefined') {
|
||||
const { data } = response;
|
||||
const tasks = data.data.reduce((tasks, task) => {
|
||||
tasks[task.id] = task;
|
||||
return tasks;
|
||||
}, {});
|
||||
|
||||
this.context.commit('setTasks', tasks);
|
||||
|
||||
return tasks;
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (!axios.isCancel(e)) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendInvites(emails) {
|
||||
return axios.post(`register/create`, emails);
|
||||
}
|
||||
}
|
||||
153
resources/frontend/core/modules/Dashboard/storeModule.js
Normal file
153
resources/frontend/core/modules/Dashboard/storeModule.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import moment from 'moment-timezone';
|
||||
import TasksService from '@/services/resource/task.service';
|
||||
import UserService from '@/services/resource/user.service';
|
||||
import DashboardService from '_internal/Dashboard/services/dashboard.service';
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
|
||||
const state = {
|
||||
service: null,
|
||||
intervals: {},
|
||||
tasks: {},
|
||||
users: [],
|
||||
timezone: moment.tz.guess(),
|
||||
};
|
||||
|
||||
const getters = {
|
||||
service: state => state.service,
|
||||
intervals: state => state.intervals,
|
||||
tasks: state => state.tasks,
|
||||
users: state => state.users,
|
||||
timePerProject: (state, getters) => {
|
||||
return Object.keys(getters.intervals).reduce((result, userID) => {
|
||||
const userEvents = getters.intervals[userID];
|
||||
if (!userEvents) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const projects = userEvents.reduce((projects, event) => {
|
||||
if (!projects[event.project_id]) {
|
||||
projects[event.project_id] = {
|
||||
id: event.project_id,
|
||||
name: event.project_name,
|
||||
duration: event.duration,
|
||||
tasks: {},
|
||||
durationAtSelectedPeriod: event.durationAtSelectedPeriod,
|
||||
};
|
||||
} else {
|
||||
projects[event.project_id].duration += event.duration;
|
||||
projects[event.project_id].durationAtSelectedPeriod += event.durationAtSelectedPeriod;
|
||||
}
|
||||
|
||||
if (!projects[event.project_id].tasks[event.task_id]) {
|
||||
projects[event.project_id].tasks[event.task_id] = {
|
||||
id: event.task_id,
|
||||
name: event.task_name,
|
||||
duration: event.duration,
|
||||
durationAtSelectedPeriod: event.durationAtSelectedPeriod,
|
||||
};
|
||||
} else {
|
||||
projects[event.project_id].tasks[event.task_id].duration += event.duration;
|
||||
projects[event.project_id].tasks[event.task_id].durationAtSelectedPeriod +=
|
||||
event.durationAtSelectedPeriod;
|
||||
}
|
||||
|
||||
return projects;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...result,
|
||||
[userID]: projects,
|
||||
};
|
||||
}, {});
|
||||
},
|
||||
timePerDay: (state, getters) => {
|
||||
return Object.keys(getters.intervals).reduce((result, userID) => {
|
||||
const userEvents = getters.intervals[userID];
|
||||
if (!userEvents) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const userTimePerDay = userEvents.reduce((result, event) => {
|
||||
return _.mergeWith({}, result, event.durationByDay, _.add);
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...result,
|
||||
[userID]: userTimePerDay,
|
||||
};
|
||||
}, {});
|
||||
},
|
||||
timezone: state => state.timezone,
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
setService(state, service) {
|
||||
state.service = service;
|
||||
},
|
||||
setIntervals(state, intervals) {
|
||||
state.intervals = intervals;
|
||||
},
|
||||
addInterval(state, interval) {
|
||||
if (Array.isArray(state.intervals)) {
|
||||
state.intervals = {};
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
|
||||
Vue.set(state.intervals, interval.user_id, []);
|
||||
}
|
||||
|
||||
state.intervals[interval.user_id].push(interval);
|
||||
},
|
||||
updateInterval(state, interval) {
|
||||
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = state.intervals[interval.user_id].findIndex(item => +item.id === +interval.id);
|
||||
if (index !== -1) {
|
||||
state.intervals[interval.user_id].splice(index, 1, interval);
|
||||
}
|
||||
},
|
||||
removeInterval(state, interval) {
|
||||
if (!Object.prototype.hasOwnProperty.call(state.intervals, interval.user_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = state.intervals[interval.user_id].findIndex(item => +item.id === +interval.id);
|
||||
if (index !== -1) {
|
||||
state.intervals[interval.user_id].splice(index, 1);
|
||||
}
|
||||
},
|
||||
removeIntervalById(state, id) {
|
||||
for (const userId in state.intervals) {
|
||||
const index = state.intervals[userId].findIndex(item => +item.id === +id);
|
||||
if (index !== -1) {
|
||||
state.intervals[userId].splice(index, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
setTasks(state, tasks) {
|
||||
state.tasks = tasks;
|
||||
},
|
||||
setUsers(state, users) {
|
||||
state.users = users;
|
||||
},
|
||||
setTimezone(state, timezone) {
|
||||
state.timezone = timezone;
|
||||
},
|
||||
};
|
||||
|
||||
const actions = {
|
||||
init(context) {
|
||||
context.commit('setService', new DashboardService(context, new TasksService(), new UserService()));
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
state,
|
||||
getters,
|
||||
mutations,
|
||||
actions,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard__routes">
|
||||
<h1 class="dashboard__link">
|
||||
<router-link :to="{ name: 'dashboard.timeline' }">{{ $t('dashboard.timeline') }}</router-link>
|
||||
</h1>
|
||||
<h1 v-if="$can('viewTeamTab', 'dashboard')" class="dashboard__link">
|
||||
<router-link :to="{ name: 'dashboard.team' }">{{ $t('dashboard.team') }}</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="dashboard__content-wrapper">
|
||||
<router-view :key="$route.fullPath" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Index',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard {
|
||||
&__routes {
|
||||
margin-bottom: 1em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__link {
|
||||
margin-right: $layout-03;
|
||||
font-size: 1.8rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: initial;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #b1b1be;
|
||||
}
|
||||
|
||||
.router-link-active {
|
||||
color: #2e2ef9;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="team">
|
||||
<div class="controls-row flex-between">
|
||||
<div class="flex">
|
||||
<Calendar
|
||||
:sessionStorageKey="sessionStorageKey"
|
||||
class="controls-row__item"
|
||||
@change="onCalendarChange"
|
||||
/>
|
||||
|
||||
<UserSelect class="controls-row__item" @change="onUsersChange" />
|
||||
|
||||
<ProjectSelect class="controls-row__item" @change="onProjectsChange" />
|
||||
|
||||
<TimezonePicker :value="timezone" class="controls-row__item" @onTimezoneChange="onTimezoneChange" />
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<router-link
|
||||
v-if="$can('viewManualTime', 'dashboard')"
|
||||
class="controls-row__item"
|
||||
to="/time-intervals/new"
|
||||
>
|
||||
<at-button class="controls-row__btn" icon="icon-edit">{{ $t('control.add_time') }}</at-button>
|
||||
</router-link>
|
||||
|
||||
<ExportDropdown
|
||||
class="export controls-row__item controls-row__btn"
|
||||
position="left"
|
||||
trigger="hover"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="at-container">
|
||||
<div class="at-container__inner">
|
||||
<div class="row">
|
||||
<div class="col-8 col-lg-6">
|
||||
<TeamSidebar
|
||||
:sort="sort"
|
||||
:sortDir="sortDir"
|
||||
:users="graphUsers"
|
||||
class="sidebar"
|
||||
@sort="onSort"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-16 col-lg-18">
|
||||
<TeamDayGraph
|
||||
v-if="type === 'day'"
|
||||
:users="graphUsers"
|
||||
:start="start"
|
||||
class="graph"
|
||||
@selectedIntervals="onSelectedIntervals"
|
||||
/>
|
||||
<TeamTableGraph
|
||||
v-else
|
||||
:end="end"
|
||||
:start="start"
|
||||
:timePerDay="timePerDay"
|
||||
:users="graphUsers"
|
||||
class="graph"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TimeIntervalEdit
|
||||
v-if="selectedIntervals.length"
|
||||
:intervals="selectedIntervals"
|
||||
@close="clearIntervals"
|
||||
@edit="load"
|
||||
@remove="onBulkRemove"
|
||||
/>
|
||||
</div>
|
||||
<preloader v-if="isDataLoading" :is-transparent="true" class="team__loader" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Calendar from '@/components/Calendar';
|
||||
import ExportDropdown from '@/components/ExportDropdown';
|
||||
import Preloader from '@/components/Preloader';
|
||||
import ProjectSelect from '@/components/ProjectSelect';
|
||||
import TimezonePicker from '@/components/TimezonePicker';
|
||||
import UserSelect from '@/components/UserSelect';
|
||||
import ProjectService from '@/services/resource/project.service';
|
||||
import { getDateToday, getEndOfDayInTimezone, getStartOfDayInTimezone } from '@/utils/time';
|
||||
import DashboardReportService from '_internal/Dashboard/services/dashboard.service';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import throttle from 'lodash/throttle';
|
||||
import moment from 'moment';
|
||||
import { mapGetters, mapMutations } from 'vuex';
|
||||
import TeamDayGraph from '../../components/TeamDayGraph';
|
||||
import TeamSidebar from '../../components/TeamSidebar';
|
||||
import TeamTableGraph from '../../components/TeamTableGraph';
|
||||
import TimeIntervalEdit from '../../components/TimeIntervalEdit';
|
||||
|
||||
const updateInterval = 60 * 1000;
|
||||
|
||||
export default {
|
||||
name: 'Team',
|
||||
components: {
|
||||
Calendar,
|
||||
UserSelect,
|
||||
ProjectSelect,
|
||||
TeamSidebar,
|
||||
TeamDayGraph,
|
||||
TeamTableGraph,
|
||||
TimezonePicker,
|
||||
ExportDropdown,
|
||||
TimeIntervalEdit,
|
||||
Preloader,
|
||||
},
|
||||
data() {
|
||||
const today = this.getDateToday();
|
||||
const sessionStorageKey = 'amazingcat.session.storage.team';
|
||||
|
||||
return {
|
||||
type: 'day',
|
||||
start: today,
|
||||
end: today,
|
||||
userIDs: [],
|
||||
projectIDs: [],
|
||||
sort: localStorage.getItem('team.sort') || 'user',
|
||||
sortDir: localStorage.getItem('team.sort-dir') || 'asc',
|
||||
projectService: new ProjectService(),
|
||||
reportService: new DashboardReportService(),
|
||||
showExportModal: false,
|
||||
selectedIntervals: [],
|
||||
sessionStorageKey: sessionStorageKey,
|
||||
isDataLoading: false,
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
localStorage['dashboard.tab'] = 'team';
|
||||
|
||||
await this.load();
|
||||
this.updateHandle = setInterval(() => {
|
||||
if (!this.updatedWithWebsockets) {
|
||||
this.load(false);
|
||||
}
|
||||
|
||||
this.updatedWithWebsockets = false;
|
||||
}, updateInterval);
|
||||
this.updatedWithWebsockets = false;
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.updateHandle);
|
||||
this.service.unloadIntervals();
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['intervals', 'timePerDay', 'users', 'timezone', 'service']),
|
||||
...mapGetters('user', ['user']),
|
||||
graphUsers() {
|
||||
return this.users
|
||||
.filter(user => this.userIDs.includes(user.id))
|
||||
.map(user => ({ ...user, worked: this.getWorked(user.id) }))
|
||||
.sort((a, b) => {
|
||||
let order = 0;
|
||||
if (this.sort === 'user') {
|
||||
const aName = a.full_name.toUpperCase();
|
||||
const bName = b.full_name.toUpperCase();
|
||||
order = aName.localeCompare(bName);
|
||||
} else if (this.sort === 'worked') {
|
||||
const aWorked = a.worked || 0;
|
||||
const bWorked = b.worked || 0;
|
||||
order = aWorked - bWorked;
|
||||
}
|
||||
|
||||
return this.sortDir === 'asc' ? order : -order;
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDateToday,
|
||||
getStartOfDayInTimezone,
|
||||
getEndOfDayInTimezone,
|
||||
...mapMutations({
|
||||
setTimezone: 'dashboard/setTimezone',
|
||||
removeInterval: 'dashboard/removeInterval',
|
||||
addInterval: 'dashboard/addInterval',
|
||||
updateInterval: 'dashboard/updateInterval',
|
||||
removeIntervalById: 'dashboard/removeIntervalById',
|
||||
}),
|
||||
load: throttle(async function (withLoadingIndicator = true) {
|
||||
this.isDataLoading = withLoadingIndicator;
|
||||
if (!this.userIDs.length || !this.projectIDs.length) {
|
||||
this.isDataLoading = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const startAt = this.getStartOfDayInTimezone(this.start, this.timezone);
|
||||
const endAt = this.getEndOfDayInTimezone(this.end, this.timezone);
|
||||
|
||||
await this.service.load(this.userIDs, this.projectIDs, startAt, endAt, this.timezone);
|
||||
|
||||
this.isDataLoading = false;
|
||||
}, 1000),
|
||||
onCalendarChange({ type, start, end }) {
|
||||
this.type = type;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
|
||||
this.service.unloadIntervals();
|
||||
|
||||
this.load();
|
||||
},
|
||||
onUsersChange(userIDs) {
|
||||
this.userIDs = [...userIDs];
|
||||
|
||||
this.load();
|
||||
},
|
||||
onProjectsChange(projectIDs) {
|
||||
this.projectIDs = [...projectIDs];
|
||||
|
||||
this.load();
|
||||
},
|
||||
onTimezoneChange(timezone) {
|
||||
this.setTimezone(timezone);
|
||||
},
|
||||
onSort(column) {
|
||||
if (column === this.sort) {
|
||||
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sort = column;
|
||||
// Sort users ascending and time descending by default
|
||||
this.sortDir = column === 'user' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
localStorage['team.sort'] = this.sort;
|
||||
localStorage['team.sort-dir'] = this.sortDir;
|
||||
},
|
||||
async onExport(format) {
|
||||
const { data } = await this.reportService.downloadReport(
|
||||
this.getStartOfDayInTimezone(this.start, this.timezone),
|
||||
this.getEndOfDayInTimezone(this.end, this.timezone),
|
||||
this.userIDs,
|
||||
this.projectIDs,
|
||||
this.timezone,
|
||||
format,
|
||||
this.sort,
|
||||
this.sortDir,
|
||||
);
|
||||
|
||||
window.open(data.data.url, '_blank');
|
||||
},
|
||||
onSelectedIntervals(event) {
|
||||
this.selectedIntervals = event ? [event] : [];
|
||||
},
|
||||
onBulkRemove(intervals) {
|
||||
const totalIntervals = cloneDeep(this.intervals);
|
||||
intervals.forEach(interval => {
|
||||
const userIntervals = cloneDeep(totalIntervals[interval.user_id]).filter(
|
||||
userInterval => interval.id !== userInterval.id,
|
||||
);
|
||||
const deletedDuration = moment(interval.end_at).diff(interval.start_at, 'seconds');
|
||||
userIntervals.duration -= deletedDuration;
|
||||
|
||||
totalIntervals[interval.user_id] = userIntervals;
|
||||
});
|
||||
this.$store.commit('dashboard/setIntervals', totalIntervals);
|
||||
|
||||
this.clearIntervals();
|
||||
},
|
||||
clearIntervals() {
|
||||
this.selectedIntervals = [];
|
||||
},
|
||||
|
||||
// for send invites to new users
|
||||
async getModalInvite() {
|
||||
let modal;
|
||||
try {
|
||||
modal = await this.$Modal.prompt({
|
||||
title: this.$t('invite.label'),
|
||||
content: this.$t('invite.content'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modal.value) {
|
||||
this.$Message.error(this.$t('invite.message.error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const emails = modal.value.split(',');
|
||||
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const regex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
|
||||
const validation = {
|
||||
isError: false,
|
||||
emails: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
let email = emails[i].replace(' ', '');
|
||||
if (regex.exec(email) == null) {
|
||||
validation.isError = true;
|
||||
validation.emails.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
if (!validation.isError) {
|
||||
this.reportService.sendInvites({ emails }).then(({ data }) => {
|
||||
this.$Message.success('Success');
|
||||
});
|
||||
} else {
|
||||
this.$Message.error(this.$t('invite.message.valid') + validation.emails);
|
||||
}
|
||||
},
|
||||
getWorked(userId) {
|
||||
return Object.prototype.hasOwnProperty.call(this.intervals, userId)
|
||||
? this.intervals[userId].reduce((acc, el) => acc + el.durationAtSelectedPeriod, 0)
|
||||
: 0;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const channel = this.$echo.private(`intervals.${this.user.id}`);
|
||||
channel.listen(`.intervals.create`, data => {
|
||||
const startAt = moment.tz(data.model.start_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
|
||||
const endAt = moment.tz(data.model.end_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
|
||||
if (startAt > this.end || endAt < this.start) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addInterval(data.model);
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
|
||||
channel.listen(`.intervals.edit`, data => {
|
||||
this.updateInterval(data.model);
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
|
||||
channel.listen(`.intervals.destroy`, data => {
|
||||
if (typeof data.model === 'number') {
|
||||
this.removeIntervalById(data.model);
|
||||
} else {
|
||||
this.removeInterval(data.model);
|
||||
}
|
||||
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
timezone() {
|
||||
this.service.unloadIntervals();
|
||||
this.load();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.at-container {
|
||||
&__inner {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.team__loader {
|
||||
z-index: 0;
|
||||
border-radius: 20px;
|
||||
|
||||
&::v-deep {
|
||||
align-items: baseline;
|
||||
|
||||
.lds-ellipsis {
|
||||
position: sticky;
|
||||
top: 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-type {
|
||||
margin-left: 10px;
|
||||
border-radius: 5px;
|
||||
|
||||
.at-btn:first-child {
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.at-btn:last-child {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
border: 1px solid #eeeef5;
|
||||
color: #b1b1be;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
height: 40px;
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
background: #2e2ef9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.controls-row {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
padding-right: 1px; // fix horizontal scroll caused by download btn padding
|
||||
&__item {
|
||||
margin: 0;
|
||||
margin-bottom: $spacing-03;
|
||||
}
|
||||
.calendar {
|
||||
&::v-deep .input {
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
& > div:first-child {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 250px);
|
||||
width: 100%;
|
||||
column-gap: $spacing-03;
|
||||
}
|
||||
& > div:last-child {
|
||||
align-self: flex-end;
|
||||
column-gap: $spacing-03;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.at-container {
|
||||
&__inner {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 40px;
|
||||
|
||||
&::v-deep .at-btn__text {
|
||||
color: #2e2ef9;
|
||||
font-size: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
.button-invite {
|
||||
color: #618fea;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<div class="at-container sidebar">
|
||||
<TimelineSidebar
|
||||
:active-task="activeTask"
|
||||
:isDataLoading="isDataLoading"
|
||||
:startDate="start"
|
||||
:endDate="end"
|
||||
/>
|
||||
</div>
|
||||
<div class="controls-row flex-between">
|
||||
<div class="flex">
|
||||
<Calendar
|
||||
class="controls-row__item"
|
||||
:range="false"
|
||||
:sessionStorageKey="sessionStorageKey"
|
||||
@change="onCalendarChange"
|
||||
/>
|
||||
<TimezonePicker class="controls-row__item" :value="timezone" @onTimezoneChange="onTimezoneChange" />
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<router-link
|
||||
v-if="$can('viewManualTime', 'dashboard')"
|
||||
to="/time-intervals/new"
|
||||
class="controls-row__item"
|
||||
>
|
||||
<at-button class="controls-row__btn" icon="icon-edit">
|
||||
{{ $t('control.add_time') }}
|
||||
</at-button>
|
||||
</router-link>
|
||||
|
||||
<ExportDropdown
|
||||
class="export-btn dropdown controls-row__btn controls-row__item"
|
||||
position="left-top"
|
||||
trigger="hover"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="at-container intervals">
|
||||
<TimelineDayGraph
|
||||
v-if="type === 'day'"
|
||||
class="graph"
|
||||
:start="start"
|
||||
:end="end"
|
||||
:events="userEvents"
|
||||
:timezone="timezone"
|
||||
@selectedIntervals="onIntervalsSelect"
|
||||
@remove="onBulkRemove"
|
||||
/>
|
||||
<TimelineCalendarGraph v-else class="graph" :start="start" :end="end" :timePerDay="userTimePerDay" />
|
||||
|
||||
<TimelineScreenshots
|
||||
v-if="type === 'day' && intervals && Object.keys(intervals).length"
|
||||
ref="timelineScreenshots"
|
||||
@on-remove="recalculateStatistic"
|
||||
@onSelectedIntervals="setSelectedIntervals"
|
||||
/>
|
||||
<preloader v-if="isDataLoading" class="timeline__loader" :is-transparent="true" />
|
||||
|
||||
<TimeIntervalEdit
|
||||
:intervals="selectedIntervals"
|
||||
@remove="onBulkRemove"
|
||||
@edit="loadData"
|
||||
@close="clearIntervals"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { mapGetters, mapMutations } from 'vuex';
|
||||
import Calendar from '@/components/Calendar';
|
||||
import TimelineSidebar from '../../components/TimelineSidebar';
|
||||
import TimelineDayGraph from '../../components/TimelineDayGraph';
|
||||
import TimelineCalendarGraph from '../../components/TimelineCalendarGraph';
|
||||
import TimelineScreenshots from '../../components/TimelineScreenshots';
|
||||
import TimezonePicker from '@/components/TimezonePicker';
|
||||
import DashboardService from '_internal/Dashboard/services/dashboard.service';
|
||||
import { getDateToday } from '@/utils/time';
|
||||
import { getStartOfDayInTimezone, getEndOfDayInTimezone } from '@/utils/time';
|
||||
import ExportDropdown from '@/components/ExportDropdown';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import TimeIntervalEdit from '../../components/TimeIntervalEdit';
|
||||
import Preloader from '@/components/Preloader';
|
||||
|
||||
const updateInterval = 60 * 1000;
|
||||
|
||||
const dashboardService = new DashboardService();
|
||||
|
||||
export default {
|
||||
name: 'Timeline',
|
||||
components: {
|
||||
Calendar,
|
||||
TimelineSidebar,
|
||||
TimelineDayGraph,
|
||||
TimelineCalendarGraph,
|
||||
TimelineScreenshots,
|
||||
TimezonePicker,
|
||||
ExportDropdown,
|
||||
TimeIntervalEdit,
|
||||
Preloader,
|
||||
},
|
||||
data() {
|
||||
const today = this.getDateToday();
|
||||
const sessionStorageKey = 'amazingcat.session.storage.timeline';
|
||||
|
||||
return {
|
||||
type: 'day',
|
||||
start: today,
|
||||
end: today,
|
||||
datepickerDateStart: '',
|
||||
datepickerDateEnd: '',
|
||||
activeTask: +localStorage.getItem('timeline.active-task') || 0,
|
||||
showExportModal: false,
|
||||
selectedIntervals: [],
|
||||
sessionStorageKey: sessionStorageKey,
|
||||
isDataLoading: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
localStorage['dashboard.tab'] = 'timeline';
|
||||
this.loadData();
|
||||
this.updateHandle = setInterval(() => {
|
||||
if (!this.updatedWithWebsockets) {
|
||||
this.loadData(false);
|
||||
}
|
||||
|
||||
this.updatedWithWebsockets = false;
|
||||
}, updateInterval);
|
||||
this.updatedWithWebsockets = false;
|
||||
},
|
||||
mounted() {
|
||||
const channel = this.$echo.private(`intervals.${this.user.id}`);
|
||||
channel.listen(`.intervals.create`, data => {
|
||||
const startAt = moment.tz(data.model.start_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
|
||||
const endAt = moment.tz(data.model.end_at, 'UTC').tz(this.timezone).format('YYYY-MM-DD');
|
||||
if (startAt > this.end || endAt < this.start) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addInterval(data.model);
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
|
||||
channel.listen(`.intervals.edit`, data => {
|
||||
this.updateInterval(data.model);
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
|
||||
channel.listen(`.intervals.destroy`, data => {
|
||||
if (typeof data.model === 'number') {
|
||||
this.removeIntervalById(data.model);
|
||||
} else {
|
||||
this.removeInterval(data.model);
|
||||
}
|
||||
|
||||
this.updatedWithWebsockets = true;
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.updateHandle);
|
||||
this.service.unloadIntervals();
|
||||
|
||||
this.$echo.leave(`intervals.${this.user.id}`);
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('dashboard', ['service', 'intervals', 'timePerDay', 'timePerProject', 'timezone']),
|
||||
...mapGetters('user', ['user']),
|
||||
userEvents() {
|
||||
if (!this.user || !this.user.id || !this.intervals[this.user.id]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.intervals[this.user.id];
|
||||
},
|
||||
userTimePerDay() {
|
||||
if (!this.user || !this.user.id || !this.timePerDay[this.user.id]) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return this.timePerDay[this.user.id];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDateToday,
|
||||
getStartOfDayInTimezone,
|
||||
getEndOfDayInTimezone,
|
||||
...mapMutations({
|
||||
setTimezone: 'dashboard/setTimezone',
|
||||
removeInterval: 'dashboard/removeInterval',
|
||||
addInterval: 'dashboard/addInterval',
|
||||
updateInterval: 'dashboard/updateInterval',
|
||||
removeIntervalById: 'dashboard/removeIntervalById',
|
||||
}),
|
||||
loadData: debounce(async function (withLoadingIndicator = true) {
|
||||
this.isDataLoading = withLoadingIndicator;
|
||||
|
||||
if (!this.user || !this.user.id) {
|
||||
this.isDataLoading = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const userIDs = [this.user.id];
|
||||
|
||||
const startAt = this.getStartOfDayInTimezone(this.start, this.timezone);
|
||||
const endAt = this.getEndOfDayInTimezone(this.end, this.timezone);
|
||||
|
||||
await this.service.load(userIDs, null, startAt, endAt, this.timezone);
|
||||
|
||||
this.isDataLoading = false;
|
||||
}, 350),
|
||||
onCalendarChange({ type, start, end }) {
|
||||
this.type = type;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
|
||||
this.service.unloadIntervals();
|
||||
|
||||
this.loadData();
|
||||
},
|
||||
onIntervalsSelect(event) {
|
||||
this.selectedIntervals = event ? [event] : [];
|
||||
},
|
||||
async onExport(format) {
|
||||
const { data } = await dashboardService.downloadReport(
|
||||
this.getStartOfDayInTimezone(this.start, this.timezone),
|
||||
this.getEndOfDayInTimezone(this.end, this.timezone),
|
||||
[this.user.id],
|
||||
this.projectIDs,
|
||||
this.timezone,
|
||||
format,
|
||||
);
|
||||
|
||||
window.open(data.data.url, '_blank');
|
||||
},
|
||||
onBulkRemove(intervals) {
|
||||
const totalIntervals = cloneDeep(this.intervals);
|
||||
intervals.forEach(interval => {
|
||||
const userIntervals = cloneDeep(totalIntervals[interval.user_id]).filter(
|
||||
userInterval => interval.id !== userInterval.id,
|
||||
);
|
||||
const deletedDuration = moment(interval.end_at).diff(interval.start_at, 'seconds');
|
||||
userIntervals.duration -= deletedDuration;
|
||||
|
||||
totalIntervals[interval.user_id] = userIntervals;
|
||||
});
|
||||
this.$store.commit('dashboard/setIntervals', totalIntervals);
|
||||
|
||||
this.clearIntervals();
|
||||
},
|
||||
onTimezoneChange(timezone) {
|
||||
this.setTimezone(timezone);
|
||||
},
|
||||
recalculateStatistic(intervals) {
|
||||
this.onBulkRemove(intervals);
|
||||
},
|
||||
setSelectedIntervals(intervalIds) {
|
||||
this.selectedIntervals = intervalIds;
|
||||
},
|
||||
clearIntervals() {
|
||||
if (this.$refs.timelineScreenshots) {
|
||||
this.$refs.timelineScreenshots.clearSelectedIntervals();
|
||||
}
|
||||
this.selectedIntervals = [];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
user() {
|
||||
this.loadData();
|
||||
},
|
||||
timezone() {
|
||||
this.service.unloadIntervals();
|
||||
this.loadData();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.at-container::v-deep {
|
||||
.modal-screenshot {
|
||||
a {
|
||||
max-height: inherit;
|
||||
|
||||
img {
|
||||
max-height: inherit;
|
||||
object-fit: fill;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.at-container {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr 1fr;
|
||||
column-gap: 0.5rem;
|
||||
&__loader {
|
||||
z-index: 0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-type {
|
||||
margin-left: 10px;
|
||||
border-radius: 5px;
|
||||
|
||||
.at-btn:first-child {
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.at-btn:last-child {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
border: 1px solid #eeeef5;
|
||||
color: #b1b1be;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
height: 40px;
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
background: #2e2ef9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 30px 0;
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 3;
|
||||
max-height: fit-content;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
grid-column: 2 / 4;
|
||||
padding-right: 1px; // fix horizontal scroll caused by download btn padding
|
||||
}
|
||||
|
||||
.intervals {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
@media (max-width: 1300px) {
|
||||
.timeline {
|
||||
grid-template-columns: 250px 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1110px) {
|
||||
.canvas {
|
||||
padding-top: 0.3rem;
|
||||
}
|
||||
.at-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.sidebar {
|
||||
padding: 15px 0;
|
||||
}
|
||||
.controls-row {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
//padding-right: 1px; // fix horizontal scroll caused by download btn padding
|
||||
&__item {
|
||||
margin: 0;
|
||||
margin-bottom: $spacing-03;
|
||||
}
|
||||
.calendar {
|
||||
&::v-deep .input {
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
& > div:first-child {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 250px);
|
||||
width: 100%;
|
||||
column-gap: $spacing-03;
|
||||
}
|
||||
& > div:last-child {
|
||||
align-self: flex-end;
|
||||
column-gap: $spacing-03;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 790px) {
|
||||
.intervals {
|
||||
grid-column: 1/4;
|
||||
}
|
||||
.controls-row {
|
||||
& > div:last-child {
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.controls-row {
|
||||
grid-column: 1/4;
|
||||
grid-row: 1;
|
||||
& > div:last-child {
|
||||
align-self: end;
|
||||
}
|
||||
}
|
||||
.sidebar {
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.graph {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
27
resources/frontend/core/modules/Gantt/locales/en.json
Normal file
27
resources/frontend/core/modules/Gantt/locales/en.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"navigation" : {
|
||||
"gantt" : "Gantt"
|
||||
},
|
||||
"gantt" : {
|
||||
"dimensions" : {
|
||||
"index" : "Index",
|
||||
"id" : "Id",
|
||||
"task_name" : "Name",
|
||||
"estimate" : "Time Estimate",
|
||||
"total_spent_time": "Total Spent Time",
|
||||
"start_date" : "Start date",
|
||||
"due_date" : "Due date",
|
||||
"name" : "Name",
|
||||
"first_task_id" : "First Task",
|
||||
"last_task_id" : "Last Task",
|
||||
"status": "Status",
|
||||
"priority": "Priority"
|
||||
}
|
||||
},
|
||||
"field" : {
|
||||
},
|
||||
"control" : {
|
||||
},
|
||||
"projects" : {
|
||||
}
|
||||
}
|
||||
28
resources/frontend/core/modules/Gantt/locales/ru.json
Normal file
28
resources/frontend/core/modules/Gantt/locales/ru.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"navigation": {
|
||||
"gantt": "Гант"
|
||||
},
|
||||
"gantt": {
|
||||
"dimensions" : {
|
||||
"index" : "Index",
|
||||
"id" : "Id",
|
||||
"task_name" : "Название",
|
||||
"estimate" : "Оценка времени",
|
||||
"total_spent_time": "Всего потрачено времени",
|
||||
"start_date" : "Дата начала",
|
||||
"due_date" : "Дата завершения",
|
||||
"name" : "Название",
|
||||
"first_task_id" : "Первая задача",
|
||||
"last_task_id" : "Последняя задача",
|
||||
"status": "Статус",
|
||||
"priority": "Приоритет"
|
||||
}
|
||||
},
|
||||
"field": {
|
||||
|
||||
},
|
||||
"control": {
|
||||
},
|
||||
"projects": {
|
||||
}
|
||||
}
|
||||
20
resources/frontend/core/modules/Gantt/module.init.js
Normal file
20
resources/frontend/core/modules/Gantt/module.init.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export const ModuleConfig = {
|
||||
routerPrefix: 'gantt',
|
||||
loadOrder: 20,
|
||||
moduleName: 'Gantt',
|
||||
};
|
||||
|
||||
export function init(context) {
|
||||
context.addRoute({
|
||||
path: '/gantt/:id',
|
||||
name: context.getModuleRouteName() + '.index',
|
||||
component: () => import(/* webpackChunkName: "gantt" */ './views/Gantt.vue'),
|
||||
});
|
||||
|
||||
context.addLocalizationData({
|
||||
en: require('./locales/en'),
|
||||
ru: require('./locales/ru'),
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
875
resources/frontend/core/modules/Gantt/views/Gantt.vue
Normal file
875
resources/frontend/core/modules/Gantt/views/Gantt.vue
Normal file
@@ -0,0 +1,875 @@
|
||||
<template>
|
||||
<div class="gantt">
|
||||
<div class="row flex-end">
|
||||
<at-button size="large" @click="$router.go(-1)">{{ $t('control.back') }}</at-button>
|
||||
</div>
|
||||
<v-chart ref="gantt" class="gantt__chart" />
|
||||
<preloader v-if="isDataLoading" :is-transparent="true" class="gantt__loader" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const HEIGHT_RATIO = 0.6;
|
||||
const ROW_HEIGHT = 20;
|
||||
const rawDimensions = [
|
||||
'index',
|
||||
'id',
|
||||
'task_name',
|
||||
'priority_id',
|
||||
'status_id',
|
||||
'estimate',
|
||||
'start_date',
|
||||
'due_date',
|
||||
'project_phase_id',
|
||||
'project_id',
|
||||
'total_spent_time',
|
||||
'total_offset',
|
||||
'status',
|
||||
'priority',
|
||||
];
|
||||
const i18nDimensions = rawDimensions.map(t => i18n.t(`gantt.dimensions.${t}`));
|
||||
|
||||
const dimensionIndex = Object.fromEntries(rawDimensions.map((el, i) => [el, i]));
|
||||
|
||||
const dimensionsMap = new Map(rawDimensions.map((el, i) => [i, el]));
|
||||
|
||||
const rawPhaseDimensions = ['id', 'name', 'start_date', 'due_date', 'first_task_id', 'last_task_id'];
|
||||
const i18nPhaseDimensions = rawPhaseDimensions.map(t => i18n.t(`gantt.dimensions.${t}`));
|
||||
const phaseDimensionIndex = Object.fromEntries(rawPhaseDimensions.map((el, i) => [el, i]));
|
||||
|
||||
import { use, format as echartsFormat, graphic as echartsGraphic } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { PieChart } from 'echarts/charts';
|
||||
import { CustomChart } from 'echarts/charts';
|
||||
|
||||
import {
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
ToolboxComponent,
|
||||
TitleComponent,
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
} from 'echarts/components';
|
||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||
import debounce from 'lodash/debounce';
|
||||
import Preloader from '@/components/Preloader.vue';
|
||||
import GanttService from '@/services/resource/gantt.service';
|
||||
import { formatDurationString, getStartDate } from '@/utils/time';
|
||||
import moment from 'moment-timezone';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
PieChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
ToolboxComponent,
|
||||
TitleComponent,
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
CustomChart,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
const grid = {
|
||||
show: true,
|
||||
top: 70,
|
||||
bottom: 20,
|
||||
left: 100,
|
||||
right: 20,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 0,
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'Index',
|
||||
components: {
|
||||
Preloader,
|
||||
VChart,
|
||||
},
|
||||
provide: {
|
||||
// [THEME_KEY]: 'dark',
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDataLoading: false,
|
||||
service: new GanttService(),
|
||||
option: {},
|
||||
tasksRelationsMap: [],
|
||||
totalRows: 0,
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
await this.load();
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.websocketEnterChannel(this.user.id, {
|
||||
updateAll: data => {
|
||||
const id = this.$route.params[this.service.getIdParam()];
|
||||
if (+id === +data.model.id) {
|
||||
this.prepareAndSetData(data.model);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.$refs.gantt.chart.on('click', { element: 'got_to_task_btn' }, params => {
|
||||
this.$router.push({
|
||||
name: 'Tasks.crud.tasks.view',
|
||||
params: { id: params.data[dimensionIndex['id']] },
|
||||
});
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
this.websocketLeaveChannel(this.user.id);
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', ['user']),
|
||||
},
|
||||
methods: {
|
||||
getYAxisZoomPercentage() {
|
||||
const chartHeight = this.$refs.gantt.chart.getHeight();
|
||||
const canDraw = chartHeight / (ROW_HEIGHT * this.totalRows * 2); // multiply by 2 so rows not squashed together
|
||||
return canDraw * 100;
|
||||
},
|
||||
onResize: debounce(
|
||||
function () {
|
||||
this.$refs.gantt.chart.resize();
|
||||
this.$refs.gantt.chart.setOption({
|
||||
dataZoom: { id: 'sliderY', start: 0, end: this.getYAxisZoomPercentage() },
|
||||
});
|
||||
},
|
||||
50,
|
||||
{
|
||||
maxWait: 100,
|
||||
},
|
||||
),
|
||||
load: debounce(async function () {
|
||||
this.isDataLoading = true;
|
||||
|
||||
const ganttData = (await this.service.getGanttData(this.$route.params.id)).data.data;
|
||||
|
||||
this.prepareAndSetData(ganttData);
|
||||
|
||||
this.isDataLoading = false;
|
||||
}, 100),
|
||||
prepareAndSetData(ganttData) {
|
||||
this.totalRows = ganttData.tasks.length;
|
||||
|
||||
const phasesMap = ganttData.phases
|
||||
.filter(p => p.start_date && p.due_date)
|
||||
.reduce((acc, phase) => {
|
||||
phase.tasks = {
|
||||
byStartDate: {},
|
||||
byDueDate: {},
|
||||
};
|
||||
acc[phase.id] = phase;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const preparedRowsMap = {};
|
||||
const preparedRows = ganttData.tasks.map((item, index) => {
|
||||
const row = [index + 1].concat(...Object.values(item));
|
||||
preparedRowsMap[item.id] = row;
|
||||
if (phasesMap[item.project_phase_id]) {
|
||||
const phaseTasks = phasesMap[item.project_phase_id].tasks;
|
||||
if (phaseTasks.byStartDate[item.start_date]) {
|
||||
phaseTasks.byStartDate[item.start_date].push(row);
|
||||
} else {
|
||||
phaseTasks.byStartDate[item.start_date] = [row];
|
||||
}
|
||||
if (phaseTasks.byDueDate[item.due_date]) {
|
||||
phaseTasks.byDueDate[item.due_date].push(row);
|
||||
} else {
|
||||
phaseTasks.byDueDate[item.due_date] = [row];
|
||||
}
|
||||
}
|
||||
return preparedRowsMap[item.id];
|
||||
});
|
||||
|
||||
this.tasksRelationsMap = ganttData.tasks_relations.reduce((obj, relation) => {
|
||||
const child = preparedRowsMap[relation.child_id];
|
||||
if (Array.isArray(obj[relation.parent_id]) && child) {
|
||||
obj[relation.parent_id].push(child);
|
||||
} else {
|
||||
obj[relation.parent_id] = [child];
|
||||
}
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
const option = {
|
||||
animation: false,
|
||||
toolbox: {
|
||||
left: 20,
|
||||
top: 0,
|
||||
itemSize: 20,
|
||||
},
|
||||
title: {
|
||||
text: `${ganttData.name}`,
|
||||
left: '4',
|
||||
textAlign: 'left',
|
||||
},
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'slider',
|
||||
xAxisIndex: 0,
|
||||
filterMode: 'none',
|
||||
height: 20,
|
||||
bottom: 0,
|
||||
start: 0,
|
||||
end: 100,
|
||||
handleIcon:
|
||||
'path://M10.7,11.9H9.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7V23h6.6V24.4z M13.3,19.6H6.7v-1.4h6.6V19.6z',
|
||||
handleSize: '80%',
|
||||
showDetail: true,
|
||||
labelFormatter: getStartDate,
|
||||
},
|
||||
{
|
||||
type: 'inside',
|
||||
id: 'insideX',
|
||||
xAxisIndex: 0,
|
||||
filterMode: 'none',
|
||||
start: 0,
|
||||
end: 50,
|
||||
zoomOnMouseWheel: false,
|
||||
moveOnMouseMove: true,
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
id: 'sliderY',
|
||||
filterMode: 'none',
|
||||
yAxisIndex: 0,
|
||||
width: 10,
|
||||
right: 10,
|
||||
top: 70,
|
||||
bottom: 20,
|
||||
start: 0,
|
||||
end: this.getYAxisZoomPercentage(this.totalRows),
|
||||
handleSize: 0,
|
||||
showDetail: false,
|
||||
},
|
||||
{
|
||||
type: 'inside',
|
||||
id: 'insideY',
|
||||
yAxisIndex: 0,
|
||||
filterMode: 'none',
|
||||
// startValue: 0,
|
||||
// endValue: 10,
|
||||
zoomOnMouseWheel: 'shift',
|
||||
moveOnMouseMove: true,
|
||||
moveOnMouseWheel: true,
|
||||
},
|
||||
],
|
||||
grid,
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
position: 'top',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: ['#E9EDFF'],
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
color: '#929ABA',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#929ABA',
|
||||
inside: false,
|
||||
align: 'center',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
},
|
||||
inverse: true,
|
||||
// axisPointer: {
|
||||
// show: true,
|
||||
// type: 'line',
|
||||
// data: [
|
||||
// [40, -10],
|
||||
// [-30, -5],
|
||||
// [-76.5, 20],
|
||||
// [-63.5, 40],
|
||||
// [-22.1, 50],
|
||||
// ]
|
||||
// },
|
||||
max: this.totalRows + 1,
|
||||
},
|
||||
tooltip: {
|
||||
textStyle: {},
|
||||
formatter: function (params) {
|
||||
const getRow = (key, value) => `
|
||||
<div style="display: inline-flex; width: 100%; justify-content: space-between; column-gap: 1rem; text-overflow: ellipsis;">
|
||||
${key} <span style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden;" ><b>${value}</b></span>
|
||||
</div>`;
|
||||
const getWrapper = dimensionsToShow => `
|
||||
<div style="display:flex; flex-direction: column; max-width: 280px;">
|
||||
${dimensionsToShow.map(([title, value]) => getRow(title, value)).join('')}
|
||||
</div>
|
||||
`;
|
||||
const prepareValues = accessor => {
|
||||
const key = params.dimensionNames[Array.isArray(accessor) ? accessor[0] : accessor];
|
||||
let value = Array.isArray(accessor)
|
||||
? accessor[1](params.value[accessor[0]])
|
||||
: params.value[accessor];
|
||||
return [key, value];
|
||||
};
|
||||
if (params.seriesId === 'tasksData' || params.seriesId === 'tasksLabels') {
|
||||
return getWrapper([
|
||||
prepareValues(dimensionIndex.task_name),
|
||||
prepareValues([dimensionIndex.status, v => v.name]),
|
||||
prepareValues([dimensionIndex.priority, v => v.name]),
|
||||
prepareValues([
|
||||
dimensionIndex.estimate,
|
||||
v => (v == null ? '—' : formatDurationString(v)),
|
||||
]),
|
||||
prepareValues([
|
||||
dimensionIndex.total_spent_time,
|
||||
v => (v == null ? '—' : formatDurationString(v)),
|
||||
]),
|
||||
prepareValues(dimensionIndex.start_date),
|
||||
prepareValues(dimensionIndex.due_date),
|
||||
]);
|
||||
}
|
||||
if (params.seriesId === 'phasesData') {
|
||||
return getWrapper([
|
||||
prepareValues(phaseDimensionIndex.name),
|
||||
prepareValues(phaseDimensionIndex.start_date),
|
||||
prepareValues(phaseDimensionIndex.due_date),
|
||||
]);
|
||||
}
|
||||
return `${params.dataIndex}`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
id: 'tasksData',
|
||||
type: 'custom',
|
||||
renderItem: this.renderGanttItem,
|
||||
dimensions: i18nDimensions,
|
||||
encode: {
|
||||
x: [dimensionIndex.start_date, dimensionIndex.due_date],
|
||||
y: dimensionIndex.index,
|
||||
},
|
||||
data: preparedRows,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'tasksLabels',
|
||||
type: 'custom',
|
||||
renderItem: this.renderAxisLabelItem,
|
||||
dimensions: i18nDimensions,
|
||||
encode: {
|
||||
x: -1,
|
||||
y: 0,
|
||||
},
|
||||
data: preparedRows,
|
||||
},
|
||||
{
|
||||
id: 'phasesData',
|
||||
type: 'custom',
|
||||
dimensions: i18nPhaseDimensions,
|
||||
renderItem: this.renderPhaseItem,
|
||||
encode: {
|
||||
x: [2, 3],
|
||||
y: 4,
|
||||
},
|
||||
data: Object.values(phasesMap).map(phase => {
|
||||
const startTaskIdx = phase.tasks.byStartDate[phase.start_date].reduce(
|
||||
(minIndex, row) => Math.min(minIndex, row[dimensionIndex.index]),
|
||||
Infinity,
|
||||
);
|
||||
const dueTaskId = phase.tasks.byDueDate[phase.due_date].reduce(
|
||||
(maxIndex, row) => Math.max(maxIndex, row[dimensionIndex.index]),
|
||||
null,
|
||||
);
|
||||
return [
|
||||
phase.id,
|
||||
phase.name,
|
||||
phase.start_date,
|
||||
phase.due_date,
|
||||
startTaskIdx,
|
||||
dueTaskId,
|
||||
];
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
const firstTaskDate = preparedRows[0] ? preparedRows[0][dimensionIndex.start_date] : null;
|
||||
const lastTaskDate = preparedRows[preparedRows.length - 1]
|
||||
? preparedRows[preparedRows.length - 1][dimensionIndex.start_date]
|
||||
: null;
|
||||
const today = moment();
|
||||
if (
|
||||
firstTaskDate &&
|
||||
lastTaskDate &&
|
||||
!today.isBefore(moment(firstTaskDate)) &&
|
||||
!today.isAfter(moment(lastTaskDate))
|
||||
) {
|
||||
option.series.push({
|
||||
id: 'currentDayLine',
|
||||
type: 'custom',
|
||||
encode: {
|
||||
x: 0,
|
||||
y: -1,
|
||||
},
|
||||
data: [getStartDate(today)],
|
||||
renderItem: (params, api) => {
|
||||
const todayCoord = api.coord([api.value(0), 0])[0];
|
||||
const chartHeight = api.getHeight() - grid.bottom - grid.top;
|
||||
const gridTop = params.coordSys.y;
|
||||
const gridBottom = gridTop + chartHeight;
|
||||
|
||||
return {
|
||||
type: 'line',
|
||||
ignore: todayCoord < grid.left || todayCoord > api.getWidth() - grid.right,
|
||||
shape: {
|
||||
x1: todayCoord,
|
||||
y1: gridTop,
|
||||
x2: todayCoord,
|
||||
y2: gridBottom,
|
||||
},
|
||||
style: {
|
||||
stroke: 'rgba(255,0,0,0.3)',
|
||||
lineWidth: 2,
|
||||
},
|
||||
silent: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
const oldZoom = this.$refs.gantt.chart.getOption()?.dataZoom;
|
||||
this.$refs.gantt.chart.setOption(option);
|
||||
if (oldZoom) {
|
||||
this.$refs.gantt.chart.setOption({
|
||||
dataZoom: oldZoom,
|
||||
});
|
||||
}
|
||||
},
|
||||
renderGanttItem(params, api) {
|
||||
let categoryIndex = api.value(dimensionIndex.index);
|
||||
let startDate = api.coord([api.value(dimensionIndex.start_date), categoryIndex]);
|
||||
let endDate = api.coord([api.value(dimensionIndex.due_date), categoryIndex]);
|
||||
|
||||
let barLength = endDate[0] - startDate[0];
|
||||
// Get the height corresponds to length 1 on y axis.
|
||||
let barHeight = api.size([0, 1])[1] * HEIGHT_RATIO;
|
||||
barHeight = ROW_HEIGHT;
|
||||
|
||||
let x = startDate[0];
|
||||
let y = startDate[1] - barHeight;
|
||||
let barText = api.value(dimensionIndex.task_name);
|
||||
let barTextWidth = echartsFormat.getTextRect(barText).width;
|
||||
|
||||
let rectNormal = this.clipRectByRect(params, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: barLength,
|
||||
height: barHeight,
|
||||
});
|
||||
|
||||
let estimate = +api.value(dimensionIndex.estimate);
|
||||
estimate = isNaN(estimate) ? 0 : estimate;
|
||||
let totalSpentTime = +api.value(dimensionIndex.total_spent_time);
|
||||
totalSpentTime = isNaN(totalSpentTime) ? 0 : totalSpentTime;
|
||||
let totalOffset = +api.value(dimensionIndex.total_offset);
|
||||
totalOffset = isNaN(totalOffset) ? 0 : totalOffset;
|
||||
const timeWithOffset = totalSpentTime + totalOffset;
|
||||
|
||||
let taskProgressLine = 0;
|
||||
const multiplier = estimate > 0 ? timeWithOffset / estimate : 0;
|
||||
if (estimate != null && estimate >= 0) {
|
||||
taskProgressLine = barLength * multiplier;
|
||||
}
|
||||
let rectProgress = this.clipRectByRect(params, {
|
||||
x: x,
|
||||
y: y + barHeight * 0.15,
|
||||
width: taskProgressLine > barLength ? barLength : taskProgressLine, // fill bar length
|
||||
height: barHeight * 0.7,
|
||||
});
|
||||
|
||||
let taskId = api.value(dimensionIndex.id);
|
||||
const canvasWidth = api.getWidth() - grid.right;
|
||||
const canvasHeight = api.getHeight() - grid.bottom;
|
||||
|
||||
let childrenLines = [];
|
||||
this.tasksRelationsMap[taskId]?.forEach((childRowData, index) => {
|
||||
let childStartDate = api.coord([
|
||||
childRowData[dimensionIndex.start_date],
|
||||
childRowData[dimensionIndex.index],
|
||||
]);
|
||||
let childY = childStartDate[1] - barHeight / 2;
|
||||
|
||||
// Start point at the end of the parent task
|
||||
let startPoint = [endDate[0], endDate[1] - barHeight / 2];
|
||||
if (startPoint[0] <= grid.left) {
|
||||
startPoint[0] = grid.left;
|
||||
startPoint[1] = childY; // if parent outside grid, don't draw line to the top
|
||||
} else if (startPoint[0] >= canvasWidth) {
|
||||
startPoint[0] = canvasWidth;
|
||||
}
|
||||
if (startPoint[1] <= grid.top) {
|
||||
startPoint[1] = grid.top;
|
||||
} else if (startPoint[1] >= canvasHeight) {
|
||||
startPoint[1] = canvasHeight;
|
||||
}
|
||||
|
||||
// Intermediate point, vertically aligned with the parent task end, but at the child task's y-level
|
||||
let intermediatePoint = [endDate[0], childY];
|
||||
if (intermediatePoint[0] <= grid.left) {
|
||||
intermediatePoint[0] = grid.left;
|
||||
} else if (intermediatePoint[0] >= canvasWidth) {
|
||||
intermediatePoint[0] = canvasWidth;
|
||||
}
|
||||
if (intermediatePoint[1] <= grid.top) {
|
||||
intermediatePoint[1] = grid.top;
|
||||
} else if (intermediatePoint[1] >= canvasHeight) {
|
||||
intermediatePoint[1] = canvasHeight;
|
||||
}
|
||||
|
||||
// End point at the start of the child task
|
||||
let endPoint = [childStartDate[0], childY];
|
||||
if (endPoint[0] <= grid.left) {
|
||||
endPoint[0] = grid.left;
|
||||
} else if (endPoint[0] >= canvasWidth) {
|
||||
endPoint[0] = canvasWidth;
|
||||
}
|
||||
if (endPoint[1] <= grid.top) {
|
||||
endPoint[1] = grid.top;
|
||||
} else if (endPoint[1] >= canvasHeight) {
|
||||
endPoint[1] = canvasHeight;
|
||||
endPoint[0] = endDate[0]; // if child outside grid, don't draw line to the right
|
||||
}
|
||||
|
||||
const ignore =
|
||||
endPoint[0] === grid.left ||
|
||||
startPoint[0] === canvasWidth ||
|
||||
endPoint[1] === grid.top ||
|
||||
startPoint[1] === canvasHeight;
|
||||
|
||||
const childOrParentAreOutside =
|
||||
startPoint[0] === grid.left ||
|
||||
startPoint[1] === grid.top ||
|
||||
endPoint[0] === canvasWidth ||
|
||||
endPoint[1] === canvasHeight;
|
||||
|
||||
childrenLines.push({
|
||||
type: 'polyline',
|
||||
ignore: ignore,
|
||||
silent: true,
|
||||
shape: {
|
||||
points: [startPoint, intermediatePoint, endPoint],
|
||||
},
|
||||
style: {
|
||||
fill: 'transparent',
|
||||
stroke: childOrParentAreOutside ? '#aaa' : '#333', // Line color
|
||||
lineWidth: 1, // Line width
|
||||
lineDash: childOrParentAreOutside ? [20, 3, 3, 3, 3, 3, 3, 3] : 'solid',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const rectTextShape = {
|
||||
x: x + barLength + 5,
|
||||
y: y + barHeight / 2,
|
||||
width: barLength,
|
||||
height: barHeight,
|
||||
};
|
||||
|
||||
const textStyle = {
|
||||
textFill: '#333',
|
||||
width: 150,
|
||||
height: barHeight,
|
||||
text: barText,
|
||||
textAlign: 'left',
|
||||
textVerticalAlign: 'top',
|
||||
lineHeight: 1,
|
||||
fontSize: 12,
|
||||
overflow: 'truncate',
|
||||
elipsis: '...',
|
||||
};
|
||||
|
||||
const progressPercentage = Number(multiplier).toLocaleString(this.$i18n.locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const progressText =
|
||||
multiplier === 0
|
||||
? ''
|
||||
: echartsFormat.truncateText(
|
||||
`${progressPercentage}`,
|
||||
rectNormal?.width ?? 0,
|
||||
api.font({ fontSize: 12 }),
|
||||
);
|
||||
return {
|
||||
type: 'group',
|
||||
children: [
|
||||
{
|
||||
type: 'rect',
|
||||
ignore: !rectNormal,
|
||||
shape: rectNormal,
|
||||
style: api.style({
|
||||
fill: 'rgba(56,134,208,1)',
|
||||
rectBorderWidth: 10,
|
||||
text: progressText,
|
||||
fontSize: 12,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'rect',
|
||||
ignore: !rectProgress,
|
||||
shape: rectProgress,
|
||||
style: {
|
||||
fill: 'rgba(0,55,111,.6)',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
ignore:
|
||||
rectTextShape.x <= grid.left ||
|
||||
rectTextShape.x > canvasWidth ||
|
||||
rectTextShape.y <= grid.top + ROW_HEIGHT / 4 ||
|
||||
rectTextShape.y >= canvasHeight - ROW_HEIGHT / 4,
|
||||
clipPath: {
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: 0,
|
||||
y: 0 - ROW_HEIGHT / 2,
|
||||
width: textStyle.width,
|
||||
height: ROW_HEIGHT,
|
||||
},
|
||||
},
|
||||
style: textStyle,
|
||||
position: [rectTextShape.x, rectTextShape.y],
|
||||
},
|
||||
...childrenLines,
|
||||
],
|
||||
};
|
||||
},
|
||||
renderPhaseItem(params, api) {
|
||||
let start = api.coord([
|
||||
api.value(phaseDimensionIndex.start_date),
|
||||
api.value(phaseDimensionIndex.first_task_id),
|
||||
]);
|
||||
let end = api.coord([
|
||||
api.value(phaseDimensionIndex.due_date),
|
||||
api.value(phaseDimensionIndex.last_task_id),
|
||||
]);
|
||||
|
||||
const phaseHeight = ROW_HEIGHT / 3;
|
||||
|
||||
// Calculate the Y position for the phase, maybe above all tasks
|
||||
let topY = start[1] - ROW_HEIGHT - phaseHeight - 5; // Determine how far above tasks you want to draw phases
|
||||
if (topY <= grid.top) {
|
||||
topY = grid.top;
|
||||
}
|
||||
// when phase approach its last task set y to task y
|
||||
if (end[1] - ROW_HEIGHT - phaseHeight - 5 <= topY) {
|
||||
topY = end[1] - ROW_HEIGHT - phaseHeight - 5;
|
||||
}
|
||||
let bottomY = topY + ROW_HEIGHT + phaseHeight + 5; // Determine the bottom Y based on the tasks' Y positions
|
||||
if (bottomY >= api.getHeight() - grid.bottom) {
|
||||
bottomY = api.getHeight() - grid.bottom;
|
||||
}
|
||||
|
||||
// Phase rectangle
|
||||
let rectShape = this.clipRectByRect(params, {
|
||||
x: start[0],
|
||||
y: topY,
|
||||
width: end[0] - start[0],
|
||||
height: phaseHeight, // Define the height of the phase rectangle
|
||||
});
|
||||
if (rectShape) {
|
||||
rectShape.r = [5, 5, 0, 0];
|
||||
}
|
||||
|
||||
const phaseName = echartsFormat.truncateText(
|
||||
api.value(phaseDimensionIndex.name),
|
||||
rectShape?.width ?? 0,
|
||||
api.font({ fontSize: 14 }),
|
||||
);
|
||||
|
||||
let rect = {
|
||||
type: 'rect',
|
||||
shape: rectShape,
|
||||
ignore: !rectShape,
|
||||
style: api.style({
|
||||
fill: 'rgba(255,149,0,0.5)',
|
||||
text: phaseName,
|
||||
textStroke: 'rgb(181,106,0)',
|
||||
}),
|
||||
};
|
||||
|
||||
const lineWidth = 1;
|
||||
let y1 = topY + phaseHeight;
|
||||
if (y1 <= grid.top) {
|
||||
y1 = grid.top;
|
||||
}
|
||||
// start vertical line
|
||||
let startLine = {
|
||||
type: 'line',
|
||||
ignore:
|
||||
bottomY <= grid.top ||
|
||||
y1 >= api.getHeight() - grid.bottom ||
|
||||
start[0] + lineWidth / 2 <= grid.left ||
|
||||
start[0] >= api.getWidth() - grid.right,
|
||||
shape: {
|
||||
x1: start[0] + lineWidth / 2,
|
||||
y1,
|
||||
x2: start[0] + lineWidth / 2,
|
||||
y2: bottomY,
|
||||
},
|
||||
style: api.style({
|
||||
stroke: 'rgba(255,149,0,0.5)', // Example style
|
||||
lineWidth,
|
||||
lineDash: [3, 3, 4],
|
||||
}),
|
||||
};
|
||||
|
||||
// End vertical line
|
||||
let endLine = {
|
||||
type: 'line',
|
||||
ignore:
|
||||
bottomY <= grid.top ||
|
||||
y1 >= api.getHeight() - grid.bottom ||
|
||||
end[0] - lineWidth / 2 >= api.getWidth() - grid.right ||
|
||||
end[0] <= grid.left,
|
||||
shape: {
|
||||
x1: end[0] - lineWidth / 2,
|
||||
y1,
|
||||
x2: end[0] - lineWidth / 2,
|
||||
y2: bottomY,
|
||||
},
|
||||
style: api.style({
|
||||
stroke: 'rgba(255,149,0,0.5)', // Example style
|
||||
lineWidth,
|
||||
lineDash: [3, 3, 4],
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'group',
|
||||
children: [rect, startLine, endLine],
|
||||
};
|
||||
},
|
||||
|
||||
renderAxisLabelItem(params, api) {
|
||||
const y = api.coord([0, api.value(0)])[1];
|
||||
const isOutside = y <= 70 || y > api.getHeight();
|
||||
return {
|
||||
type: 'group',
|
||||
position: [10, y],
|
||||
ignore: isOutside,
|
||||
children: [
|
||||
{
|
||||
type: 'path',
|
||||
shape: {
|
||||
d: 'M 0 0 L 0 -20 C 20.3333 -20 40.6667 -20 52 -20 C 64 -20 65 -2 70 -2 L 70 0 Z',
|
||||
x: 12,
|
||||
y: -ROW_HEIGHT,
|
||||
width: 78,
|
||||
height: ROW_HEIGHT,
|
||||
layout: 'cover',
|
||||
},
|
||||
style: {
|
||||
fill: '#368c6c',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
style: {
|
||||
x: 15,
|
||||
y: -3,
|
||||
width: 80,
|
||||
text: api.value(dimensionIndex.task_name),
|
||||
textVerticalAlign: 'bottom',
|
||||
textAlign: 'left',
|
||||
textFill: '#fff',
|
||||
overflow: 'truncate',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
name: 'got_to_task_btn',
|
||||
children: [
|
||||
{
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: -10,
|
||||
y: -ROW_HEIGHT,
|
||||
width: ROW_HEIGHT,
|
||||
height: ROW_HEIGHT,
|
||||
layout: 'center',
|
||||
},
|
||||
style: {
|
||||
fill: '#5988E5',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'path',
|
||||
shape: {
|
||||
d: 'M15.7285 3.88396C17.1629 2.44407 19.2609 2.41383 20.4224 3.57981C21.586 4.74798 21.5547 6.85922 20.1194 8.30009L17.6956 10.7333C17.4033 11.0268 17.4042 11.5017 17.6976 11.794C17.9911 12.0863 18.466 12.0854 18.7583 11.7919L21.1821 9.35869C23.0934 7.43998 23.3334 4.37665 21.4851 2.5212C19.6346 0.663551 16.5781 0.905664 14.6658 2.82536L9.81817 7.69182C7.90688 9.61053 7.66692 12.6739 9.51519 14.5293C9.80751 14.8228 10.2824 14.8237 10.5758 14.5314C10.8693 14.2391 10.8702 13.7642 10.5779 13.4707C9.41425 12.3026 9.44559 10.1913 10.8809 8.75042L15.7285 3.88396Z M14.4851 9.47074C14.1928 9.17728 13.7179 9.17636 13.4244 9.46868C13.131 9.76101 13.1301 10.2359 13.4224 10.5293C14.586 11.6975 14.5547 13.8087 13.1194 15.2496L8.27178 20.1161C6.83745 21.556 4.73937 21.5863 3.57791 20.4203C2.41424 19.2521 2.44559 17.1408 3.88089 15.6999L6.30473 13.2667C6.59706 12.9732 6.59614 12.4984 6.30268 12.206C6.00922 11.9137 5.53434 11.9146 5.24202 12.2081L2.81818 14.6413C0.906876 16.5601 0.666916 19.6234 2.51519 21.4789C4.36567 23.3365 7.42221 23.0944 9.33449 21.1747L14.1821 16.3082C16.0934 14.3895 16.3334 11.3262 14.4851 9.47074Z',
|
||||
x: -10 * 0.8,
|
||||
y: -ROW_HEIGHT * 0.9,
|
||||
width: ROW_HEIGHT * 0.8,
|
||||
height: ROW_HEIGHT * 0.8,
|
||||
layout: 'center',
|
||||
},
|
||||
style: {
|
||||
fill: '#ffffff',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
clipRectByRect(params, rect) {
|
||||
return echartsGraphic.clipRectByRect(rect, {
|
||||
x: params.coordSys.x,
|
||||
y: params.coordSys.y,
|
||||
width: params.coordSys.width,
|
||||
height: params.coordSys.height,
|
||||
});
|
||||
},
|
||||
websocketLeaveChannel(userId) {
|
||||
this.$echo.leave(`gantt.${userId}`);
|
||||
},
|
||||
websocketEnterChannel(userId, handlers) {
|
||||
const channel = this.$echo.private(`gantt.${userId}`);
|
||||
for (const action in handlers) {
|
||||
channel.listen(`.gantt.${action}`, handlers[action]);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gantt {
|
||||
height: calc(100vh - 75px * 2);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="invite-form">
|
||||
<validation-observer ref="form">
|
||||
<div v-for="(user, index) in users" :key="index" class="row invite-form__group">
|
||||
<validation-provider v-slot="{ errors }" :vid="`users.${index}.email`" class="col-14">
|
||||
<at-input
|
||||
v-model="users[index]['email']"
|
||||
:placeholder="$t('field.email')"
|
||||
:status="errors.length > 0 ? 'error' : ''"
|
||||
>
|
||||
<template slot="prepend">{{ $t('field.email') }}</template>
|
||||
</at-input>
|
||||
<small>{{ errors[0] }}</small>
|
||||
</validation-provider>
|
||||
<validation-provider :vid="`users.${index}.role_id`" class="col-6">
|
||||
<role-select v-model="users[index]['role_id']"></role-select>
|
||||
</validation-provider>
|
||||
<at-button v-if="index > 0" class="col-2 invite-form__remove" @click="removeUser(index)"
|
||||
><i class="icon icon-x"></i
|
||||
></at-button>
|
||||
</div>
|
||||
</validation-observer>
|
||||
<at-button type="default" size="small" class="col-4" @click="handleAdd">{{ $t('control.add') }}</at-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ValidationObserver, ValidationProvider } from 'vee-validate';
|
||||
import RoleSelect from '@/components/RoleSelect';
|
||||
|
||||
export default {
|
||||
name: 'InviteInput',
|
||||
components: {
|
||||
ValidationObserver,
|
||||
ValidationProvider,
|
||||
RoleSelect,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: [Array, Object],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
users: [
|
||||
{
|
||||
email: null,
|
||||
role_id: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('input', this.users);
|
||||
},
|
||||
methods: {
|
||||
handleAdd() {
|
||||
this.users.push({ email: null, role_id: 2 });
|
||||
},
|
||||
removeUser(index) {
|
||||
this.users.splice(index, 1);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
users(value) {
|
||||
this.$emit('input', value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.invite-form {
|
||||
&__group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
max-height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"invitations": {
|
||||
"grid-title": "Invitations",
|
||||
"crud-title": "Invitation"
|
||||
},
|
||||
"navigation": {
|
||||
"invitations": "Invitations"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"invitations": {
|
||||
"grid-title": "Приглашения",
|
||||
"crud-title": "Приглашение"
|
||||
},
|
||||
"navigation": {
|
||||
"invitations": "Приглашения"
|
||||
}
|
||||
}
|
||||
16
resources/frontend/core/modules/Invitations/module.init.js
Normal file
16
resources/frontend/core/modules/Invitations/module.init.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export const ModuleConfig = {
|
||||
routerPrefix: 'settings',
|
||||
loadOrder: 10,
|
||||
moduleName: 'Invitations',
|
||||
};
|
||||
|
||||
export function init(context, router) {
|
||||
context.addCompanySection(require('./sections/invitations').default(context, router));
|
||||
|
||||
context.addLocalizationData({
|
||||
en: require('./locales/en'),
|
||||
ru: require('./locales/ru'),
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user