first commit

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

View File

@@ -0,0 +1,142 @@
<template>
<div class="groups">
<at-collapse simple accordion @on-change="event => (opened = event)">
<at-collapse-item
v-for="(group, index) in groups"
:key="index"
:name="String(index)"
:disabled="group.projects_count === 0"
>
<div slot="title">
<div class="groups__header">
<h5 :class="{ groups__disabled: group.projects_count === 0 }" class="groups__title">
<span
v-if="group.depth > 0 && group.breadCrumbs == null"
:class="{ groups__disabled: group.projects_count === 0 }"
class="groups__depth"
>
{{ group.depth | getSpaceByDepth }}
</span>
<span v-if="group.breadCrumbs" class="groups__bread-crumbs">
<span
v-for="(breadCrumb, index) in group.breadCrumbs"
:key="index"
class="groups__bread-crumbs__item"
@click.stop="$emit('getTargetClickGroupAndChildren', breadCrumb.id)"
>
{{ breadCrumb.name }} /
</span>
</span>
<span>{{ `${group.name} (${group.projects_count})` }}</span>
</h5>
<span @click.stop>
<router-link
v-if="$can('update', 'projectGroup')"
class="groups__header__link"
:to="{ name: 'ProjectGroups.crud.groups.edit', params: { id: group.id } }"
target="_blank"
rel="opener"
>
<i class="icon icon-external-link" />
</router-link>
</span>
</div>
</div>
<div v-if="group.projects_count > 0 && isOpen(index)" class="groups__projects-wrapper">
<GroupProjects :group-id="group.id" class="groups__projects" @reloadData="$emit('reloadData')" />
</div>
</at-collapse-item>
</at-collapse>
</div>
</template>
<script>
import GroupProjects from '../components/GroupProjects';
export default {
name: 'GroupCollapsable',
components: { GroupProjects },
data() {
return {
opened: [],
};
},
props: {
groups: {
type: Array,
required: true,
},
},
methods: {
isOpen(index) {
return this.opened[0] === String(index);
},
reloadData() {
this.$emit('reloadData');
},
},
filters: {
getSpaceByDepth(value) {
return ''.padStart(value, '-');
},
},
};
</script>
<style lang="scss" scoped>
.groups {
&__title {
display: inline-block;
}
.icon-external-link {
font-size: 20px;
}
&__disabled {
opacity: 0.3;
}
&__depth {
padding-right: 0.3em;
letter-spacing: 0.1em;
opacity: 0.3;
font-weight: 300;
}
&__header {
display: flex;
&__link {
margin-left: 5px;
}
}
&__bread-crumbs {
color: #0075b2;
}
&::v-deep {
.at-collapse {
&__item--active {
background-color: #fff;
.groups__title {
color: $blue-2;
}
}
&__header {
display: flex;
align-items: center;
padding: 15px;
}
&__content {
padding: 10px;
}
&__icon.icon-chevron-right {
position: static;
display: block;
color: black;
margin-right: 10px;
}
}
}
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<div class="projects">
<at-input
v-model="query"
type="text"
:placeholder="$t('message.project_search_input_placeholder')"
class="projects__search col-6"
@input="onSearch"
>
<template v-slot:prepend>
<i class="icon icon-search" />
</template>
</at-input>
<div class="at-container">
<div ref="tableWrapper" class="table">
<at-table ref="table" size="large" :columns="columns" :data="projects" />
</div>
</div>
<at-pagination :total="projectsTotal" :current="page" :page-size="limit" @page-change="loadPage" />
</div>
</template>
<script>
import ProjectService from '@/services/resource/project.service';
import TeamAvatars from '@/components/TeamAvatars';
import i18n from '@/i18n';
import debounce from 'lodash.debounce';
const service = new ProjectService();
export default {
name: 'GroupProjects',
props: {
groupId: {
type: Number,
required: true,
},
},
data() {
return {
projects: [],
projectsTotal: 0,
limit: 15,
query: '',
page: 1,
};
},
async created() {
this.search = debounce(this.search, 350);
await this.search();
},
methods: {
async loadPage(page) {
this.page = page;
this.resetOptions();
await this.loadOptions();
},
onSearch() {
this.search();
},
async search() {
this.totalPages = 0;
this.resetOptions();
await this.$nextTick();
await this.loadOptions();
await this.$nextTick();
},
async loadOptions() {
const filters = {
where: {
group: ['in', [this.groupId]],
},
with: ['users', 'tasks', 'can'],
withCount: ['tasks'],
search: {
query: this.query,
fields: ['name'],
},
page: this.page,
};
return service.getWithFilters(filters).then(({ data, pagination = data.pagination }) => {
this.projectsTotal = pagination.total;
this.currentPage = pagination.currentPage;
data.data.forEach(option => this.projects.push(option));
});
},
resetOptions() {
this.projects = [];
},
},
computed: {
columns() {
const columns = [
{
title: this.$t('field.project'),
key: 'name',
},
{
title: this.$t('field.members'),
key: 'users',
render: (h, { item }) => {
return h(TeamAvatars, {
props: {
users: item.users || [],
},
});
},
},
{
title: this.$t('field.amount_of_tasks'),
key: 'tasks',
render: (h, { item }) => {
const amountOfTasks = item.tasks_count || 0;
return h(
'span',
i18n.tc('projects.amount_of_tasks', amountOfTasks, {
count: amountOfTasks,
}),
);
},
},
];
const actions = [
{
title: 'control.view',
icon: 'icon-eye',
onClick: (router, { item }) => {
this.$router.push({ name: 'Projects.crud.projects.view', params: { id: item.id } });
},
renderCondition({ $store }) {
return true;
},
},
{
title: 'projects.members',
icon: 'icon-users',
onClick: (router, { item }) => {
this.$router.push({ name: 'Projects.members', params: { id: item.id } });
},
renderCondition({ $can }, item) {
return $can('updateMembers', 'project', item);
},
},
{
title: 'control.edit',
icon: 'icon-edit',
onClick: (router, { item }, context) => {
this.$router.push({ name: 'Projects.crud.projects.edit', params: { id: item.id } });
},
renderCondition: ({ $can }, item) => {
return $can('update', 'project', item);
},
},
{
title: 'control.delete',
actionType: 'error', // AT-UI action type,
icon: 'icon-trash-2',
onClick: async (router, { item }, context) => {
const isConfirm = await this.$CustomModal({
title: this.$t('notification.record.delete.confirmation.title'),
content: this.$t('notification.record.delete.confirmation.message'),
okText: this.$t('control.delete'),
cancelText: this.$t('control.cancel'),
showClose: false,
styles: {
'border-radius': '10px',
'text-align': 'center',
footer: {
'text-align': 'center',
},
header: {
padding: '16px 35px 4px 35px',
color: 'red',
},
body: {
padding: '16px 35px 4px 35px',
},
},
width: 320,
type: 'trash',
typeButton: 'error',
});
if (isConfirm !== 'confirm') {
return;
}
await service.deleteItem(item.id);
this.$Notify({
type: 'success',
title: this.$t('notification.record.delete.success.title'),
message: this.$t('notification.record.delete.success.message'),
});
await this.search();
this.$emit('reloadData');
},
renderCondition: ({ $can }, item) => {
return $can('delete', 'project', item);
},
},
];
columns.push({
title: this.$t('field.actions'),
render: (h, params) => {
let cell = h(
'div',
{
class: 'actions-column',
},
actions.map(item => {
if (
typeof item.renderCondition !== 'undefined'
? item.renderCondition(this, params.item)
: true
) {
return h(
'AtButton',
{
props: {
type: item.actionType || 'primary', // AT-ui button display type
icon: item.icon || undefined, // Prepend icon to button
},
on: {
click: () => {
item.onClick(this.$router, params, this);
},
},
class: 'action-button',
style: {
margin: '0 10px 0 0',
},
},
this.$t(item.title),
);
}
}),
);
return cell;
},
});
return columns;
},
},
};
</script>
<style lang="scss" scoped>
.projects {
&__search {
margin-bottom: $spacing-03;
}
.at-container {
margin-bottom: 1rem;
.table {
&::v-deep .at-table {
&__cell {
width: 100%;
overflow-x: hidden;
padding-top: $spacing-05;
padding-bottom: $spacing-05;
border-bottom: 2px solid $blue-3;
position: relative;
z-index: 0;
&:last-child {
max-width: unset;
}
}
.actions-column {
display: flex;
flex-flow: row nowrap;
}
.action-button {
margin-right: 1em;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
{
"groups": {
"grid-title": "Groups",
"crud-title": "Group"
},
"navigation": {
"project-groups": "By groups"
},
"message": {
"loading_projects": "Loading projects",
"project_search_input_placeholder": "type to find project",
"group_search_input_placeholder": "type to find project"
},
"field": {
"project": "Project",
"members": "Members",
"amount_of_tasks": "Amount of tasks",
"parent_group": "Parent group",
"loading_groups": "Loading groups"
}
}

View File

@@ -0,0 +1,21 @@
{
"groups": {
"grid-title": "Группы",
"crud-title": "Группа"
},
"navigation": {
"project-groups": "По группам"
},
"message": {
"loading_projects": "Загрузка проектов",
"project_search_input_placeholder": "введите, чтобы найти проект",
"group_search_input_placeholder": "введите, чтобы найти группу"
},
"field": {
"project": "Проект",
"members": "Участники",
"amount_of_tasks": "Кол-во задач",
"parent_group": "Родительская группа",
"loading_groups": "Загружаем группы"
}
}

View File

@@ -0,0 +1,132 @@
import ProjectGroupsService from '@/services/resource/project-groups.service';
import GroupSelect from '@/components/GroupSelect';
import i18n from '@/i18n';
import Vue from 'vue';
export const ModuleConfig = {
routerPrefix: 'project-groups',
loadOrder: 20,
moduleName: 'ProjectGroups',
};
export function init(context) {
const crud = context.createCrud('groups.crud-title', 'groups', ProjectGroupsService);
const crudEditRoute = crud.edit.getEditRouteName();
const crudNewRoute = crud.new.getNewRouteName();
const navigation = { edit: crudEditRoute, new: crudNewRoute };
crud.new.addToMetaProperties('permissions', 'groups/create', crud.new.getRouterConfig());
crud.new.addToMetaProperties('navigation', navigation, crud.new.getRouterConfig());
crud.edit.addToMetaProperties('permissions', 'groups/edit', crud.edit.getRouterConfig());
const fieldsToFill = [
{
key: 'id',
displayable: false,
},
{
label: 'field.name',
key: 'name',
type: 'text',
placeholder: 'field.name',
required: true,
},
{
label: 'field.parent_group',
key: 'parent_id',
render: (h, data) => {
return h(GroupSelect, {
props: { value: data.values.group_parent },
on: {
input(value) {
Vue.set(data.values, 'group_parent', value);
data.values.parent_id = value?.id ?? null;
},
},
});
},
required: false,
},
];
crud.new.addField(fieldsToFill);
crud.edit.addField(fieldsToFill);
context.addRoute(crud.getRouterConfig());
crud.edit.addPageControlsToBottom([
{
title: 'control.delete',
type: 'error',
icon: 'icon-trash-2',
onClick: async ({ service, $router }, item) => {
const isConfirm = await Vue.prototype.$CustomModal({
title: i18n.t('notification.record.delete.confirmation.title'),
content: i18n.t('notification.record.delete.confirmation.message'),
okText: i18n.t('control.delete'),
cancelText: i18n.t('control.cancel'),
showClose: false,
styles: {
'border-radius': '10px',
'text-align': 'center',
footer: {
'text-align': 'center',
},
header: {
padding: '16px 35px 4px 35px',
color: 'red',
},
body: {
padding: '16px 35px 4px 35px',
},
},
width: 320,
type: 'trash',
typeButton: 'error',
});
if (isConfirm !== 'confirm') {
return;
}
await service.deleteItem(item);
Vue.prototype.$Notify({
type: 'success',
title: i18n.t('notification.record.delete.success.title'),
message: i18n.t('notification.record.delete.success.message'),
});
$router.push({ name: context.getModuleRouteName() });
},
renderCondition: ({ $can }) => {
return $can('delete', 'projectGroup');
},
},
]);
context.addRoute({
path: `/${context.routerPrefix}`,
name: context.getModuleRouteName(),
component: () => import(/* webpackChunkName: "project-groups" */ './views/ProjectGroups.vue'),
meta: {
auth: true,
},
});
context.addNavbarEntryDropDown({
label: 'navigation.project-groups',
section: 'navigation.dropdown.projects',
to: {
name: context.getModuleRouteName(),
},
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,217 @@
<template>
<div class="project-groups">
<h1 class="page-title">{{ $t('groups.grid-title') }}</h1>
<div class="project-groups__search-container">
<at-input
v-model="query"
type="text"
:placeholder="$t('message.group_search_input_placeholder')"
class="project-groups__search-container__search col-6"
@input="onSearch"
>
<template slot="prepend">
<i class="icon icon-search" />
</template>
</at-input>
<div v-if="isGroupSelected" class="project-groups__selected-group">
{{ groups[0].name }}
<at-button
icon="icon-x"
circle
size="small"
class="project-groups__selected-group__clear"
@click="onSearch"
></at-button>
</div>
</div>
<div class="at-container">
<div v-if="Object.keys(groups).length && !isDataLoading">
<GroupCollapsable
:groups="groups"
@getTargetClickGroupAndChildren="getTargetClickGroupAndChildren"
@reloadData="onSearch"
/>
<div v-show="hasNextPage" ref="load" class="option__infinite-loader">
{{ $t('field.loading_groups') }} <i class="icon icon-loader" />
</div>
</div>
<div v-else class="at-container__inner no-data">
<preloader v-if="isDataLoading" />
<span>{{ $t('message.no_data') }}</span>
</div>
</div>
</div>
</template>
<script>
import ProjectGroupsService from '@/services/resource/project-groups.service';
import GroupCollapsable from '../components/GroupCollapsable';
import Preloader from '@/components/Preloader';
import debounce from 'lodash.debounce';
const service = new ProjectGroupsService();
export default {
name: 'ProjectGroups',
components: {
Preloader,
GroupCollapsable,
},
data() {
return {
groups: [],
isDataLoading: false,
groupsTotal: 0,
limit: 10,
totalPages: 0,
currentPage: 0,
query: '',
isGroupSelected: false,
};
},
async created() {
this.search = debounce(this.search, 350);
this.requestTimestamp = Date.now();
this.search(this.requestTimestamp);
},
mounted() {
this.observer = new IntersectionObserver(this.infiniteScroll);
},
computed: {
hasNextPage() {
return this.currentPage < this.totalPages;
},
},
methods: {
async infiniteScroll([{ isIntersecting, target }]) {
if (isIntersecting) {
const requestTimestamp = +target.dataset.requestTimestamp;
if (requestTimestamp === this.requestTimestamp) {
await this.loadOptions(requestTimestamp);
await this.$nextTick();
this.observer.disconnect();
this.observe(requestTimestamp);
}
}
},
onSearch() {
this.isGroupSelected = false;
this.requestTimestamp = Date.now();
this.search(this.requestTimestamp);
},
async search(requestTimestamp) {
this.observer.disconnect();
this.totalPages = 0;
this.currentPage = 0;
this.resetOptions();
await this.$nextTick();
await this.loadOptions(requestTimestamp);
await this.$nextTick();
this.observe(requestTimestamp);
},
observe(requestTimestamp) {
if (this.$refs.load) {
this.$refs.load.dataset.requestTimestamp = requestTimestamp;
this.observer.observe(this.$refs.load);
}
},
async loadOptions() {
const filters = {
search: { query: this.query, fields: ['name'] },
with: [],
page: this.currentPage + 1,
limit: this.limit,
};
if (this.query !== '') {
filters.with.push('groupParentsWithProjectsCount');
}
return service.getWithFilters(filters).then(({ data, pagination }) => {
this.groupsTotal = pagination.total;
if (this.query === '') {
this.totalPages = pagination.totalPages;
this.currentPage = pagination.currentPage;
data.forEach(option => this.groups.push(option));
} else {
this.totalPages = pagination.totalPages;
this.currentPage = pagination.currentPage;
data.forEach(option => {
let breadCrumbs = [];
option.group_parents_with_projects_count.forEach(el => {
breadCrumbs.push({
name: `${el.name} (${el.projects_count})`,
id: el.id,
});
});
option.breadCrumbs = breadCrumbs;
this.groups.push(option);
});
}
});
},
resetOptions() {
this.groups = [];
},
getTargetClickGroupAndChildren(id) {
this.query = '';
service
.getWithFilters({
where: { id },
with: ['descendantsWithDepthAndProjectsCount'],
})
.then(({ data, pagination }) => {
this.totalPages = pagination.totalPages;
this.currentPage = pagination.currentPage;
this.resetOptions();
this.groups.push(data[0]);
data[0].descendants_with_depth_and_projects_count.forEach(element => {
this.groups.push(element);
});
this.isGroupSelected = true;
});
},
},
};
</script>
<style lang="scss" scoped>
.no-data {
text-align: center;
font-weight: bold;
position: relative;
}
.project-groups {
&__search-container {
display: flex;
align-items: center;
margin-bottom: $spacing-03;
}
&__selected-group {
background: #ddd;
border-radius: 90px/100px;
padding: 5px 20px;
margin-left: 15px;
align-items: center;
&__clear {
margin-left: 10px;
&:hover {
background: rgba(97, 144, 232, 0.6);
}
}
}
&::v-deep {
.at-container {
overflow: hidden;
margin-bottom: 1rem;
}
}
}
</style>