Initial commit: Angular frontend and Expressjs backend
This commit is contained in:
16
worklenz-frontend/.editorconfig
Normal file
16
worklenz-frontend/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
46
worklenz-frontend/.eslintrc.json
Normal file
46
worklenz-frontend/.eslintrc.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": [
|
||||
"projects/**/*"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@angular-eslint/recommended",
|
||||
"plugin:@angular-eslint/template/process-inline-templates"
|
||||
],
|
||||
"rules": {
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"prefix": "worklenz",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "element",
|
||||
"prefix": "worklenz",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/template/recommended"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
44
worklenz-frontend/.gitignore
vendored
Normal file
44
worklenz-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
# Only exists if Bazel was run
|
||||
/bazel-out
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# profiling files
|
||||
chrome-profiler-events*.json
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
2
worklenz-frontend/.npmrc
Normal file
2
worklenz-frontend/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
engine-strict=true
|
||||
fund=false # Don't print the trailing funding message
|
||||
11
worklenz-frontend/Dockerfile
Normal file
11
worklenz-frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY . /usr/src/app
|
||||
|
||||
RUN npm install -g @angular/cli
|
||||
|
||||
RUN npm install
|
||||
|
||||
CMD ["npm", "start"]
|
||||
37
worklenz-frontend/README.md
Normal file
37
worklenz-frontend/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Worklenz
|
||||
|
||||
[](https://github.com/ceydigital/worklenz-frontend/actions/workflows/workflow.yml)
|
||||
|
||||
[](https://sonar.ceydigitalworld.com/dashboard?id=Worklenz-Frontend) [](https://sonar.ceydigitalworld.com/dashboard?id=Worklenz-Frontend) [](https://sonar.ceydigitalworld.com/dashboard?id=Worklenz-Frontend) [](https://sonar.ceydigitalworld.com/dashboard?id=Worklenz-Frontend) [](https://sonar.ceydigitalworld.com/dashboard?id=Worklenz-Frontend) [](https://sonar.ceydigitalworld.com/dashboard?id=Worklenz-Frontend)
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 13.1.1.
|
||||
|
||||
## Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
|
||||
## References
|
||||
- https://pro.ant.design/
|
||||
- https://ant.design/components/overview/
|
||||
- https://fontawesome.com/v5.15/icons?d=gallery&p=2&m=free
|
||||
- https://feathericons.com/
|
||||
150
worklenz-frontend/angular.json
Normal file
150
worklenz-frontend/angular.json
Normal file
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"worklenz": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
},
|
||||
"@schematics/angular:application": {
|
||||
"strict": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "worklenz",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/worklenz",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",
|
||||
"output": "/assets/"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/theme.less",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [],
|
||||
"serviceWorker": true,
|
||||
"ngswConfigPath": "ngsw-config.json",
|
||||
"allowedCommonJsDependencies": [
|
||||
"jquery",
|
||||
"moment",
|
||||
"canvg",
|
||||
"core-js",
|
||||
"file-saver",
|
||||
"raf",
|
||||
"rgbcolor"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "5.7mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "5kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "worklenz:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "worklenz:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "worklenz:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss",
|
||||
"./node_modules/@syncfusion/ej2-material-theme/styles/material.css"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false,
|
||||
"schematicCollections": [
|
||||
"@angular-eslint/schematics"
|
||||
]
|
||||
}
|
||||
}
|
||||
44
worklenz-frontend/karma.conf.js
Normal file
44
worklenz-frontend/karma.conf.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
jasmine: {
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
},
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/worklenz'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
30
worklenz-frontend/ngsw-config.json
Normal file
30
worklenz-frontend/ngsw-config.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"index": "/",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app",
|
||||
"installMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/",
|
||||
"/manifest.webmanifest",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/assets/**",
|
||||
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
16478
worklenz-frontend/package-lock.json
generated
Normal file
16478
worklenz-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
76
worklenz-frontend/package.json
Normal file
76
worklenz-frontend/package.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "worklenz",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.config.json --disable-host-check",
|
||||
"build": "ng build --extract-licenses --common-chunk --delete-output-path --output-hashing=all",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.13.0",
|
||||
"npm": ">= 8.5.5",
|
||||
"yarn": "WARNING: Please use npm package manager instead of yarn"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^16.2.0",
|
||||
"@angular/cdk": "^16.2.0",
|
||||
"@angular/common": "^16.2.0",
|
||||
"@angular/compiler": "^16.2.0",
|
||||
"@angular/core": "^16.2.0",
|
||||
"@angular/forms": "^16.2.0",
|
||||
"@angular/platform-browser": "^16.2.0",
|
||||
"@angular/platform-browser-dynamic": "^16.2.0",
|
||||
"@angular/router": "^16.2.0",
|
||||
"@angular/service-worker": "^16.2.0",
|
||||
"@rx-angular/cdk": "^16.0.0",
|
||||
"@rx-angular/template": "^16.0.2",
|
||||
"@tinymce/tinymce-angular": "^7.0.0",
|
||||
"antd": "^5.8.2",
|
||||
"bootstrap": "^5.3.1",
|
||||
"chart.js": "^4.3.3",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"chartjs-to-image": "^1.2.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jquery": "^3.7.0",
|
||||
"jspdf": "^2.5.1",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"ng-zorro-antd": "^16.1.0",
|
||||
"ng2-charts": "^5.0.3",
|
||||
"ngx-doc-viewer": "^15.0.1",
|
||||
"ngx-socket-io": "^4.5.1",
|
||||
"rxjs": "~7.4.0",
|
||||
"tinymce": "^6.7.3",
|
||||
"tslib": "^2.6.1",
|
||||
"zone.js": "^0.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^16.2.0",
|
||||
"@angular-eslint/builder": "^16.1.0",
|
||||
"@angular-eslint/eslint-plugin": "^16.0.3",
|
||||
"@angular-eslint/eslint-plugin-template": "^16.0.3",
|
||||
"@angular-eslint/schematics": "^16.1.0",
|
||||
"@angular-eslint/template-parser": "^16.1.0",
|
||||
"@angular/cli": "^16.2.0",
|
||||
"@angular/compiler-cli": "^16.2.0",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/jasmine": "~3.10.0",
|
||||
"@types/jquery": "^3.5.16",
|
||||
"@types/node": "^12.20.55",
|
||||
"@types/quill": "^2.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "5.48.2",
|
||||
"@typescript-eslint/parser": "5.48.2",
|
||||
"eslint": "^8.46.0",
|
||||
"jasmine-core": "~3.10.0",
|
||||
"karma": "~6.3.0",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage": "~2.1.0",
|
||||
"karma-jasmine": "~4.0.0",
|
||||
"karma-jasmine-html-reporter": "~1.7.0",
|
||||
"typescript": "~5.0.4"
|
||||
}
|
||||
}
|
||||
24
worklenz-frontend/proxy.config.json
Normal file
24
worklenz-frontend/proxy.config.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://127.0.0.1:3000/",
|
||||
"headers": {
|
||||
"language": "en",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
},
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/secure": {
|
||||
"target": "http://127.0.0.1:3000/",
|
||||
"headers": {
|
||||
"language": "en",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
},
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
0
worklenz-frontend/src/app/DTOs/.gitkeep
Normal file
0
worklenz-frontend/src/app/DTOs/.gitkeep
Normal file
@@ -0,0 +1,16 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {RouterModule, Routes} from '@angular/router';
|
||||
import {AccountSetupComponent} from "./account-setup/account-setup.component";
|
||||
import {TeamsListComponent} from "./teams-list/teams-list.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{path: '', component: AccountSetupComponent},
|
||||
{path: 'teams', component: TeamsListComponent}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AccountSetupRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
||||
import {AccountSetupRoutingModule} from './account-setup-routing.module';
|
||||
import {AccountSetupComponent} from './account-setup/account-setup.component';
|
||||
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {NzButtonModule} from "ng-zorro-antd/button";
|
||||
import {NzInputModule} from "ng-zorro-antd/input";
|
||||
import {NzSelectModule} from "ng-zorro-antd/select";
|
||||
import {NzStepsModule} from "ng-zorro-antd/steps";
|
||||
import {NzSkeletonModule} from "ng-zorro-antd/skeleton";
|
||||
import {NzSpaceModule} from 'ng-zorro-antd/space';
|
||||
import {NzIconModule} from 'ng-zorro-antd/icon';
|
||||
import {NzTypographyModule} from 'ng-zorro-antd/typography';
|
||||
import {NzDividerModule} from 'ng-zorro-antd/divider';
|
||||
import {NzListModule} from "ng-zorro-antd/list";
|
||||
import {TeamsListComponent} from './teams-list/teams-list.component';
|
||||
import {NzRadioModule} from "ng-zorro-antd/radio";
|
||||
import {ProjectTemplateImportDrawerComponent} from "@admin/components/project-template-import-drawer/project-template-import-drawer.component";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AccountSetupComponent,
|
||||
TeamsListComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
AccountSetupRoutingModule,
|
||||
ReactiveFormsModule,
|
||||
NzInputModule,
|
||||
NzFormModule,
|
||||
NzButtonModule,
|
||||
NzSelectModule,
|
||||
NzStepsModule,
|
||||
NzSkeletonModule,
|
||||
NzSpaceModule,
|
||||
NzIconModule,
|
||||
NzTypographyModule,
|
||||
NzDividerModule,
|
||||
NzListModule,
|
||||
NgOptimizedImage,
|
||||
NzRadioModule,
|
||||
ProjectTemplateImportDrawerComponent
|
||||
]
|
||||
})
|
||||
export class AccountSetupModule {
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<div class="setup-wrapper m-auto px-3">
|
||||
<div class="row">
|
||||
<div class="py-5">
|
||||
<div class="text-center">
|
||||
<img alt="Worklenz" height="50" src="/assets/images/logo.png">
|
||||
</div>
|
||||
<h5 nz-typography class="mb-4 mt-1 text-center">
|
||||
Setup your account.
|
||||
</h5>
|
||||
<div class="mt-4 pt-5 col-xl-8 col-lg-10 col-md-12 mx-auto bg-white">
|
||||
<nz-space nzDirection="vertical" class="w-100">
|
||||
<nz-skeleton *nzSpaceItem class="d-block" [nzActive]="true" [nzLoading]="verifying">
|
||||
|
||||
<nz-steps class="mb-3 mt-3 justify-content-center w-steps mx-auto" [nzCurrent]="index"
|
||||
[nzDirection]="'horizontal'" [nzSize]="'default'" (nzIndexChange)="onIndexChange($event)">
|
||||
<nz-step [nzDisabled]="index < 0" [class.active-half]="isTeamNameValid()"></nz-step>
|
||||
<nz-step [nzDisabled]="index < 1" [class.active-half]="isProjectNameValid()"></nz-step>
|
||||
<nz-step [nzDisabled]="index < 2"></nz-step>
|
||||
<nz-step [nzDisabled]="index < 3"></nz-step>
|
||||
</nz-steps>
|
||||
|
||||
<form (submit)="next()" [formGroup]="form" [nzLayout]="'vertical'" nz-form
|
||||
class="w-600 mx-auto mt-5 mb-5 pb-3">
|
||||
|
||||
<ng-container [ngSwitch]="index">
|
||||
|
||||
<!-- workspace name step -->
|
||||
<nz-form-item *ngSwitchCase="0">
|
||||
<h2 nz-typography>Name your organization.</h2>
|
||||
<span nz-typography class="label-description">
|
||||
Pick a name for your Worklenz account.
|
||||
</span>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<input [formControlName]="'team_name'" nz-input [placeholder]="teamSetupPlaceholder" type="text"
|
||||
tabindex="0" [id]="teamNameId"/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<!-- end of workspace name -->
|
||||
|
||||
<!-- project name step -->
|
||||
<nz-form-item *ngSwitchCase="1">
|
||||
<h2 nz-typography>Create your first project.</h2>
|
||||
<span nz-typography class="label-description">
|
||||
What project are you working on right now?
|
||||
</span>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<input [formControlName]="'project_name'" nz-input [placeholder]="'e.g. Worklenz marketing plan'"
|
||||
[id]="projectNameId"
|
||||
type="text" tabindex="0"/>
|
||||
</nz-form-control>
|
||||
|
||||
<div class="position-relative">
|
||||
<h4 nz-typography class="text-center mt-2 vert-text">or</h4>
|
||||
<div class="vert-line"></div>
|
||||
</div>
|
||||
|
||||
<button [nzType]="'primary'" nz-button (click)="openTemplateSelector()"
|
||||
class="ms-auto me-auto">Import from templates
|
||||
</button>
|
||||
|
||||
</nz-form-item>
|
||||
<!-- end of project name step -->
|
||||
|
||||
<!-- start of add tasks step -->
|
||||
<div *ngSwitchCase="2">
|
||||
<h2 nz-typography>Create your first task.</h2>
|
||||
<span nz-typography class="label-description">
|
||||
Type few tasks that you are going to do in <span nz-typography>"<mark>{{projectName}}</mark>".</span>
|
||||
</span>
|
||||
<nz-list>
|
||||
<nz-list-item *ngFor="let item of getTaskControls.controls; let i = index;"
|
||||
[formArrayName]="'tasks'">
|
||||
<input nz-input placeholder="Your Task" [attr.id]="'task-name-input-' + i" [formControlName]="i"
|
||||
tabindex="0"/>
|
||||
<ul nz-list-item-actions>
|
||||
<nz-list-item-action>
|
||||
<span nz-icon nzType="close-circle" class="dynamic-delete-button"
|
||||
(click)="removeTaskRow(i, $event)"></span>
|
||||
</nz-list-item-action>
|
||||
</ul>
|
||||
</nz-list-item>
|
||||
<nz-list-item>
|
||||
<button nz-button nzType="dashed" class="add-button" (click)="addNewTaskRow($event)">
|
||||
<span nz-icon nzType="plus"></span>
|
||||
Add another
|
||||
</button>
|
||||
</nz-list-item>
|
||||
</nz-list>
|
||||
</div>
|
||||
<!-- end of add tasks step -->
|
||||
|
||||
<!-- start of team members step -->
|
||||
<nz-form-item *ngSwitchCase="3">
|
||||
<h2 nz-typography>
|
||||
Invite your team to work with <br>
|
||||
<span nz-typography style="font-size: 20px;font-weight:
|
||||
400;">
|
||||
<!-- use team name here -->
|
||||
"<mark>{{workspaceName}}</mark>".
|
||||
</span>
|
||||
</h2>
|
||||
<span nz-typography class="label-description">
|
||||
Invite with email <span class="ms-1" nz-icon nzType="mail" nzTheme="outline"></span>
|
||||
</span>
|
||||
|
||||
<nz-list>
|
||||
<nz-list-item *ngFor="let item of getTeamMemberControls.controls; let i = index;"
|
||||
[formArrayName]="'team_members'">
|
||||
<ng-container>
|
||||
<input nz-input placeholder="Email address" [attr.id]="emailInputId"
|
||||
[formControlName]="i"/>
|
||||
<ul nz-list-item-actions>
|
||||
<nz-list-item-action>
|
||||
<span nz-icon nzType="close-circle" class="dynamic-delete-button"
|
||||
(click)="removeTeamMember(i, $event)"></span>
|
||||
</nz-list-item-action>
|
||||
</ul>
|
||||
</ng-container>
|
||||
</nz-list-item>
|
||||
<nz-list-item>
|
||||
<button nz-button nzType="dashed" class="add-button" (click)="addNewTeamMemberRow($event)">
|
||||
<span nz-icon nzType="plus"></span>
|
||||
Add another
|
||||
</button>
|
||||
</nz-list-item>
|
||||
</nz-list>
|
||||
</nz-form-item>
|
||||
<!-- end of add team members step -->
|
||||
</ng-container>
|
||||
|
||||
<!-- button steps for account setup -->
|
||||
<div class="d-flex mt-5">
|
||||
<button (click)="previous()" nz-button type="button" class="ps-0" nzType="link" *ngIf="index !== 0">
|
||||
Go back
|
||||
</button>
|
||||
<button *ngIf="index === 3 && !loading" (click)="skipInvite()" [nzLoading]="loading" nzType="text"
|
||||
type="button"
|
||||
nz-button>
|
||||
<span nz-typography style="font-weight:
|
||||
500;color:#00000073;">Skip for now</span>
|
||||
</button>
|
||||
<!-- rename to Finish when goes to last step -->
|
||||
<button [nzLoading]="loading" [nzType]="'primary'" [disabled]="!isValid()" nz-button
|
||||
class="ms-auto">Continue
|
||||
</button>
|
||||
</div>
|
||||
<!-- button steps for account setup -->
|
||||
</form>
|
||||
</nz-skeleton>
|
||||
</nz-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ant-grey" style="position: fixed;"></div>
|
||||
|
||||
<worklenz-project-template-import-drawer
|
||||
[showBothTabs]="false"
|
||||
(importProject)="templateSelected($event)">
|
||||
</worklenz-project-template-import-drawer>
|
||||
@@ -0,0 +1,116 @@
|
||||
nz-steps {
|
||||
transform: scale(0.83);
|
||||
@media (max-width: 480px) {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-wrapper {
|
||||
width: 100%;
|
||||
// max-width: 380px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.w-600 {
|
||||
width: 600px;
|
||||
@media(max-width: 860px) {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-ant-grey {
|
||||
background: #FAFAFA;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.w-steps {
|
||||
width: 725px;
|
||||
@media(max-width: 860px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.label-description {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #00000073;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.setup-success {
|
||||
-webkit-animation: scale-in-center 0.15s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
|
||||
animation: scale-in-center 0.15s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@-webkit-keyframes scale-in-center {
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in-center {
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
h2.ant-typography, div.ant-typography-h2, div.ant-typography-h2 > textarea, .ant-typography h2 {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.dynamic-delete-button {
|
||||
font-size: 20px !important;
|
||||
color: #999 !important;
|
||||
cursor: pointer !important;
|
||||
position: relative !important;
|
||||
transition: all .3s !important;
|
||||
margin-left: 5px !important;
|
||||
}
|
||||
|
||||
.vert-text {
|
||||
max-width: 40px;
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
z-index: 99;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.vert-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
content: '';
|
||||
height: 1px;
|
||||
background-color: #00000047;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
margin-bottom: auto;
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {AccountSetupComponent} from './account-setup.component';
|
||||
|
||||
describe('AccountSetupComponent', () => {
|
||||
let component: AccountSetupComponent;
|
||||
let fixture: ComponentFixture<AccountSetupComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [AccountSetupComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AccountSetupComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import {AfterViewInit, Component, OnInit, ViewChild} from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ValidationErrors,
|
||||
ValidatorFn,
|
||||
Validators
|
||||
} from "@angular/forms";
|
||||
import {AppService} from "@services/app.service";
|
||||
import {AuthService} from "@services/auth.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {log_error, smallId} from "@shared/utils";
|
||||
import {ProfileSettingsApiService} from "@api/profile-settings-api.service";
|
||||
import {EMAIL_REGEXP, WHITESPACE_REGEXP} from "@shared/constants";
|
||||
import {
|
||||
ProjectTemplateImportDrawerComponent
|
||||
} from "@admin/components/project-template-import-drawer/project-template-import-drawer.component";
|
||||
import {ProjectTemplateApiService} from "@api/project-template-api.service";
|
||||
|
||||
export interface IAccountSetupRequest {
|
||||
team_name?: string;
|
||||
project_name?: string;
|
||||
tasks: string[];
|
||||
team_members: string[];
|
||||
}
|
||||
|
||||
export interface IAccountSetupResponse {
|
||||
id?: string;
|
||||
has_invitations?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-account-setup',
|
||||
templateUrl: './account-setup.component.html',
|
||||
styleUrls: ['./account-setup.component.scss']
|
||||
})
|
||||
export class AccountSetupComponent implements OnInit, AfterViewInit {
|
||||
@ViewChild(ProjectTemplateImportDrawerComponent) projectTemplateDrawer!: ProjectTemplateImportDrawerComponent;
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
inputsMap: { [x: number]: string } = {};
|
||||
validateForm!: FormGroup;
|
||||
validateFormMember!: FormGroup;
|
||||
loading = false;
|
||||
verifying = false;
|
||||
|
||||
readonly teamNameId = smallId(6);
|
||||
readonly projectNameId = smallId(6);
|
||||
readonly emailInputId = smallId(6);
|
||||
|
||||
skipInviteClicked = false;
|
||||
selectedTemplateId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private app: AppService,
|
||||
private auth: AuthService,
|
||||
private api: ProfileSettingsApiService,
|
||||
private templateApi: ProjectTemplateApiService,
|
||||
private router: Router
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
team_name: [null, [Validators.required, Validators.pattern(WHITESPACE_REGEXP)]],
|
||||
project_name: [null, [Validators.required, Validators.pattern(WHITESPACE_REGEXP)]],
|
||||
tasks: this.fb.array([], [Validators.minLength(1), Validators.pattern(WHITESPACE_REGEXP)]),
|
||||
team_members: this.fb.array([], [Validators.minLength(1), this.validEmail(EMAIL_REGEXP)])
|
||||
})
|
||||
this.app.setTitle('Setup your account');
|
||||
}
|
||||
|
||||
get profile() {
|
||||
return this.auth.getCurrentSession();
|
||||
}
|
||||
|
||||
get teamSetupPlaceholder() {
|
||||
return `e.g., ${this.profile?.name}'s Team`;
|
||||
}
|
||||
|
||||
get projectName() {
|
||||
return this.form.value.project_name;
|
||||
}
|
||||
|
||||
get workspaceName() {
|
||||
return this.form.value.team_name;
|
||||
}
|
||||
|
||||
_index = 0;
|
||||
|
||||
get index() {
|
||||
return this._index;
|
||||
}
|
||||
|
||||
set index(i) {
|
||||
this._index = i;
|
||||
}
|
||||
|
||||
get getTaskControls() {
|
||||
return <FormArray>this.form.get('tasks');
|
||||
}
|
||||
|
||||
get getTasks() {
|
||||
return this.form.controls['tasks'] as FormArray;
|
||||
}
|
||||
|
||||
get getTeamMemberControls() {
|
||||
return <FormArray>this.form.get('team_members');
|
||||
}
|
||||
|
||||
get getTeamMembers() {
|
||||
return this.form.controls['team_members'] as FormArray;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.reauthorize();
|
||||
this.validateForm = this.fb.group({});
|
||||
this.validateFormMember = this.fb.group({});
|
||||
this.addNewTaskRow();
|
||||
this.addNewTeamMemberRow();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.inputsMap = {
|
||||
0: this.teamNameId,
|
||||
1: this.projectNameId,
|
||||
2: 'task-name-input-0',
|
||||
3: this.emailInputId
|
||||
};
|
||||
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
public async submit() {
|
||||
if (this.loading) return;
|
||||
try {
|
||||
this.loading = true;
|
||||
const model = this.form.value;
|
||||
|
||||
model.tasks = model.tasks.filter((t: any) => t?.trim().length);
|
||||
model.template_id = this.selectedTemplateId;
|
||||
|
||||
let res: any;
|
||||
if (model.template_id) {
|
||||
res = await this.templateApi.setupAccount(model);
|
||||
} else {
|
||||
res = await this.api.setupAccount(model);
|
||||
}
|
||||
|
||||
await this.auth.authorize();
|
||||
if (res.done && res.body.id) {
|
||||
await this.auth.authorize();
|
||||
|
||||
const url = (res.body.has_invitations)
|
||||
? `/worklenz/setup/teams`
|
||||
: `/worklenz/projects/${res.body.id}`;
|
||||
await this.router.navigate([url]);
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (this.index > 0) {
|
||||
this.index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
next() {
|
||||
if (!this.isValid()) return;
|
||||
|
||||
if ((this.index + 1) > 3) {
|
||||
void this.submit();
|
||||
} else {
|
||||
this.index++;
|
||||
this.focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
onIndexChange(index: number) {
|
||||
this.index = index;
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
validEmail(pattern: RegExp): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
let valid = false;
|
||||
if (Array.isArray(control.value)) {
|
||||
valid = control.value.every(email => pattern.test(email));
|
||||
} else {
|
||||
valid = pattern.test(control.value);
|
||||
}
|
||||
return valid ? null : {email: {value: control.value}};
|
||||
};
|
||||
}
|
||||
|
||||
isValidTasksInput() {
|
||||
if (this.getTasks.length && this.getTasks.valid) return true;
|
||||
return this.getTasks.valid;
|
||||
}
|
||||
|
||||
isValidTeamMembers() {
|
||||
return this.getTeamMembers.length && this.getTeamMembers.valid;
|
||||
}
|
||||
|
||||
isValid() {
|
||||
if (this.index === 0) return this.form.controls["team_name"].valid;
|
||||
if (this.index === 1) return this.form.controls["project_name"].valid;
|
||||
if (this.index === 2) return this.isValidTasksInput();
|
||||
if (this.index === 3) return this.isValidTeamMembers();
|
||||
return false;
|
||||
}
|
||||
|
||||
addNewTaskRow(event?: MouseEvent): void {
|
||||
if (event) event.preventDefault();
|
||||
const emptyTaskInput = new FormControl(null, [Validators.required]);
|
||||
this.getTasks.push(emptyTaskInput);
|
||||
// Focus new input
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(`#task-name-input-${this.getTaskControls.controls.length - 1}`) as HTMLInputElement;
|
||||
element?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
removeTaskRow(i: number, e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
const tasks = this.getTasks;
|
||||
if (tasks.length > 1) {
|
||||
tasks.removeAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
addNewTeamMemberRow(e?: MouseEvent): void {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const teamMembers = this.getTeamMembers;
|
||||
const teamMemberForm = new FormControl('', [Validators.email]);
|
||||
|
||||
teamMembers.push(teamMemberForm);
|
||||
}
|
||||
|
||||
removeTeamMember(i: number, e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
const teamMembers = this.getTeamMembers;
|
||||
if (teamMembers.length > 1) {
|
||||
teamMembers.removeAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
isTeamNameValid() {
|
||||
return this.form.controls["team_name"].valid
|
||||
}
|
||||
|
||||
isProjectNameValid() {
|
||||
return this.form.controls["project_name"].valid
|
||||
}
|
||||
|
||||
private async reauthorize() {
|
||||
this.verifying = true;
|
||||
await this.auth.authorize();
|
||||
if (this.auth.getCurrentSession()?.setup_completed)
|
||||
return this.router.navigate(['/worklenz/home']);
|
||||
this.verifying = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
private focusInput() {
|
||||
setTimeout(() => {
|
||||
const id = this.inputsMap[this.index];
|
||||
const element = document.querySelector(`#${id}`) as HTMLInputElement;
|
||||
element?.focus();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
skipInvite() {
|
||||
this.skipInviteClicked = false;
|
||||
this.form.controls['team_members'].reset([]);
|
||||
void this.submit();
|
||||
}
|
||||
|
||||
openTemplateSelector() {
|
||||
this.projectTemplateDrawer.open();
|
||||
}
|
||||
|
||||
templateSelected(event: any) {
|
||||
this.selectedTemplateId = event.template_id;
|
||||
this.submit();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="setup-wrapper m-auto px-3">
|
||||
<div class="row">
|
||||
<div class="py-5">
|
||||
<div class="text-center">
|
||||
<img alt="Worklenz" height="50" ngSrc="/assets/images/logo.png" width="235">
|
||||
</div>
|
||||
|
||||
<div class="pt-5 col-md-3 mx-auto bg-white">
|
||||
<nz-skeleton [nzActive]="true" [nzLoading]="loading">
|
||||
<nz-radio-group [(ngModel)]="selectedTeamId" [name]="'team'" class="w-100">
|
||||
<ng-container *ngIf="teams.length">
|
||||
<h5 [ngStyle]="{ 'margin-bottom.px': 16 }" nz-typography>Teams</h5>
|
||||
<nz-list nzBordered>
|
||||
<nz-list-item
|
||||
*ngFor="let item of teams"
|
||||
[class.selected]="selectedTeamId === item.id"
|
||||
(click)="selectTeam(item.id, false)"
|
||||
>
|
||||
<span nz-typography>{{item.name}}</span>
|
||||
<label nz-radio [nzValue]="item.id"></label>
|
||||
</nz-list-item>
|
||||
</nz-list>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="invites.length">
|
||||
<h5 [ngStyle]="{ margin: '16px 0' }" nz-typography>Invitations</h5>
|
||||
<nz-list nzBordered>
|
||||
<nz-list-item
|
||||
*ngFor="let item of invites"
|
||||
[class.selected]="selectedTeamId === item.team_id"
|
||||
(click)="selectTeam(item.team_id, true)"
|
||||
>
|
||||
<span nz-typography>{{item.team_name}}</span>
|
||||
<label nz-radio [nzValue]="item.team_id"></label>
|
||||
</nz-list-item>
|
||||
</nz-list>
|
||||
</ng-container>
|
||||
</nz-radio-group>
|
||||
|
||||
<nz-divider></nz-divider>
|
||||
|
||||
<button [nzLoading]="switching" (click)="continueWithSelection()" nz-button [nzType]="'default'"
|
||||
[nzSize]="'large'" nzBlock>
|
||||
<span nz-icon nzType="check" nzTheme="outline"></span> Continue with selection
|
||||
</button>
|
||||
</nz-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
nz-list-item {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8f7f9;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: rgba(24, 144, 255, 0.0784313725) !important;
|
||||
}
|
||||
}
|
||||
|
||||
nz-radio-group {
|
||||
max-height: calc(100vh - 270px);
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {TeamsListComponent} from './teams-list.component';
|
||||
|
||||
describe('TeamsListComponent', () => {
|
||||
let component: TeamsListComponent;
|
||||
let fixture: ComponentFixture<TeamsListComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [TeamsListComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(TeamsListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {TeamsApiService} from "@api/teams-api.service";
|
||||
import {log_error} from "@shared/utils";
|
||||
import {ITeamGetResponse} from "@interfaces/api-models/team-get-response";
|
||||
import {ITeamInvites} from "@interfaces/team";
|
||||
import {AuthService} from "@services/auth.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {IServerResponse} from "@interfaces/api-models/server-response";
|
||||
import {AppService} from "@services/app.service";
|
||||
|
||||
interface ITeamViewModel extends ITeamGetResponse {
|
||||
active?: boolean;
|
||||
pending_invitation?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-teams-list',
|
||||
templateUrl: './teams-list.component.html',
|
||||
styleUrls: ['./teams-list.component.scss']
|
||||
})
|
||||
export class TeamsListComponent implements OnInit {
|
||||
teams: ITeamViewModel[] = [];
|
||||
invites: ITeamInvites[] = [];
|
||||
|
||||
selectedTeamId: string | undefined = undefined;
|
||||
|
||||
loading = false;
|
||||
switching = false;
|
||||
isInvitation = false;
|
||||
|
||||
constructor(
|
||||
private readonly app: AppService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly teamsApi: TeamsApiService,
|
||||
private readonly router: Router
|
||||
) {
|
||||
this.app.setTitle("Teams & Invitations");
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
void this.getData();
|
||||
}
|
||||
|
||||
private updateDefaultSelection() {
|
||||
if (this.teams.length) {
|
||||
const activeTeam = this.teams.find(t => t.active);
|
||||
if (activeTeam) {
|
||||
this.selectedTeamId = activeTeam.id;
|
||||
} else {
|
||||
this.selectedTeamId = this.teams[0].id;
|
||||
}
|
||||
} else if (this.invites.length) {
|
||||
this.selectedTeamId = this.invites[0].team_id;
|
||||
}
|
||||
}
|
||||
|
||||
private async getData() {
|
||||
this.loading = true;
|
||||
await this.getTeams();
|
||||
await this.getInvites();
|
||||
this.updateDefaultSelection();
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private async getTeams() {
|
||||
try {
|
||||
const res: IServerResponse<ITeamViewModel[]> = await this.teamsApi.get();
|
||||
if (res.done) {
|
||||
this.teams = res.body.filter(t => !t.pending_invitation);
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getInvites() {
|
||||
try {
|
||||
const res = await this.teamsApi.getInvites();
|
||||
if (res.done) {
|
||||
this.invites = res.body;
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
selectTeam(id: string | undefined, isInvitation: boolean) {
|
||||
if (id) {
|
||||
this.selectedTeamId = id;
|
||||
this.isInvitation = isInvitation;
|
||||
}
|
||||
}
|
||||
|
||||
async continueWithSelection() {
|
||||
if (this.selectedTeamId) {
|
||||
try {
|
||||
this.switching = true;
|
||||
|
||||
if (this.isInvitation) {
|
||||
const accepted = await this.acceptInvitation();
|
||||
if (!accepted) {
|
||||
this.switching = false;
|
||||
this.app.notify("Request failed!", "Invitation accept failed. Please try again.", false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await this.teamsApi.activate(this.selectedTeamId);
|
||||
if (res.done) {
|
||||
await this.handleSelectionDone();
|
||||
}
|
||||
this.switching = false;
|
||||
} catch (e) {
|
||||
this.switching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSelectionDone() {
|
||||
await this.auth.authorize();
|
||||
await this.router.navigate(["/worklenz"]);
|
||||
}
|
||||
|
||||
private async acceptInvitation() {
|
||||
const invitation = this.invites.find(i => i.team_id === this.selectedTeamId);
|
||||
if (invitation) {
|
||||
const res = await this.teamsApi.accept({team_member_id: invitation.team_member_id});
|
||||
return res.done;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {RouterModule, Routes} from '@angular/router';
|
||||
import {OverviewComponent} from './overview/overview.component';
|
||||
import {UsersComponent} from './users/users.component';
|
||||
import {TeamsComponent} from './teams/teams.component';
|
||||
import {LayoutComponent} from './layout/layout.component';
|
||||
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LayoutComponent,
|
||||
children: [
|
||||
{path: "", redirectTo: "overview", pathMatch: "full"},
|
||||
{path: "overview", component: OverviewComponent},
|
||||
{path: "users", component: UsersComponent},
|
||||
{path: "teams", component: TeamsComponent},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AdminCenterRoutingModule {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Subject} from "rxjs";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AdminCenterService {
|
||||
private readonly _teamCreateSbj$ = new Subject<void>();
|
||||
private readonly _teamNameChangeSbj$ = new Subject<{ teamId: string, teamName: string }>();
|
||||
|
||||
get onCreateTeam() {
|
||||
return this._teamCreateSbj$.asObservable();
|
||||
}
|
||||
|
||||
get onTeamNameChange() {
|
||||
return this._teamNameChangeSbj$.asObservable();
|
||||
}
|
||||
|
||||
public emitCreateTeam() {
|
||||
this._teamCreateSbj$.next();
|
||||
}
|
||||
|
||||
public emitTeamNameChange(response: { teamId: string, teamName: string }) {
|
||||
this._teamNameChangeSbj$.next(response);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AdminCenterRoutingModule} from './admin-center-routing.module';
|
||||
import {NzLayoutModule} from 'ng-zorro-antd/layout';
|
||||
import {NzPageHeaderModule} from 'ng-zorro-antd/page-header';
|
||||
import {SidebarComponent} from './sidebar/sidebar.component';
|
||||
import {NzMenuModule} from 'ng-zorro-antd/menu';
|
||||
import {NzIconModule} from 'ng-zorro-antd/icon';
|
||||
import {OverviewComponent} from './overview/overview.component';
|
||||
import {NzCardModule} from 'ng-zorro-antd/card';
|
||||
import {NzTypographyModule} from 'ng-zorro-antd/typography';
|
||||
import {NzTableModule} from 'ng-zorro-antd/table';
|
||||
import {UsersComponent} from './users/users.component';
|
||||
import {TeamsComponent} from './teams/teams.component';
|
||||
import {LayoutComponent} from './layout/layout.component';
|
||||
import {NzSpaceModule} from 'ng-zorro-antd/space';
|
||||
import {NzFormModule} from 'ng-zorro-antd/form';
|
||||
import {NzInputModule} from 'ng-zorro-antd/input';
|
||||
import {NzButtonModule} from 'ng-zorro-antd/button';
|
||||
import {NzSkeletonModule} from 'ng-zorro-antd/skeleton';
|
||||
import {NzAvatarModule} from 'ng-zorro-antd/avatar';
|
||||
import {NzBadgeModule} from 'ng-zorro-antd/badge';
|
||||
import {NzToolTipModule} from 'ng-zorro-antd/tooltip';
|
||||
import {NzDropDownModule} from 'ng-zorro-antd/dropdown';
|
||||
import {NzRadioModule} from 'ng-zorro-antd/radio';
|
||||
import {NzDrawerModule} from 'ng-zorro-antd/drawer';
|
||||
import {NzSelectModule} from 'ng-zorro-antd/select';
|
||||
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {AvatarsComponent} from "../components/avatars/avatars.component";
|
||||
import {NzTabsModule} from "ng-zorro-antd/tabs";
|
||||
import {FirstCharUpperPipe} from "@pipes/first-char-upper.pipe";
|
||||
import {NzModalModule} from "ng-zorro-antd/modal";
|
||||
import {NzProgressModule} from "ng-zorro-antd/progress";
|
||||
import {NzDividerModule} from "ng-zorro-antd/divider";
|
||||
import {NzSegmentedModule} from "ng-zorro-antd/segmented";
|
||||
import {NzPopconfirmModule} from "ng-zorro-antd/popconfirm";
|
||||
import {NzCheckboxModule} from "ng-zorro-antd/checkbox";
|
||||
import {NzAutocompleteModule} from "ng-zorro-antd/auto-complete";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SidebarComponent,
|
||||
OverviewComponent,
|
||||
UsersComponent,
|
||||
TeamsComponent,
|
||||
LayoutComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
AdminCenterRoutingModule,
|
||||
NzLayoutModule,
|
||||
NzPageHeaderModule,
|
||||
NzMenuModule,
|
||||
NzIconModule,
|
||||
NzCardModule,
|
||||
NzTypographyModule,
|
||||
NzTableModule,
|
||||
NzSpaceModule,
|
||||
NzFormModule,
|
||||
NzInputModule,
|
||||
NzButtonModule,
|
||||
NzSkeletonModule,
|
||||
NzBadgeModule,
|
||||
NzAvatarModule,
|
||||
NzToolTipModule,
|
||||
NzDropDownModule,
|
||||
NzRadioModule,
|
||||
NzDrawerModule,
|
||||
NzSelectModule,
|
||||
ReactiveFormsModule,
|
||||
AvatarsComponent,
|
||||
FormsModule,
|
||||
NzTabsModule,
|
||||
FirstCharUpperPipe,
|
||||
NzModalModule,
|
||||
NzProgressModule,
|
||||
NzDividerModule,
|
||||
NzSegmentedModule,
|
||||
NzPopconfirmModule,
|
||||
NzCheckboxModule,
|
||||
NzAutocompleteModule
|
||||
]
|
||||
})
|
||||
export class AdminCenterModule {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="container">
|
||||
<nz-page-header [nzGhost]="false" class="px-0">
|
||||
<nz-page-header-title>Admin Center</nz-page-header-title>
|
||||
</nz-page-header>
|
||||
<nz-layout class="inner-layout">
|
||||
<nz-sider [nzWidth]="'240px'">
|
||||
<worklenz-admin-center-sidebar></worklenz-admin-center-sidebar>
|
||||
</nz-sider>
|
||||
<nz-content class="px-4 bg-white">
|
||||
<router-outlet></router-outlet>
|
||||
</nz-content>
|
||||
</nz-layout>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
[nz-submenu] {
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {LayoutComponent} from './layout.component';
|
||||
|
||||
describe('LayoutComponent', () => {
|
||||
let component: LayoutComponent;
|
||||
let fixture: ComponentFixture<LayoutComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [LayoutComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(LayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-layout',
|
||||
templateUrl: './layout.component.html',
|
||||
styleUrls: ['./layout.component.scss']
|
||||
})
|
||||
export class LayoutComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<nz-page-header [nzGhost]="false" class="px-0">
|
||||
<nz-page-header-title>Overview</nz-page-header-title>
|
||||
</nz-page-header>
|
||||
<nz-card>
|
||||
<h4>Organization Name</h4>
|
||||
<div class="card-content">
|
||||
<p nz-typography nzEditable [(nzContent)]="organizationDetails.name"
|
||||
(nzContentChange)="updateOrganizationName();"></p>
|
||||
</div>
|
||||
</nz-card>
|
||||
<div class="mt-4"></div>
|
||||
<nz-card>
|
||||
<h4>Organization Owner</h4>
|
||||
<div class="card-content">
|
||||
<nz-skeleton [nzLoading]="loadingName" [nzActive]="true" [nzParagraph]="{rows: 3}">
|
||||
<p nz-typography>{{organizationDetails.owner_name}}</p>
|
||||
<div>
|
||||
<nz-space class="align-items-center">
|
||||
<span *nzSpaceItem nz-icon nzType="mail" nzTheme="outline" nz-tooltip
|
||||
[nzTooltipTitle]="'Email Address'"></span>
|
||||
<span *nzSpaceItem nz-typography>{{organizationDetails.email}}</span>
|
||||
</nz-space>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<nz-space class="align-items-center" style="margin-left: -4px">
|
||||
<span *nzSpaceItem nz-icon nzType="phone" nzTheme="outline" nz-tooltip
|
||||
[nzTooltipTitle]="'Contact Number'"></span>
|
||||
<span *nzSpaceItem>
|
||||
<nz-space class="align-items-center">
|
||||
<div *nzSpaceItem class="position-relative" style="min-height: 32px">
|
||||
|
||||
<span *ngIf="organizationDetails.contact_number && !isNumberEditing" nz-typography
|
||||
style="line-height: 32px">{{organizationDetails.contact_number}}</span>
|
||||
<span *ngIf="!organizationDetails.contact_number && !isNumberEditing" (click)="focusNumberInput()"
|
||||
nz-typography class="text-btn">Add Contact Number</span>
|
||||
|
||||
<div *ngIf="isNumberEditing" class="number-input">
|
||||
<input nz-input type="tel" placeholder="Add Contact Number"
|
||||
[(ngModel)]="organizationDetails.contact_number"
|
||||
(input)="sanitizeContactNumber($event)"
|
||||
(blur)="updateOwnerContactNumber();"
|
||||
(keydown.enter)="updateOwnerContactNumber()"
|
||||
maxlength="20" #numberInput/>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *nzSpaceItem>
|
||||
<ng-container *ngIf="organizationDetails.contact_number">
|
||||
<span *ngIf="!isNumberEditing" (click)="focusNumberInput()" nz-icon nzType="edit"
|
||||
nzTheme="outline" class="edit-btn"></span>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</nz-space>
|
||||
</span>
|
||||
</nz-space>
|
||||
</div>
|
||||
</nz-skeleton>
|
||||
</div>
|
||||
</nz-card>
|
||||
<div class="mt-4"></div>
|
||||
<nz-card>
|
||||
<h4>Organization Admins</h4>
|
||||
<div class="card-content">
|
||||
<nz-skeleton [nzLoading]="loadingAdmins" [nzActive]="true" [nzParagraph]="{rows: 5}">
|
||||
<nz-table [nzNoResult]="" [nzData]="organizationAdmins" [nzPaginationType]="'small'" #adminsTable>
|
||||
<tbody>
|
||||
<tr *ngFor="let item of adminsTable.data">
|
||||
<td class="left-td">{{item.name}} <span nz-typography *ngIf="item.is_owner" nzType="secondary">(Owner)</span>
|
||||
</td>
|
||||
<td class="b-65">{{item.email}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-skeleton>
|
||||
</div>
|
||||
</nz-card>
|
||||
@@ -0,0 +1,42 @@
|
||||
.card-content {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.ant-card-bordered {
|
||||
// border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.b-65 {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.left-td {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.number-input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
min-width: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
cursor: pointer;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.text-btn {
|
||||
line-height: 32px;
|
||||
cursor: pointer;
|
||||
color: #188fff
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {OverviewComponent} from './overview.component';
|
||||
|
||||
describe('OverviewComponent', () => {
|
||||
let component: OverviewComponent;
|
||||
let fixture: ComponentFixture<OverviewComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [OverviewComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(OverviewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import {Component, ElementRef, NgZone, OnInit, ViewChild} from '@angular/core';
|
||||
import {AccountCenterApiService} from "@api/account-center-api.service";
|
||||
import {log_error} from "@shared/utils";
|
||||
import {IOrganization, IOrganizationAdmin} from "@interfaces/account-center";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-overview',
|
||||
templateUrl: './overview.component.html',
|
||||
styleUrls: ['./overview.component.scss']
|
||||
})
|
||||
export class OverviewComponent implements OnInit {
|
||||
@ViewChild('numberInput') private numberInput: ElementRef | undefined;
|
||||
loadingName = false;
|
||||
loadingAdmins = false;
|
||||
isNumberEditing = false;
|
||||
|
||||
organizationDetails: IOrganization = {};
|
||||
organizationAdmins: IOrganizationAdmin[] = [];
|
||||
|
||||
constructor(
|
||||
private api: AccountCenterApiService,
|
||||
private readonly ngZone: NgZone) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
void this.getOrganizationName();
|
||||
void this.getOrganizationAdmins();
|
||||
}
|
||||
|
||||
async getOrganizationName() {
|
||||
try {
|
||||
this.loadingName = true;
|
||||
const res = await this.api.getOrganizationName();
|
||||
if (res.done) {
|
||||
this.loadingName = false;
|
||||
this.organizationDetails = res.body;
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingName = false;
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async getOrganizationAdmins() {
|
||||
try {
|
||||
this.loadingAdmins = true;
|
||||
const res = await this.api.getOrganizationAdmins();
|
||||
if (res.done) {
|
||||
this.loadingAdmins = false;
|
||||
this.organizationAdmins = res.body;
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingAdmins = false;
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrganizationName() {
|
||||
try {
|
||||
this.loadingName = true;
|
||||
const res = await this.api.updateOrganizationName({name: this.organizationDetails.name});
|
||||
if (res.done) {
|
||||
this.loadingName = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingName = false;
|
||||
log_error(e);
|
||||
}
|
||||
await this.getOrganizationName();
|
||||
}
|
||||
|
||||
async updateOwnerContactNumber() {
|
||||
try {
|
||||
this.loadingName = true;
|
||||
this.isNumberEditing = false;
|
||||
const res = await this.api.updateOwnerContactNumber({contact_number: this.organizationDetails.contact_number || ''});
|
||||
if (res.done) {
|
||||
this.loadingName = false;
|
||||
await this.getOrganizationName();
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingName = false;
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
focusNumberInput() {
|
||||
this.isNumberEditing = true;
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
setTimeout(() => {
|
||||
this.numberInput?.nativeElement.focus();
|
||||
this.numberInput?.nativeElement.select();
|
||||
}, 100)
|
||||
});
|
||||
}
|
||||
|
||||
sanitizeContactNumber(event: any) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const sanitizedValue = input.value.replace(/[^0-9()+ -]/g, '');
|
||||
this.organizationDetails.contact_number = sanitizedValue;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<ul class="border-0" nz-menu [nzMode]="'vertical'">
|
||||
<li class="rounded-4" [routerLink]="'overview'" [routerLinkActive]="['ant-menu-item-selected']"
|
||||
[nzTitle]="'Overview'" [nzIcon]="'appstore'" nz-submenu>
|
||||
</li>
|
||||
<li class="rounded-4" [routerLink]="'users'" [routerLinkActive]="['ant-menu-item-selected']" [nzTitle]="'Users'"
|
||||
[nzIcon]="'user'" nz-submenu>
|
||||
</li>
|
||||
<li class="rounded-4" [routerLink]="'teams'" [routerLinkActive]="['ant-menu-item-selected']" [nzTitle]="'Teams'"
|
||||
[nzIcon]="'team'" nz-submenu>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {SidebarComponent} from './sidebar.component';
|
||||
|
||||
describe('SidebarComponent', () => {
|
||||
let component: SidebarComponent;
|
||||
let fixture: ComponentFixture<SidebarComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [SidebarComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SidebarComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {AuthService} from "@services/auth.service";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-admin-center-sidebar',
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrls: ['./sidebar.component.scss']
|
||||
})
|
||||
export class SidebarComponent {
|
||||
constructor(
|
||||
private readonly auth: AuthService
|
||||
) {
|
||||
}
|
||||
|
||||
get profile() {
|
||||
return this.auth.getCurrentSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<nz-page-header [nzGhost]="false" class="px-0">
|
||||
<nz-page-header-title>Teams</nz-page-header-title>
|
||||
</nz-page-header>
|
||||
<nz-page-header class="site-page-header pt-0 ps-0">
|
||||
<nz-page-header-subtitle>{{this.total ? this.total : 0}} teams</nz-page-header-subtitle>
|
||||
<nz-page-header-extra>
|
||||
<nz-space>
|
||||
<button *nzSpaceItem nz-button nz-tooltip nzShape="circle" (click)="getTeams()"
|
||||
nzTooltipTitle="Refresh teams" nzType="default">
|
||||
<span nz-icon nzTheme="outline" nzType="sync"></span>
|
||||
</button>
|
||||
<form *nzSpaceItem [nzLayout]="'vertical'" nz-form [formGroup]="searchForm">
|
||||
<nz-input-group [nzSuffix]="suffixIconSearch">
|
||||
<input nz-input placeholder="Search by name" [formControlName]="'search'"
|
||||
type="text"/>
|
||||
</nz-input-group>
|
||||
<ng-template #suffixIconSearch>
|
||||
<span nz-icon nzType="search"></span>
|
||||
</ng-template>
|
||||
</form>
|
||||
<span *nzSpaceItem>
|
||||
<button nz-button nzType="primary" (click)="openNewTeam()">Add
|
||||
Team</button>
|
||||
</span>
|
||||
</nz-space>
|
||||
</nz-page-header-extra>
|
||||
</nz-page-header>
|
||||
<nz-card>
|
||||
<nz-skeleton [nzActive]="false" [nzLoading]="false">
|
||||
<nz-table #teamsTable
|
||||
(nzQueryParams)="onQueryParamsChange($event)"
|
||||
[nzData]="teams || []"
|
||||
[nzLoading]="loading"
|
||||
[nzPageIndex]="pageIndex"
|
||||
[nzPageSizeOptions]="paginationSizes"
|
||||
[nzFrontPagination]="false"
|
||||
[nzPageSize]="pageSize"
|
||||
[nzTotal]="total"
|
||||
class="custom-table"
|
||||
nzShowSizeChanger
|
||||
nzSize="small" [nzNoResult]="currentTeam ? noDataTemplate1 : noDataTemplate">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Team</th>
|
||||
<th scope="col" class="text-center">Members Count</th>
|
||||
<th scope="col">Members</th>
|
||||
<th scope="col" class="text-center"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="actions-row" *ngIf="currentTeam">
|
||||
<td class="cursor-pointer">
|
||||
<nz-badge nzColor="#52c41a" [nzText]="currentTeam.name"></nz-badge>
|
||||
</td>
|
||||
<td class="cursor-pointer text-center">
|
||||
{{currentTeam.members_count}}
|
||||
</td>
|
||||
<td class="cursor-pointer">
|
||||
<worklenz-avatars [names]="currentTeam.names"></worklenz-avatars>
|
||||
</td>
|
||||
<td class="cursor-pointer">
|
||||
<div class="actions text-center">
|
||||
<nz-space>
|
||||
<button *nzSpaceItem nz-button nz-tooltip
|
||||
nzSize="small"
|
||||
[nzTooltipPlacement]="'top'"
|
||||
[nzTooltipTitle]="'Settings'"
|
||||
[nzType]="'default'"
|
||||
(click)="openTeamDrawer(currentTeam)">
|
||||
<span nz-icon nzType="setting"></span>
|
||||
</button>
|
||||
<button *nzSpaceItem (nzOnConfirm)="deleteTeam(currentTeam.id)"
|
||||
nz-button nz-popconfirm nz-tooltip [nzOkText]="'Yes'"
|
||||
[nzPopconfirmTitle]="'Are you sure?'" [nzSize]="'small'" [nzTooltipPlacement]="'top'"
|
||||
[nzTooltipTitle]="'Delete'"
|
||||
[nzType]="'default'">
|
||||
<span nz-icon nzType="delete"></span>
|
||||
</button>
|
||||
</nz-space>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="actions-row" *ngFor="let team of teamsTable.data">
|
||||
<td class="cursor-pointer">
|
||||
{{team.name}}
|
||||
</td>
|
||||
<td class="cursor-pointer text-center">
|
||||
{{team.members_count}}
|
||||
</td>
|
||||
<td class="cursor-pointer">
|
||||
<worklenz-avatars [names]="team.names"></worklenz-avatars>
|
||||
</td>
|
||||
<td class="cursor-pointer">
|
||||
<div class="actions text-center">
|
||||
<nz-space>
|
||||
<button *nzSpaceItem nz-button nz-tooltip
|
||||
nzSize="small"
|
||||
[nzTooltipPlacement]="'top'"
|
||||
[nzTooltipTitle]="'Settings'"
|
||||
[nzType]="'default'"
|
||||
(click)="openTeamDrawer(team)">
|
||||
<span nz-icon nzType="setting"></span>
|
||||
</button>
|
||||
<button *nzSpaceItem (nzOnConfirm)="deleteTeam(team.id)"
|
||||
nz-button nz-popconfirm nz-tooltip [nzOkText]="'Yes'"
|
||||
[nzPopconfirmTitle]="'Are you sure?'" [nzSize]="'small'" [nzTooltipPlacement]="'top'"
|
||||
[nzTooltipTitle]="'Delete'"
|
||||
[nzType]="'default'">
|
||||
<span nz-icon nzType="delete"></span>
|
||||
</button>
|
||||
</nz-space>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-skeleton>
|
||||
</nz-card>
|
||||
|
||||
<ng-template #noDataTemplate>
|
||||
<div class="pt-4 pb-5">
|
||||
<div class="no-data-img-holder mx-auto mb-3">
|
||||
<img src="/assets/images/empty-box.webp" class="img-fluid" alt="">
|
||||
</div>
|
||||
<span nz-typography class="no-data-text">No teams found in the organization.</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noDataTemplate1>
|
||||
|
||||
</ng-template>
|
||||
|
||||
<nz-drawer
|
||||
[nzClosable]="true"
|
||||
[nzVisible]="visible"
|
||||
nzPlacement="right"
|
||||
[nzTitle]="'Team Settings'"
|
||||
[nzSize]="'large'"
|
||||
[nzWidth]="'550px'"
|
||||
(nzOnClose)="close()">
|
||||
<ng-container *nzDrawerContent>
|
||||
<form nz-form [formGroup]="editTeamForm" (ngSubmit)="submit()" [nzLayout]="'vertical'">
|
||||
<nz-form-item>
|
||||
<nz-form-label
|
||||
[nzSpan]="null"
|
||||
nzRequired>
|
||||
Team Name
|
||||
</nz-form-label>
|
||||
<input nz-input [formControlName]="'name'" placeholder="Name of the team"/>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item class="d-block">
|
||||
<nz-form-label [nzSpan]="null" nzRequired>
|
||||
Users ({{teamMembers.controls.length}})
|
||||
</nz-form-label>
|
||||
|
||||
<!-- <nz-form-item>-->
|
||||
<!-- <nz-form-label [nzSpan]="null">-->
|
||||
<!-- Add Team Members-->
|
||||
<!-- </nz-form-label>-->
|
||||
<!-- <nz-select nzShowSearch nzAllowClear nzPlaceHolder="Type to search" (nzOnSearch)="getUsers($event)"-->
|
||||
<!-- nzServerSearch [formControlName]="'search'" [nzNotFoundContent]="notFoundContentTemplate">-->
|
||||
<!-- <nz-option *ngFor="let item of users" nzCustomContent [nzLabel]="item.name || ''" [nzValue]="item.email">-->
|
||||
<!-- <div class="d-flex align-items-center user-select-none">-->
|
||||
<!-- <nz-avatar-->
|
||||
<!-- nz-tooltip-->
|
||||
<!-- [nzSize]="28"-->
|
||||
<!-- [nzText]="item.name | firstCharUpper"-->
|
||||
<!-- [nzTooltipTitle]="item.name"-->
|
||||
<!-- [style.background-color]="item.avatar_url ? '#ececec' : item.color_code"-->
|
||||
<!-- [nzSrc]="item.avatar_url"-->
|
||||
<!-- [nzTooltipPlacement]="'top'"-->
|
||||
<!-- class="mt-auto mb-auto me-2"-->
|
||||
<!-- ></nz-avatar>-->
|
||||
<!-- <div style="line-height: 15px;">-->
|
||||
<!-- <span class="d-block" nz-typography>{{item.name}}</span>-->
|
||||
<!-- <small nz-typography nzType="secondary">{{item.email}}</small>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </nz-option>-->
|
||||
<!-- </nz-select>-->
|
||||
<!-- <ng-template #notFoundContentTemplate>-->
|
||||
<!-- <button nz-button nzType="primary" nzBlock [disabled]="!isValueIsAnEmail() || inviting" [nzLoading]="inviting"-->
|
||||
<!-- (click)="sendInvitation()">-->
|
||||
<!-- <span nz-icon nzType="mail" nzTheme="outline"></span> {{buttonText}}-->
|
||||
<!-- </button>-->
|
||||
<!-- <div nz-typography nzType="secondary" class="mt-2 mb-0" style="font-size: 12px;">-->
|
||||
<!-- Invitees will be added to the team and project either they accept the invitation or not.-->
|
||||
<!-- </div>-->
|
||||
<!-- </ng-template>-->
|
||||
<!-- </nz-form-item>-->
|
||||
|
||||
<nz-table [nzData]="teamMembers.controls" [nzFrontPagination]="false" [formArrayName]="'teamMembers'"
|
||||
[nzNoResult]="emptyData">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th style="width: 150px;">Role</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let item of teamMembers.controls; let i = index;" [formGroupName]="i">
|
||||
<td>
|
||||
<nz-avatar class="me-2" [nzSize]="28" [nzText]="item.value.name | firstCharUpper"
|
||||
[nzSrc]="item.value.avatar_url"
|
||||
[style.background-color]="item.value.avatar_url ? '#ececec' : getColor('name')"></nz-avatar>
|
||||
<nz-badge>{{item.value.name}}</nz-badge>
|
||||
</td>
|
||||
<td>
|
||||
<nz-select style="width: 150px;" [formControlName]="'role_name'" [attr.id]="'member_' + i"
|
||||
[nzDisabled]="item.value.role_name === 'Owner'">
|
||||
<nz-option nzValue="Admin" nzLabel="Admin"></nz-option>
|
||||
<nz-option nzValue="Member" nzLabel="Member"></nz-option>
|
||||
<nz-option nzValue="Owner" [nzDisabled]="item.value.role_name !== 'Owner'" nzLabel="Owner"></nz-option>
|
||||
</nz-select>
|
||||
</td>
|
||||
<td>
|
||||
<button *ngIf="item.value.role_name !== 'Owner'" (nzOnConfirm)="deleteTeamMember(item.value.id)"
|
||||
nz-button nz-popconfirm nz-tooltip [nzOkText]="'Yes'"
|
||||
[nzPopconfirmTitle]="'Are you sure?'" [nzSize]="'small'" [nzTooltipPlacement]="'top'"
|
||||
[nzTooltipTitle]="'Delete'"
|
||||
[nzType]="'default'">
|
||||
<span nz-icon nzType="delete"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-form-item>
|
||||
<button nz-button nzType="primary" nzBlock>Save</button>
|
||||
</form>
|
||||
</ng-container>
|
||||
</nz-drawer>
|
||||
|
||||
<nz-drawer
|
||||
[nzClosable]="true"
|
||||
[nzVisible]="visibleNewTeam"
|
||||
nzPlacement="right"
|
||||
nzTitle="Create New Team"
|
||||
[nzSize]="'large'"
|
||||
[nzWidth]="'350px'"
|
||||
(nzOnClose)="closeNewTeam()">
|
||||
<ng-container *nzDrawerContent>
|
||||
<form nz-form [formGroup]="form" (submit)="createTeam()">
|
||||
<nz-form-item class="d-block">
|
||||
<nz-form-label nzRequired>
|
||||
Team name
|
||||
</nz-form-label>
|
||||
<nz-form-control>
|
||||
<input nz-input type="text" [formControlName]="'name'">
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<button nz-button nzType="primary" type="submit" nzBlock>Create</button>
|
||||
</form>
|
||||
</ng-container>
|
||||
</nz-drawer>
|
||||
|
||||
|
||||
<ng-template #emptyData></ng-template>
|
||||
@@ -0,0 +1,11 @@
|
||||
nz-page-header-subtitle {
|
||||
color: hsla(0, 0%, 0%, 0.85);
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.no-data-img-holder {
|
||||
width: 100px;
|
||||
margin-top: 42px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {TeamsComponent} from './teams.component';
|
||||
|
||||
describe('TeamsComponent', () => {
|
||||
let component: TeamsComponent;
|
||||
let fixture: ComponentFixture<TeamsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [TeamsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TeamsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {AvatarNamesMap, DEFAULT_PAGE_SIZE} from "@shared/constants";
|
||||
import {FormArray, FormBuilder, FormGroup, Validators} from "@angular/forms";
|
||||
import {AccountCenterApiService} from "@api/account-center-api.service";
|
||||
import {NzTableQueryParams} from "ng-zorro-antd/table";
|
||||
import {isValidateEmail, log_error} from "@shared/utils";
|
||||
import {IOrganizationTeam, IOrganizationTeamMember, IOrganizationUser} from "@interfaces/account-center";
|
||||
import {AppService} from "@services/app.service";
|
||||
import {TeamsApiService} from "@api/teams-api.service";
|
||||
import {AdminCenterService} from "../admin-center-service.service";
|
||||
import {ITeamMemberCreateRequest} from "@interfaces/api-models/team-member-create-request";
|
||||
import {TeamMembersApiService} from "@api/team-members-api.service";
|
||||
import {ProjectMembersApiService} from "@api/project-members-api.service";
|
||||
import {AuthService} from "@services/auth.service";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-teams',
|
||||
templateUrl: './teams.component.html',
|
||||
styleUrls: ['./teams.component.scss']
|
||||
})
|
||||
export class TeamsComponent {
|
||||
visible = false;
|
||||
visibleNewTeam = false;
|
||||
teams: IOrganizationTeam[] = [];
|
||||
currentTeam: IOrganizationTeam | null = null;
|
||||
|
||||
loading = false;
|
||||
|
||||
// Table sorting & pagination
|
||||
total = 0;
|
||||
pageSize = DEFAULT_PAGE_SIZE;
|
||||
pageIndex = 1;
|
||||
paginationSizes = [5, 10, 15, 20, 50, 100];
|
||||
sortField: string | null = null;
|
||||
sortOrder: string | null = null;
|
||||
|
||||
form!: FormGroup;
|
||||
editTeamForm!: FormGroup;
|
||||
searchForm!: FormGroup;
|
||||
|
||||
loadingTeamDetails = false;
|
||||
teamData: IOrganizationTeam = {};
|
||||
selectedTeam: IOrganizationTeam = {};
|
||||
updatingTeam = false;
|
||||
|
||||
users: IOrganizationUser[] = []
|
||||
totalUsers = 0;
|
||||
|
||||
searchingName: string | null = null;
|
||||
projectId: string | null = null;
|
||||
inviting = false;
|
||||
|
||||
get buttonText() {
|
||||
return this.isValueIsAnEmail() ? 'Invite as a member' : 'Invite a new member by email';
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly api: AccountCenterApiService,
|
||||
private readonly teamMembersApi: TeamMembersApiService,
|
||||
private readonly teamsApiService: TeamsApiService,
|
||||
private fb: FormBuilder,
|
||||
private app: AppService,
|
||||
private readonly service: AdminCenterService,
|
||||
private readonly membersApi: ProjectMembersApiService,
|
||||
private readonly auth: AuthService
|
||||
) {
|
||||
this.app.setTitle("Admin Center - Teams");
|
||||
this.form = this.fb.group({
|
||||
name: [null, [Validators.required]]
|
||||
});
|
||||
this.editTeamForm = this.fb.group({
|
||||
name: [null, [Validators.required]],
|
||||
teamMembers: this.fb.array([]),
|
||||
search: [null]
|
||||
});
|
||||
this.searchForm = this.fb.group({search: []});
|
||||
this.searchForm.valueChanges.subscribe(() => this.getTeams());
|
||||
this.editTeamForm.controls["search"]?.valueChanges.subscribe((value) => this.handleMemberSelect(value));
|
||||
}
|
||||
|
||||
get teamMembers() {
|
||||
return <FormArray>this.editTeamForm.get('teamMembers');
|
||||
}
|
||||
|
||||
getColor(name?: string) {
|
||||
return AvatarNamesMap[name?.charAt(0).toUpperCase() || 'A'];
|
||||
}
|
||||
|
||||
async onQueryParamsChange(params: NzTableQueryParams) {
|
||||
const {pageSize, pageIndex, sort} = params;
|
||||
this.pageIndex = pageIndex;
|
||||
this.pageSize = pageSize;
|
||||
|
||||
const currentSort = sort.find(item => item.value !== null);
|
||||
|
||||
this.sortField = (currentSort && currentSort.key) || null;
|
||||
this.sortOrder = (currentSort && currentSort.value) || null;
|
||||
|
||||
await this.getTeams();
|
||||
}
|
||||
|
||||
async getTeams() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await this.api.getOrganizationTeams(this.pageIndex, this.pageSize, this.sortField, this.sortOrder, this.searchForm.value.search);
|
||||
if (res.done) {
|
||||
this.total = res.body.total || 0;
|
||||
this.teams = res.body.data?.filter(t => t.id !== this.auth.getCurrentSession()?.team_id) || [];
|
||||
this.currentTeam = res.body.current_team_data || null;
|
||||
this.loading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.loading = false;
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async createTeam() {
|
||||
if (!this.form.value || !this.form.value.name || this.form.value.name.trim() === "") return;
|
||||
try {
|
||||
if (this.form.valid) {
|
||||
this.loading = true;
|
||||
const res = await this.teamsApiService.create({name: this.form.value.name});
|
||||
if (res.done) {
|
||||
this.closeNewTeam();
|
||||
void this.getTeams();
|
||||
this.service.emitCreateTeam();
|
||||
}
|
||||
} else {
|
||||
this.app.displayErrorsOf(this.form);
|
||||
}
|
||||
} catch (e) {
|
||||
this.loading = false;
|
||||
log_error(e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async openTeamDrawer(team: IOrganizationTeam) {
|
||||
if (!team.id) return;
|
||||
try {
|
||||
this.loadingTeamDetails = true;
|
||||
this.selectedTeam = team;
|
||||
|
||||
this.getTeamMembers(team);
|
||||
} catch (e) {
|
||||
this.loadingTeamDetails = false;
|
||||
log_error(e);
|
||||
}
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
async getTeamMembers(team: IOrganizationTeam) {
|
||||
if (!team.id) return;
|
||||
try {
|
||||
const res = await this.api.getOrganizationTeam(team.id);
|
||||
if (res.done) {
|
||||
this.teamMembers.clear();
|
||||
this.teamData = res.body;
|
||||
this.total = this.teamData.team_members?.length || 0;
|
||||
|
||||
this.editTeamForm.patchValue({name: this.teamData.name});
|
||||
if (this.teamData.team_members?.map((member: IOrganizationTeamMember) => {
|
||||
const tempForm = this.fb.group({
|
||||
id: member.id,
|
||||
user_id: member.user_id,
|
||||
name: member.name,
|
||||
role_name: member.role_name,
|
||||
avatar_url: member.avatar_url
|
||||
});
|
||||
this.teamMembers.push(tempForm);
|
||||
}))
|
||||
this.loadingTeamDetails = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingTeamDetails = false;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.teamMembers.clear();
|
||||
this.editTeamForm.reset();
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
openNewTeam() {
|
||||
this.visibleNewTeam = true;
|
||||
}
|
||||
|
||||
closeNewTeam() {
|
||||
this.visibleNewTeam = false;
|
||||
this.form.reset();
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (!this.teamData.id) return;
|
||||
|
||||
if (!this.editTeamForm.value || !this.editTeamForm.value.name || this.editTeamForm.value.name.trim() === "") return;
|
||||
|
||||
try {
|
||||
this.updatingTeam = true;
|
||||
const res = await this.api.updateTeam(this.teamData.id, this.editTeamForm.value);
|
||||
if (res.done) {
|
||||
this.service.emitTeamNameChange({teamId: this.teamData.id, teamName: this.editTeamForm.value.name});
|
||||
this.close();
|
||||
void this.getTeams();
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTeam(id: string | undefined) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await this.api.deleteTeam(id);
|
||||
if (res.done) {
|
||||
await this.getTeams();
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMemberSelect(value: string) {
|
||||
if (!value || !this.selectedTeam.id) return;
|
||||
if (this.editTeamForm.valid) {
|
||||
try {
|
||||
this.loading = true;
|
||||
const body: ITeamMemberCreateRequest = {
|
||||
job_title: null,
|
||||
emails: [value],
|
||||
is_admin: false
|
||||
};
|
||||
|
||||
const res = await this.teamMembersApi.addTeamMember(this.selectedTeam.id, body);
|
||||
if (res.done) {
|
||||
this.editTeamForm.controls["search"]?.setValue(null);
|
||||
this.getTeamMembers(this.selectedTeam);
|
||||
}
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
this.loading = false;
|
||||
}
|
||||
} else {
|
||||
this.app.displayErrorsOf(this.form);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTeamMember(id: string | undefined) {
|
||||
if (!id || !this.selectedTeam.id) return;
|
||||
try {
|
||||
const res = await this.api.removeTeamMember(id, this.selectedTeam.id);
|
||||
if (res.done) {
|
||||
if (id === this.auth.getCurrentSession()?.team_member_id) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
await this.getTeamMembers(this.selectedTeam);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
isValueIsAnEmail() {
|
||||
if (!this.searchingName) return false;
|
||||
return isValidateEmail(this.searchingName);
|
||||
}
|
||||
|
||||
async sendInvitation() {
|
||||
if (!this.projectId) return;
|
||||
if (typeof this.searchingName !== "string" || !this.searchingName.length) return;
|
||||
|
||||
try {
|
||||
const email = this.searchingName.trim().toLowerCase();
|
||||
const request = {
|
||||
project_id: this.projectId,
|
||||
email
|
||||
};
|
||||
this.inviting = true;
|
||||
const res = await this.membersApi.createByEmail(request);
|
||||
this.inviting = false;
|
||||
if (res.done) {
|
||||
// this.resetSearchInput();
|
||||
}
|
||||
} catch (e) {
|
||||
this.inviting = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<nz-page-header [nzGhost]="false" class="px-0">
|
||||
<nz-page-header-title>Users</nz-page-header-title>
|
||||
</nz-page-header>
|
||||
<nz-page-header class="site-page-header pt-0 ps-0">
|
||||
<nz-page-header-subtitle>{{total}} users</nz-page-header-subtitle>
|
||||
<nz-page-header-extra>
|
||||
<nz-space>
|
||||
<button *nzSpaceItem nz-button nz-tooltip nzShape="circle" (click)="getUsers()"
|
||||
nzTooltipTitle="Refresh users" nzType="default">
|
||||
<span nz-icon nzTheme="outline" nzType="sync"></span>
|
||||
</button>
|
||||
<form *nzSpaceItem [formGroup]="searchForm" [nzLayout]="'vertical'" nz-form>
|
||||
<nz-input-group [nzSuffix]="suffixIconSearch">
|
||||
<input [formControlName]="'search'" nz-input placeholder="Search by name" type="text"/>
|
||||
</nz-input-group>
|
||||
<ng-template #suffixIconSearch>
|
||||
<span nz-icon nzType="search"></span>
|
||||
</ng-template>
|
||||
</form>
|
||||
</nz-space>
|
||||
</nz-page-header-extra>
|
||||
</nz-page-header>
|
||||
<nz-card>
|
||||
<nz-skeleton [nzActive]="false" [nzLoading]="false">
|
||||
<nz-table #usersTable
|
||||
(nzQueryParams)="onQueryParamsChange($event)"
|
||||
[nzData]="users || []"
|
||||
[nzFrontPagination]="false"
|
||||
[nzLoading]="loading"
|
||||
[nzPageIndex]="pageIndex"
|
||||
[nzPageSizeOptions]="paginationSizes"
|
||||
[nzPageSize]="pageSize"
|
||||
[nzTotal]="total"
|
||||
class="custom-table"
|
||||
nzShowSizeChanger
|
||||
nzSize="small" [nzNoResult]="noDataTemplate">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">User</th>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Last Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="actions-row" *ngFor="let item of usersTable.data">
|
||||
<td class="cursor-pointer">
|
||||
<nz-avatar class="me-2" [nzSize]="28" nzText="{{item.name | firstCharUpper}}"
|
||||
[nzSrc]="item.avatar_url"
|
||||
[style.background-color]="item.avatar_url ? '#ececec' : getColor(item.name)"></nz-avatar>
|
||||
<nz-badge>
|
||||
{{item.name}}
|
||||
<span nz-typography
|
||||
[nzType]="'secondary'">{{item.is_admin ? '(Admin)' : item.is_owner ? '(Owner)' : ''}}</span>
|
||||
</nz-badge>
|
||||
</td>
|
||||
<td class="cursor-pointer">
|
||||
{{item.email}}
|
||||
</td>
|
||||
<td class="cursor-pointer">
|
||||
{{(item.last_logged | date: 'medium') || '-'}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-skeleton>
|
||||
</nz-card>
|
||||
|
||||
<ng-template #noDataTemplate>
|
||||
<div class="pt-4 pb-5">
|
||||
<div class="no-data-img-holder mx-auto mb-3">
|
||||
<img src="/assets/images/empty-box.webp" class="img-fluid" alt="">
|
||||
</div>
|
||||
<span nz-typography class="no-data-text">No users found in the organization.</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,45 @@
|
||||
.you-text {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.admin-text {
|
||||
font-size: 12px;
|
||||
background-color: rgba(250, 173, 20, 0.1);
|
||||
font-weight: 500;
|
||||
padding: 0px 4px;
|
||||
border-radius: 12px;
|
||||
color: #FAAD14;
|
||||
}
|
||||
|
||||
.ant-dropdown {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
text-align: left;
|
||||
list-style-type: none;
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
box-shadow: 0 3px 6px -4px #0000001f, 0 6px 16px #00000014, 0 9px 28px 8px #0000000d;
|
||||
}
|
||||
|
||||
.ant-form-item-label > label {
|
||||
float: left !important;
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
nz-page-header-subtitle {
|
||||
color: hsla(0, 0%, 0%, 0.85);
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.no-data-img-holder {
|
||||
width: 100px;
|
||||
margin-top: 42px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {UsersComponent} from './users.component';
|
||||
|
||||
describe('UsersComponent', () => {
|
||||
let component: UsersComponent;
|
||||
let fixture: ComponentFixture<UsersComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [UsersComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UsersComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {IOrganizationUser} from "@interfaces/account-center";
|
||||
import {AvatarNamesMap, DEFAULT_PAGE_SIZE} from "@shared/constants";
|
||||
import {NzTableQueryParams} from "ng-zorro-antd/table";
|
||||
import {log_error} from "@shared/utils";
|
||||
import {AccountCenterApiService} from "@api/account-center-api.service";
|
||||
import {FormBuilder, FormGroup, Validators} from "@angular/forms";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-users',
|
||||
templateUrl: './users.component.html',
|
||||
styleUrls: ['./users.component.scss']
|
||||
})
|
||||
export class UsersComponent {
|
||||
visible = false;
|
||||
visibleNewMember = false;
|
||||
|
||||
users: IOrganizationUser[] = []
|
||||
loading = false;
|
||||
|
||||
// Table sorting & pagination
|
||||
total = 0;
|
||||
pageSize = DEFAULT_PAGE_SIZE;
|
||||
pageIndex = 1;
|
||||
paginationSizes = [5, 10, 15, 20, 50, 100];
|
||||
sortField: string | null = null;
|
||||
sortOrder: string | null = null;
|
||||
|
||||
form!: FormGroup;
|
||||
searchForm!: FormGroup;
|
||||
|
||||
constructor(
|
||||
private api: AccountCenterApiService,
|
||||
private fb: FormBuilder,
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: [null, [Validators.required]]
|
||||
});
|
||||
this.searchForm = this.fb.group({search: []});
|
||||
this.searchForm.valueChanges.subscribe(() => this.getUsers());
|
||||
}
|
||||
|
||||
getColor(name?: string) {
|
||||
return AvatarNamesMap[name?.charAt(0).toUpperCase() || 'A'];
|
||||
}
|
||||
|
||||
async onQueryParamsChange(params: NzTableQueryParams) {
|
||||
const {pageSize, pageIndex, sort} = params;
|
||||
this.pageIndex = pageIndex;
|
||||
this.pageSize = pageSize;
|
||||
|
||||
const currentSort = sort.find(item => item.value !== null);
|
||||
|
||||
this.sortField = (currentSort && currentSort.key) || null;
|
||||
this.sortOrder = (currentSort && currentSort.value) || null;
|
||||
|
||||
await this.getUsers();
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await this.api.getOrganizationUsers(this.pageIndex, this.pageSize, this.sortField, this.sortOrder, this.searchForm.value.search);
|
||||
if (res.done) {
|
||||
this.total = res.body.total || 0;
|
||||
this.users = res.body.data || [];
|
||||
this.loading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.loading = false;
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {RouterModule, Routes} from '@angular/router';
|
||||
import {LayoutComponent} from './layout/layout.component';
|
||||
import {TeamOwnerOrAdminGuard} from '../guards/team-owner-or-admin-guard.service';
|
||||
import {TeamNameGuard} from '../guards/team-name.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LayoutComponent,
|
||||
canActivate: [TeamNameGuard],
|
||||
children: [
|
||||
{path: '', redirectTo: 'home', pathMatch: 'full'},
|
||||
{path: 'dashboard', redirectTo: 'home', pathMatch: 'full'}, // Remove after a couple releases
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./my-dashboard/my-dashboard.module').then(m => m.MyDashboardModule)
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
canActivate: [TeamOwnerOrAdminGuard],
|
||||
loadChildren: () => import('./projects/projects.module').then(m => m.ProjectsModule)
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule)
|
||||
},
|
||||
{
|
||||
path: 'schedule',
|
||||
canActivate: [TeamOwnerOrAdminGuard],
|
||||
loadChildren: () => import('./schedule/schedule.module').then(m => m.ScheduleModule)
|
||||
},
|
||||
{
|
||||
path: 'reporting',
|
||||
canActivate: [TeamOwnerOrAdminGuard],
|
||||
loadChildren: () => import('./reporting/reporting.module').then(m => m.ReportingModule)
|
||||
}, {
|
||||
path: 'admin-center',
|
||||
canActivate: [TeamOwnerOrAdminGuard],
|
||||
loadChildren: () => import('./admin-center/admin-center.module').then(m => m.AdminCenterModule)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'setup',
|
||||
loadChildren: () => import('./account-setup/account-setup.module').then(m => m.AccountSetupModule)
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AdministratorRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
||||
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {NotificationsDrawerComponent} from "./layout/notifications-drawer/notifications-drawer.component";
|
||||
import {AdministratorRoutingModule} from './administrator-routing.module';
|
||||
import {LayoutComponent} from './layout/layout.component';
|
||||
import {NzSpinModule} from "ng-zorro-antd/spin";
|
||||
import {NzAffixModule} from "ng-zorro-antd/affix";
|
||||
import {NzAlertModule} from "ng-zorro-antd/alert";
|
||||
import {NzLayoutModule} from "ng-zorro-antd/layout";
|
||||
import {NzMenuModule} from "ng-zorro-antd/menu";
|
||||
import {NzTypographyModule} from "ng-zorro-antd/typography";
|
||||
import {NzToolTipModule} from "ng-zorro-antd/tooltip";
|
||||
import {NzDropDownModule} from "ng-zorro-antd/dropdown";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
import {NzBadgeModule} from "ng-zorro-antd/badge";
|
||||
import {NzAvatarModule} from "ng-zorro-antd/avatar";
|
||||
import {NzBreadCrumbModule} from "ng-zorro-antd/breadcrumb";
|
||||
import {NzDrawerModule} from "ng-zorro-antd/drawer";
|
||||
import {NzEmptyModule} from "ng-zorro-antd/empty";
|
||||
import {NzSpaceModule} from "ng-zorro-antd/space";
|
||||
import {NzButtonModule} from "ng-zorro-antd/button";
|
||||
import {FromNowPipe} from "@pipes/from-now.pipe";
|
||||
import {NzMessageServiceModule} from "ng-zorro-antd/message";
|
||||
import {AlertsComponent} from './layout/alerts/alerts.component';
|
||||
import {HeaderComponent} from './layout/header/header.component';
|
||||
import {SafeStringPipe} from "@pipes/safe-string.pipe";
|
||||
import {NzTagModule} from "ng-zorro-antd/tag";
|
||||
import {
|
||||
NotificationTemplateComponent
|
||||
} from './layout/notifications-drawer/notification-template/notification-template.component';
|
||||
import {TagBackgroundPipe} from './layout/notifications-drawer/tag-background.pipe';
|
||||
import {NzSegmentedModule} from "ng-zorro-antd/segmented";
|
||||
import {NzDividerModule} from "ng-zorro-antd/divider";
|
||||
import {NzSkeletonModule} from "ng-zorro-antd/skeleton";
|
||||
import {CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
|
||||
import {LicensingAlertsComponent} from './layout/licensing-alerts/licensing-alerts.component';
|
||||
import {NzResultModule} from "ng-zorro-antd/result";
|
||||
import {NzListModule} from "ng-zorro-antd/list";
|
||||
import {TeamMembersFormComponent} from "@admin/components/team-members-form/team-members-form.component";
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
LayoutComponent,
|
||||
AlertsComponent,
|
||||
HeaderComponent,
|
||||
NotificationTemplateComponent,
|
||||
TagBackgroundPipe,
|
||||
LicensingAlertsComponent,
|
||||
NotificationsDrawerComponent,
|
||||
],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
AdministratorRoutingModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NzSpinModule,
|
||||
NzAffixModule,
|
||||
NzAlertModule,
|
||||
NzLayoutModule,
|
||||
NzMenuModule,
|
||||
NzTypographyModule,
|
||||
NzToolTipModule,
|
||||
NzDropDownModule,
|
||||
NzIconModule,
|
||||
NzBadgeModule,
|
||||
NzAvatarModule,
|
||||
NzBreadCrumbModule,
|
||||
NzDrawerModule,
|
||||
NzEmptyModule,
|
||||
NzMessageServiceModule,
|
||||
NzSpaceModule,
|
||||
NzButtonModule,
|
||||
FromNowPipe,
|
||||
NgOptimizedImage,
|
||||
SafeStringPipe,
|
||||
NzTagModule,
|
||||
NzSegmentedModule,
|
||||
NzDividerModule,
|
||||
NzSkeletonModule,
|
||||
CdkVirtualScrollViewport,
|
||||
CdkVirtualForOf,
|
||||
CdkFixedSizeVirtualScroll,
|
||||
NzResultModule,
|
||||
NzListModule,
|
||||
TeamMembersFormComponent
|
||||
]
|
||||
})
|
||||
export class AdministratorModule {
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<nz-avatar-group>
|
||||
<ng-container [ngSwitch]="showDot">
|
||||
<ng-container *ngSwitchCase="true">
|
||||
<nz-badge
|
||||
*ngFor="let item of names || []" [nzOffset]="[-4,24]"
|
||||
[nzStyle]="{background:'#52c41a',border:'4px solid #52c41a'}" nzDot>
|
||||
<ng-container *ngTemplateOutlet="templateRef; context: { $implicit: item }"></ng-container>
|
||||
</nz-badge>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="false">
|
||||
<ng-container *ngFor="let item of names || []">
|
||||
<ng-container *ngTemplateOutlet="templateRef; context: { $implicit: item }"></ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</nz-avatar-group>
|
||||
|
||||
<ng-template let-item #templateRef>
|
||||
<nz-avatar
|
||||
[nzSize]="28"
|
||||
[class]="avatarClass"
|
||||
[nzText]="item.end ? item.name : (item.name | firstCharUpper)"
|
||||
[style.background-color]="item.avatar_url ? '#ececec' : item.color_code"
|
||||
[nzSrc]="item.avatar_url"
|
||||
nz-tooltip
|
||||
[nzTooltipTitle]="item.end && item.names ? item.names : item.name"
|
||||
[nzTooltipPlacement]="'top'"
|
||||
></nz-avatar>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {AvatarsComponent} from './avatars.component';
|
||||
|
||||
describe('AvatarsComponent', () => {
|
||||
let component: AvatarsComponent;
|
||||
let fixture: ComponentFixture<AvatarsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [AvatarsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AvatarsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||
import {NgForOf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from "@angular/common";
|
||||
import {NzAvatarModule} from "ng-zorro-antd/avatar";
|
||||
import {NzToolTipModule} from "ng-zorro-antd/tooltip";
|
||||
|
||||
import {InlineMember} from "@interfaces/api-models/inline-member";
|
||||
import {FirstCharUpperPipe} from "@pipes/first-char-upper.pipe";
|
||||
import {NzBadgeModule} from "ng-zorro-antd/badge";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-avatars',
|
||||
templateUrl: './avatars.component.html',
|
||||
styleUrls: ['./avatars.component.scss'],
|
||||
imports: [
|
||||
NzAvatarModule,
|
||||
NzToolTipModule,
|
||||
NgForOf,
|
||||
FirstCharUpperPipe,
|
||||
NzBadgeModule,
|
||||
NgSwitch,
|
||||
NgTemplateOutlet,
|
||||
NgSwitchCase
|
||||
],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AvatarsComponent {
|
||||
@Input() names: InlineMember[] = [];
|
||||
@Input() avatarClass: string | null = null;
|
||||
@Input() showDot = false;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<form [formGroup]="form" [nzLayout]="'vertical'" nz-form>
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null" [nzTooltipTitle]="'You can manage clients under settings.'">Client</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<input [formControlName]="'name'" (ngModelChange)="search()" [nzAutocomplete]="jobTitlesAutoComplete"
|
||||
nz-input
|
||||
placeholder="Select client"/>
|
||||
<nz-autocomplete #jobTitlesAutoComplete>
|
||||
|
||||
<nz-auto-option *ngIf="searching">
|
||||
<span class="loading-icon" nz-icon nzType="loading"></span>
|
||||
Loading Data...
|
||||
</nz-auto-option>
|
||||
<span *ngIf="!searching">
|
||||
<nz-auto-option *ngIf="isNew" nzValue="{{ newName }}">+ ADD "{{ newName }}"</nz-auto-option>
|
||||
<nz-auto-option *ngFor="let item of clients" [nzValue]="item.name">{{ item.name }}</nz-auto-option>
|
||||
</span>
|
||||
</nz-autocomplete>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ClientsAutocompleteComponent} from './clients-autocomplete.component';
|
||||
|
||||
describe('ClientsAutocompleteComponent', () => {
|
||||
let component: ClientsAutocompleteComponent;
|
||||
let fixture: ComponentFixture<ClientsAutocompleteComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ClientsAutocompleteComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ClientsAutocompleteComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
|
||||
import {FormBuilder, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {IClient} from "@interfaces/client";
|
||||
import {ClientsApiService} from "@api/clients-api.service";
|
||||
import {log_error} from "@shared/utils";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {NzAutocompleteModule} from "ng-zorro-antd/auto-complete";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
import {NgForOf, NgIf} from "@angular/common";
|
||||
import {NzInputModule} from "ng-zorro-antd/input";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-clients-autocomplete',
|
||||
templateUrl: './clients-autocomplete.component.html',
|
||||
styleUrls: ['./clients-autocomplete.component.scss'],
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
NzFormModule,
|
||||
NzAutocompleteModule,
|
||||
NzIconModule,
|
||||
NgIf,
|
||||
NgForOf,
|
||||
NzInputModule
|
||||
],
|
||||
standalone: true
|
||||
})
|
||||
export class ClientsAutocompleteComponent implements OnInit {
|
||||
@Output() nameChange: EventEmitter<string> = new EventEmitter<string>();
|
||||
@Input() name: string | null = null;
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
searching = false;
|
||||
isNew = false;
|
||||
|
||||
newName: string | null = null;
|
||||
|
||||
clients: IClient[] = [];
|
||||
|
||||
total = 0;
|
||||
|
||||
constructor(
|
||||
private api: ClientsApiService,
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: []
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.form.controls["name"].setValue(this.name || null);
|
||||
this.form.get('name')?.valueChanges.subscribe((value) => {
|
||||
if (value) {
|
||||
this.newName = value;
|
||||
this.isNew = !this.clients.some((i) => i.name === value);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isNew = false;
|
||||
});
|
||||
await this.get();
|
||||
}
|
||||
|
||||
async get() {
|
||||
try {
|
||||
const res = await this.api.get(1, 5, null, null, this.form.value.name || null);
|
||||
if (res.done) {
|
||||
this.clients = res.body.data || [];
|
||||
this.total = this.clients.length;
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async search() {
|
||||
this.emitChange();
|
||||
this.searching = true;
|
||||
await this.get();
|
||||
this.searching = false;
|
||||
}
|
||||
|
||||
private emitChange() {
|
||||
if (this.form.valid)
|
||||
this.nameChange.emit(this.form.value.name.trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<nz-modal [(nzVisible)]="showConvertTasksModal" nzTitle="Choose a parent task" [nzStyle]="{ top: '20px' }"
|
||||
(nzOnCancel)="closeModal()">
|
||||
<ng-container *nzModalContent>
|
||||
<ng-container *ngIf="converting">
|
||||
<div class="spinner">
|
||||
<nz-spin nzSimple [nzIndicator]="indicatorTemplate"></nz-spin>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="modal-content">
|
||||
<div class="search-task">
|
||||
<input [(ngModel)]="searchText" nz-input placeholder="Search by task name"/>
|
||||
</div>
|
||||
<div class="scrollable mt-3">
|
||||
<div *ngFor="let group of groups; let i=index" class="task-group">
|
||||
|
||||
<div *ngIf="group.tasks.length" [class.active]="isExpanded[i]" nz-typography
|
||||
[style.background]="group.color_code"
|
||||
class="py-1 px-2 mb-0 ant-typography d-block btn" (click)="toggleGroup($event, i)">
|
||||
<span class="accordion-icon" nz-icon nzType="right" nzTheme="outline"></span> {{group.name}}
|
||||
</div>
|
||||
|
||||
<div *ngIf="group.tasks.length" [class.show]="isExpanded[i]" class="mt-0 mb-3 panel" #panel>
|
||||
<div class="panel-left-border" [style.background]="group.color_code"></div>
|
||||
<ul nz-menu class="border-bottom">
|
||||
<ng-container *ngFor="let item of group.tasks | searchByName: searchText;">
|
||||
<li *ngIf="!item.parent_task_id && selectedTask?.id !== item?.id"
|
||||
class="m-0 d-flex px-0 single-task-cont" nz-menu-item (click)="convertToSubTask(group.id, item.id)">
|
||||
<div class="d-flex align-items-center justify-content-center hover-bg-change px-2 ">
|
||||
<div class="d-flex" style="width:90px;">
|
||||
<nz-tag nz-tooltip [nzTooltipTitle]="item.task_key"
|
||||
style="width:auto; max-width: 90px; overflow:hidden; text-overflow: ellipsis;font-size: 12px;">{{item.task_key}}</nz-tag>
|
||||
</div>
|
||||
<div nz-tooltip [nzTooltipTitle]="item.name"
|
||||
style="width: 340px; overflow:hidden; text-overflow: ellipsis;">
|
||||
<span nz-typography>{{item.name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div *nzModalFooter>
|
||||
</div>
|
||||
</nz-modal>
|
||||
|
||||
<ng-template #indicatorTemplate><span nz-icon nzType="loading"></span></ng-template>
|
||||
@@ -0,0 +1,86 @@
|
||||
.scrollable {
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
label span {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hover-bg-change:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgb(255 255 255 / 30%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
padding: 0 0;
|
||||
background-color: white;
|
||||
max-height: 0px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.1s ease-out;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.panel.show {
|
||||
transition: max-height 0.1s ease-out;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.panel-left-border {
|
||||
position: absolute;
|
||||
content: '';
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
z-index: 3;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: max-content;
|
||||
padding-left: 16px !important;
|
||||
padding-right: 32px !important;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
|
||||
.btn .accordion-icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.btn.active .accordion-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.single-task-cont {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-right: none !important;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ConvertToSubtaskModalComponent} from './convert-to-subtask-modal.component';
|
||||
|
||||
describe('ConvertToSubtaskModalComponent', () => {
|
||||
let component: ConvertToSubtaskModalComponent;
|
||||
let fixture: ComponentFixture<ConvertToSubtaskModalComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ConvertToSubtaskModalComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConvertToSubtaskModalComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, NgZone, OnDestroy, Renderer2} from '@angular/core';
|
||||
import {NzListModule} from "ng-zorro-antd/list";
|
||||
import {NzButtonModule} from "ng-zorro-antd/button";
|
||||
import {NgForOf, NgIf} from "@angular/common";
|
||||
import {NzSkeletonModule} from "ng-zorro-antd/skeleton";
|
||||
import {NzSpinModule} from "ng-zorro-antd/spin";
|
||||
import {NzModalModule} from 'ng-zorro-antd/modal';
|
||||
import {TaskListHashMapService} from "../../modules/task-list-v2/task-list-hash-map.service";
|
||||
import {ITaskListConfigV2, ITaskListGroup} from 'app/administrator/modules/task-list-v2/interfaces';
|
||||
import {TasksApiService} from '@api/tasks-api.service';
|
||||
import {TaskListV2Service} from 'app/administrator/modules/task-list-v2/task-list-v2.service';
|
||||
import {IServerResponse} from '@interfaces/api-models/server-response';
|
||||
import {NzInputModule} from 'ng-zorro-antd/input';
|
||||
import {SearchByNamePipe} from "../../../pipes/search-by-name.pipe";
|
||||
import {NzTagModule} from 'ng-zorro-antd/tag';
|
||||
import {NzToolTipModule} from 'ng-zorro-antd/tooltip';
|
||||
import {SocketEvents} from '@shared/socket-events';
|
||||
import {Socket} from 'ngx-socket-io';
|
||||
import {
|
||||
SubtaskConvertService
|
||||
} from 'app/administrator/modules/task-list-v2/task-list-table/task-list-context-menu/subtask-convert-service.service';
|
||||
import {Subject, takeUntil} from 'rxjs';
|
||||
import {IProjectTask} from '@interfaces/api-models/project-tasks-view-model';
|
||||
import {
|
||||
ISubtaskConvertRequest
|
||||
} from 'app/administrator/modules/task-list-v2/task-list-table/task-list-context-menu/interfaces/convert-subtask-request';
|
||||
import {NzMenuModule} from 'ng-zorro-antd/menu';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {NzIconModule} from 'ng-zorro-antd/icon';
|
||||
import {KanbanV2Service} from 'app/administrator/modules/kanban-view-v2/kanban-view-v2.service';
|
||||
import {AuthService} from "@services/auth.service";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-convert-to-subtask-modal',
|
||||
templateUrl: './convert-to-subtask-modal.component.html',
|
||||
styleUrls: ['./convert-to-subtask-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
NzListModule,
|
||||
NzButtonModule,
|
||||
NgIf,
|
||||
NgForOf,
|
||||
NzSkeletonModule,
|
||||
NzSpinModule,
|
||||
NzModalModule,
|
||||
NzInputModule,
|
||||
SearchByNamePipe,
|
||||
NzTagModule,
|
||||
NzToolTipModule,
|
||||
NzMenuModule,
|
||||
FormsModule,
|
||||
NzIconModule
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ConvertToSubtaskModalComponent implements OnDestroy {
|
||||
|
||||
projectId?: string | null;
|
||||
searchText: string | null = null;
|
||||
selectedTaskId: string | null = null;
|
||||
|
||||
selectedTask?: IProjectTask | null;
|
||||
|
||||
showConvertTasksModal = false;
|
||||
loadingGroups = false;
|
||||
converting = false;
|
||||
|
||||
protected groupIds: string[] = [];
|
||||
isExpanded: boolean[] = [];
|
||||
groups: ITaskListGroup[] = [];
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private readonly map: TaskListHashMapService,
|
||||
private readonly api: TasksApiService,
|
||||
private readonly service: TaskListV2Service,
|
||||
private readonly subTaskConvertService: SubtaskConvertService,
|
||||
private readonly cdr: ChangeDetectorRef,
|
||||
private readonly socket: Socket,
|
||||
private readonly ngZone: NgZone,
|
||||
private readonly kanbanService: KanbanV2Service,
|
||||
private readonly auth: AuthService,
|
||||
) {
|
||||
this.subTaskConvertService.onConvertingSubtask
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(value => {
|
||||
this.getTaskData(value);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
getTaskData(value: ISubtaskConvertRequest) {
|
||||
this.projectId = value.projectId;
|
||||
this.selectedTask = value.selectedTask;
|
||||
void this.getGroups();
|
||||
}
|
||||
|
||||
async getGroups() {
|
||||
if (!this.projectId) return;
|
||||
try {
|
||||
this.map.deselectAll();
|
||||
this.loadingGroups = true;
|
||||
const config = this.getConf();
|
||||
const res = await this.api.getTaskListV2(config) as IServerResponse<ITaskListGroup[]>;
|
||||
if (res.done) {
|
||||
this.groups = res.body;
|
||||
this.groupIds = res.body.map(g => g.id);
|
||||
await this.mapTasks(this.service.groups);
|
||||
this.showConvertTasksModal = true;
|
||||
}
|
||||
this.loadingGroups = false;
|
||||
} catch (e) {
|
||||
this.loadingGroups = false;
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
private getConf(parentTaskId?: string): ITaskListConfigV2 {
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: this.projectId as string,
|
||||
group: this.service.getCurrentGroup().value,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
statuses: null,
|
||||
members: null,
|
||||
projects: null,
|
||||
isSubtasksInclude: false
|
||||
};
|
||||
|
||||
if (parentTaskId)
|
||||
config.parent_task = parentTaskId;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private mapTasks(groups: ITaskListGroup[]) {
|
||||
for (const group of groups) {
|
||||
this.map.registerGroup(group);
|
||||
for (const task of group.tasks) {
|
||||
if (task.start_date) task.start_date = new Date(task.start_date) as any;
|
||||
if (task.end_date) task.end_date = new Date(task.end_date) as any;
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
// expanding panels after groups loaded
|
||||
this.isExpanded = this.groups.map(() => true);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async convertToSubTask(toGroupId: string, parentTaskId?: string) {
|
||||
|
||||
const selectedTask = this.selectedTask;
|
||||
if (!selectedTask) return;
|
||||
|
||||
const groupBy = this.service.getCurrentGroup();
|
||||
if (groupBy.value === this.service.GROUP_BY_STATUS_VALUE) {
|
||||
this.handleStatusChange(toGroupId, this.selectedTask?.id);
|
||||
} else if (groupBy.value === this.service.GROUP_BY_PRIORITY_VALUE) {
|
||||
this.handlePriorityChange(toGroupId, this.selectedTask?.id as string);
|
||||
}
|
||||
|
||||
try {
|
||||
this.converting = true;
|
||||
const res = await this.api.convertToSubTask(
|
||||
selectedTask.id as string,
|
||||
selectedTask.project_id as string,
|
||||
parentTaskId as string,
|
||||
groupBy.value,
|
||||
toGroupId
|
||||
);
|
||||
if (res.done) {
|
||||
this.service.updateTaskGroup(res.body, false);
|
||||
if (groupBy.value === this.service.GROUP_BY_PHASE_VALUE)
|
||||
this.service.emitRefresh();
|
||||
}
|
||||
this.reset();
|
||||
} catch (e) {
|
||||
this.converting = false;
|
||||
}
|
||||
this.kanbanService.emitRefreshGroups();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
handleStatusChange(statusId: string, taskId?: string) {
|
||||
if (!taskId) return;
|
||||
this.socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({
|
||||
task_id: taskId,
|
||||
status_id: statusId,
|
||||
team_id: this.auth.getCurrentSession()?.team_id
|
||||
}));
|
||||
this.socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), taskId);
|
||||
}
|
||||
|
||||
handlePriorityChange(priorityId: string, taskId: string) {
|
||||
this.socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({
|
||||
task_id: taskId,
|
||||
priority_id: priorityId
|
||||
}));
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.showConvertTasksModal = false;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.converting = false;
|
||||
this.showConvertTasksModal = false;
|
||||
this.loadingGroups = false;
|
||||
this.groups = [];
|
||||
this.groupIds = [];
|
||||
this.searchText = null;
|
||||
this.selectedTaskId = null;
|
||||
}
|
||||
|
||||
toggleGroup(event: MouseEvent, index: number) {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
const target = event.target as Element;
|
||||
if (!target) return;
|
||||
this.isExpanded[index] = !this.isExpanded[index];
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<nz-drawer
|
||||
[nzBodyStyle]="{ overflow: 'auto' }"
|
||||
[nzWidth]="650"
|
||||
[nzVisible]="drawerVisible"
|
||||
[nzTitle]="'Import tasks'"
|
||||
[nzFooter]="footerTpl"
|
||||
(nzOnClose)="closeDrawer()"
|
||||
(nzVisibleChange)="onVisibleChange($event)"
|
||||
>
|
||||
<form nz-form *nzDrawerContent [formGroup]="form">
|
||||
<div nz-row [nzGutter]="8">
|
||||
<div nz-col nzSpan="24">
|
||||
<nz-form-item class="mb-4">
|
||||
<nz-form-label>Select Template</nz-form-label>
|
||||
<nz-form-control nzErrorTip="Please select a template!">
|
||||
<nz-select name="template" [nzPlaceHolder]="'Please select a template to load tasks'" #templateSelect
|
||||
[formControlName]="'template'" [nzLoading]="loadingTemplates">
|
||||
<nz-option *ngFor="let template of templates; let i = index;" [nzLabel]="template.name | safeString"
|
||||
[nzValue]="template.id"></nz-option>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-divider></nz-divider>
|
||||
<span nz-typography class="fw-bold">Selected Tasks ({{tasks.length}})</span>
|
||||
<ul nz-list nzBordered class="mt-4" [nzDataSource]="tasks" [nzLoading]="loadingData"
|
||||
[nzNoResult]="'No template selected! Please select a template to load tasks.'">
|
||||
<li nz-list-item *ngFor="let task of tasks; let i = index;">
|
||||
<ul nz-list-item-actions>
|
||||
<nz-list-item-action>
|
||||
<a (click)="removeTask(i)">Remove</a>
|
||||
</nz-list-item-action>
|
||||
</ul>
|
||||
{{task.name}}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-template #footerTpl>
|
||||
<div style="float: right">
|
||||
<button nz-button style="margin-right: 8px;" (click)="closeDrawer()">Cancel</button>
|
||||
<button nz-button nzType="primary" (click)="importFromTemplate()" [nzLoading]="importing">Import</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</nz-drawer>
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ImportTasksTemplateComponent} from './import-tasks-template.component';
|
||||
|
||||
describe('ImportTasksTemplateComponent', () => {
|
||||
let component: ImportTasksTemplateComponent;
|
||||
let fixture: ComponentFixture<ImportTasksTemplateComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ImportTasksTemplateComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ImportTasksTemplateComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {NzDrawerModule} from "ng-zorro-antd/drawer";
|
||||
import {NzGridModule} from "ng-zorro-antd/grid";
|
||||
import {NzSelectComponent, NzSelectModule} from "ng-zorro-antd/select";
|
||||
import {NzButtonModule} from "ng-zorro-antd/button";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {TaskTemplatesService} from "@api/task-templates.service";
|
||||
import {ITaskTemplatesGetResponse} from "@interfaces/api-models/task-templates-get-response";
|
||||
import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {IProjectTask} from "@interfaces/api-models/project-tasks-view-model";
|
||||
import {NzListModule} from "ng-zorro-antd/list";
|
||||
import {NzEmptyModule} from "ng-zorro-antd/empty";
|
||||
import {AppService} from "@services/app.service";
|
||||
import {NzDividerModule} from "ng-zorro-antd/divider";
|
||||
import {SafeStringPipe} from "@pipes/safe-string.pipe";
|
||||
import {NzTypographyModule} from "ng-zorro-antd/typography";
|
||||
import {DRAWER_ANIMATION_INTERVAL} from "@shared/constants";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-import-tasks-template',
|
||||
templateUrl: './import-tasks-template.component.html',
|
||||
styleUrls: ['./import-tasks-template.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, NzDrawerModule, NzGridModule, NzSelectModule, NzButtonModule, NzFormModule, FormsModule, NzListModule, NzEmptyModule, ReactiveFormsModule, NzDividerModule, SafeStringPipe, NzTypographyModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ImportTasksTemplateComponent implements OnDestroy {
|
||||
@ViewChild("templateSelect", {static: false}) selectTemplate!: NzSelectComponent;
|
||||
|
||||
@Input() drawerVisible = false;
|
||||
@Input() projectId: string | null = null;
|
||||
|
||||
@Output() onImportDone = new EventEmitter();
|
||||
@Output() onCancel = new EventEmitter();
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
selectedId: string | null = null;
|
||||
|
||||
loadingTemplates = false;
|
||||
loadingData = false;
|
||||
importing = false;
|
||||
|
||||
templates: ITaskTemplatesGetResponse[] = [];
|
||||
tasks: IProjectTask[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly api: TaskTemplatesService,
|
||||
private readonly ngZone: NgZone,
|
||||
private readonly app: AppService,
|
||||
private readonly fb: FormBuilder,
|
||||
private readonly cdr: ChangeDetectorRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
template: [null, Validators.required],
|
||||
});
|
||||
|
||||
this.form.get('template')?.valueChanges.subscribe(changes => {
|
||||
if (changes) {
|
||||
this.selectedId = changes;
|
||||
this.templateSelected();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.reset();
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.drawerVisible = true;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
closeDrawer(): void {
|
||||
this.form.reset();
|
||||
this.tasks = [];
|
||||
this.onCancel.emit();
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
onVisibleChange(event: boolean) {
|
||||
if (event) {
|
||||
void this.getTaskTemplate();
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
setTimeout(() => {
|
||||
this.selectTemplate?.focus();
|
||||
}, DRAWER_ANIMATION_INTERVAL)
|
||||
});
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async getTaskTemplate() {
|
||||
try {
|
||||
this.loadingTemplates = true;
|
||||
const res = await this.api.get();
|
||||
if (res.done) {
|
||||
this.templates = res.body;
|
||||
this.loadingTemplates = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingTemplates = false;
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async getTemplateData() {
|
||||
if (!this.selectedId) return;
|
||||
|
||||
try {
|
||||
this.loadingData = true;
|
||||
const res = await this.api.getById(this.selectedId);
|
||||
if (res.done) {
|
||||
this.tasks = res.body.tasks || [];
|
||||
this.loadingData = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingData = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
templateSelected() {
|
||||
void this.getTemplateData();
|
||||
}
|
||||
|
||||
removeTask(index: number) {
|
||||
if (this.tasks.length > 1) {
|
||||
this.tasks.splice(index, 1);
|
||||
} else {
|
||||
this.tasks = [];
|
||||
}
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
for (const controlName in this.form.controls) {
|
||||
this.form.controls[controlName].updateValueAndValidity();
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async importFromTemplate() {
|
||||
if (!this.projectId) return;
|
||||
|
||||
try {
|
||||
this.validateForm();
|
||||
if (this.form.invalid) {
|
||||
this.form.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (this.tasks.length) {
|
||||
this.importing = true;
|
||||
const res = await this.api.import(this.projectId, this.tasks);
|
||||
if (res.done) {
|
||||
this.api.emitOnImport();
|
||||
this.onImportDone.emit();
|
||||
this.reset();
|
||||
this.drawerVisible = false;
|
||||
}
|
||||
this.importing = false;
|
||||
} else {
|
||||
this.app.notify("Incomplete request!", "No tasks to import", false);
|
||||
}
|
||||
} catch (e) {
|
||||
this.importing = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.tasks = [];
|
||||
this.selectedId = null;
|
||||
this.form.reset();
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<form [formGroup]="form" [nzLayout]="'vertical'" nz-form>
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">Job Title</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<input [formControlName]="'name'" (ngModelChange)="search()" [nzAutocomplete]="jobTitlesAutoComplete"
|
||||
nz-input
|
||||
[placeholder]="placeholder"/>
|
||||
<nz-autocomplete #jobTitlesAutoComplete>
|
||||
|
||||
<nz-auto-option *ngIf="searching">
|
||||
<span class="loading-icon" nz-icon nzType="loading"></span>
|
||||
Loading Data...
|
||||
</nz-auto-option>
|
||||
<span *ngIf="!searching">
|
||||
<nz-auto-option *ngIf="isNew" nzValue="{{ newTitle }}">+ ADD "{{ newTitle }}"</nz-auto-option>
|
||||
<nz-auto-option *ngFor="let item of jobTitles" [nzValue]="item.name">{{ item.name }}</nz-auto-option>
|
||||
</span>
|
||||
|
||||
</nz-autocomplete>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {JobTitlesAutocompleteComponent} from './job-titles-autocomplete.component';
|
||||
|
||||
describe('JobTitlesAutocompleteComponent', () => {
|
||||
let component: JobTitlesAutocompleteComponent;
|
||||
let fixture: ComponentFixture<JobTitlesAutocompleteComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [JobTitlesAutocompleteComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(JobTitlesAutocompleteComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
|
||||
import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
|
||||
import {JobTitlesApiService} from '@api/job-titles-api.service';
|
||||
import {IJobTitle} from '@interfaces/job-title';
|
||||
import {log_error} from "@shared/utils";
|
||||
import {NzInputModule} from "ng-zorro-antd/input";
|
||||
import {NgForOf, NgIf} from "@angular/common";
|
||||
import {NzAutocompleteModule} from "ng-zorro-antd/auto-complete";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-job-titles-autocomplete',
|
||||
templateUrl: './job-titles-autocomplete.component.html',
|
||||
styleUrls: ['./job-titles-autocomplete.component.scss'],
|
||||
imports: [
|
||||
NzInputModule,
|
||||
NgIf,
|
||||
ReactiveFormsModule,
|
||||
NgForOf,
|
||||
NzAutocompleteModule,
|
||||
NzFormModule,
|
||||
NzIconModule
|
||||
],
|
||||
standalone: true
|
||||
})
|
||||
export class JobTitlesAutocompleteComponent implements OnInit {
|
||||
@Output() titleChange: EventEmitter<string> = new EventEmitter<string>();
|
||||
@Input() title: string | null = null;
|
||||
@Input() placeholder = "Job Title";
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
@Input() loading = false;
|
||||
@Output() loadingChange: EventEmitter<boolean> = new EventEmitter<boolean>();
|
||||
searching = false;
|
||||
isNew = false;
|
||||
|
||||
newTitle: string | null = null;
|
||||
|
||||
jobTitles: IJobTitle[] = [];
|
||||
|
||||
total = 0;
|
||||
|
||||
constructor(
|
||||
private api: JobTitlesApiService,
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: [null]
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.form.controls['name'].setValue(this.title || null);
|
||||
this.form.get('name')?.valueChanges.subscribe((value) => {
|
||||
if (value) {
|
||||
this.newTitle = value;
|
||||
this.isNew = !this.jobTitles.some((i) => i.name === value);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isNew = false;
|
||||
});
|
||||
await this.get();
|
||||
}
|
||||
|
||||
async get() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
const res = await this.api.get(1, 5, null, null, this.form.value.name || null);
|
||||
if (res.done) {
|
||||
this.jobTitles = res.body.data || [];
|
||||
this.total = this.jobTitles.length;
|
||||
}
|
||||
this.setLoading(false);
|
||||
} catch (e) {
|
||||
this.setLoading(false);
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async search() {
|
||||
this.emitChange();
|
||||
this.searching = true;
|
||||
await this.get();
|
||||
this.searching = false;
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean) {
|
||||
this.loading = loading;
|
||||
this.loadingChange.emit(this.loading);
|
||||
}
|
||||
|
||||
private emitChange() {
|
||||
if (this.form.valid)
|
||||
this.titleChange.emit(this.form.value.name.trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<span nz-typography [nzType]="'secondary'">-</span>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NaComponent } from './na.component';
|
||||
|
||||
describe('NaComponent', () => {
|
||||
let component: NaComponent;
|
||||
let fixture: ComponentFixture<NaComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NaComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(NaComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import {ChangeDetectionStrategy, Component} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {NzTypographyModule} from "ng-zorro-antd/typography";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-na',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NzTypographyModule],
|
||||
templateUrl: './na.component.html',
|
||||
styleUrls: ['./na.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class NaComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<nz-card nzTitle="Tasks due today" nzType="inner">
|
||||
<nz-list [nzLoading]="tasks_due_today_loading" nzItemLayout="horizontal" nzSize="small">
|
||||
<nz-list-item *ngFor="let item of tasksDueToday">
|
||||
<nz-list-item-meta [nzDescription]=item.name>
|
||||
<nz-list-item-meta-title>
|
||||
<span>{{item.project_name}}</span>
|
||||
</nz-list-item-meta-title>
|
||||
</nz-list-item-meta>
|
||||
<span><span>{{item.priority}}</span>
|
||||
<span *ngIf="item.priority === 'Low'" class="low-priority" nz-icon nzType="minus"></span>
|
||||
<span *ngIf="item.priority === 'Medium'" class="medium-priority" nz-icon nzType="pause"></span>
|
||||
<span *ngIf="item.priority === 'High'" class="high-priority" nz-icon nzType="double-left"></span>
|
||||
</span>
|
||||
</nz-list-item>
|
||||
<nz-list-empty *ngIf="tasksDueToday.length === 0"></nz-list-empty>
|
||||
</nz-list>
|
||||
</nz-card>
|
||||
|
||||
<nz-space nzSize="middle"></nz-space>
|
||||
|
||||
<nz-card nzTitle="Remaining tasks" nzType="inner">
|
||||
<nz-list [nzLoading]="tasks_due_loading" nzItemLayout="horizontal">
|
||||
<nz-list-item *ngFor="let item of tasksRemaining">
|
||||
<nz-list-item-meta [nzDescription]=item.name>
|
||||
<nz-list-item-meta-title>
|
||||
<span>{{item.project_name}}</span>
|
||||
</nz-list-item-meta-title>
|
||||
</nz-list-item-meta>
|
||||
<span [nz-tooltip]="getSpecificTime(item.created_at)">{{getTimestamp(item.created_at)}}</span>
|
||||
</nz-list-item>
|
||||
<nz-list-empty *ngIf="tasksRemaining.length === 0"></nz-list-empty>
|
||||
</nz-list>
|
||||
</nz-card>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<nz-card nzTitle="Activity Log" nzType="inner">
|
||||
<nz-list [nzLoading]="activity_log_loading" nzItemLayout="horizontal">
|
||||
<nz-list-item *ngFor="let item of activityLog">
|
||||
<nz-list-item-meta [nzDescription]=item.description>
|
||||
<nz-list-item-meta-title>
|
||||
<span>{{item.project_name}}</span>
|
||||
</nz-list-item-meta-title>
|
||||
</nz-list-item-meta>
|
||||
<span [nz-tooltip]="getSpecificTime(item.created_at)">{{getTimestamp(item.created_at)}}</span>
|
||||
</nz-list-item>
|
||||
<nz-list-empty *ngIf="activityLog.length === 0"></nz-list-empty>
|
||||
</nz-list>
|
||||
</nz-card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {PersonalOverviewComponent} from './personal-overview.component';
|
||||
|
||||
describe('PersonalOverviewComponent', () => {
|
||||
let component: PersonalOverviewComponent;
|
||||
let fixture: ComponentFixture<PersonalOverviewComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [PersonalOverviewComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PersonalOverviewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {PersonalOverviewService} from '@api/personal-overview.service';
|
||||
import moment from 'moment';
|
||||
import {NzListModule} from "ng-zorro-antd/list";
|
||||
import {NzCardModule} from "ng-zorro-antd/card";
|
||||
import {NzSpaceModule} from "ng-zorro-antd/space";
|
||||
import {NgForOf, NgIf} from "@angular/common";
|
||||
import {NzToolTipModule} from "ng-zorro-antd/tooltip";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-personal-project-insights-member-overview',
|
||||
templateUrl: './personal-overview.component.html',
|
||||
styleUrls: ['./personal-overview.component.scss'],
|
||||
imports: [
|
||||
NzListModule,
|
||||
NzCardModule,
|
||||
NzSpaceModule,
|
||||
NgForOf,
|
||||
NgIf,
|
||||
NzToolTipModule,
|
||||
NzIconModule
|
||||
],
|
||||
standalone: true
|
||||
})
|
||||
export class PersonalOverviewComponent implements OnInit {
|
||||
loading = false;
|
||||
activity_log_loading = false;
|
||||
tasks_due_today_loading = false;
|
||||
tasks_due_loading = false;
|
||||
|
||||
activityLog: any = [];
|
||||
tasksDueToday: any = [];
|
||||
tasksRemaining: any = [];
|
||||
|
||||
constructor(
|
||||
private personalOverviewService: PersonalOverviewService
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.getActivityLog().then(r => r);
|
||||
this.getTasksDueToday().then(r => r);
|
||||
this.getTasksRemaining().then(r => r);
|
||||
}
|
||||
|
||||
async getActivityLog() {
|
||||
try {
|
||||
this.activity_log_loading = true;
|
||||
const res = await this.personalOverviewService.getActivityLog();
|
||||
if (res.done) {
|
||||
this.activityLog = res.body;
|
||||
}
|
||||
this.activity_log_loading = false;
|
||||
} catch (e) {
|
||||
this.activity_log_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getTasksDueToday() {
|
||||
try {
|
||||
this.tasks_due_today_loading = true;
|
||||
const res = await this.personalOverviewService.getTasksDueToday();
|
||||
if (res.done) {
|
||||
this.tasksDueToday = res.body;
|
||||
}
|
||||
this.tasks_due_today_loading = false;
|
||||
} catch (e) {
|
||||
this.tasks_due_today_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getTasksRemaining() {
|
||||
try {
|
||||
this.tasks_due_loading = true;
|
||||
const res = await this.personalOverviewService.getRemainingTasks();
|
||||
if (res.done) {
|
||||
this.tasksRemaining = res.body;
|
||||
}
|
||||
this.tasks_due_loading = false;
|
||||
} catch (e) {
|
||||
this.tasks_due_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getTimestamp(created_at: string) {
|
||||
return moment(created_at).fromNow();
|
||||
}
|
||||
|
||||
getSpecificTime(created_at: string) {
|
||||
return moment(created_at).format('YYYY-MM-DD hh:mm:ss A');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">Category</nz-form-label>
|
||||
<nz-form-control [ngSwitch]="showCategoryInput" [nzSpan]="null"
|
||||
[nzExtra]="showCategoryInput ? 'Hit enter to create!' : ''">
|
||||
|
||||
<ng-container *ngSwitchCase="true">
|
||||
<input nz-input placeholder="Enter a name for the category" [(ngModel)]="newCategoryName"
|
||||
(blur)="resetInputMode()"
|
||||
[disabled]="creating"
|
||||
(keyup.enter)="$event.stopPropagation();create()"
|
||||
#nameInput/>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="false">
|
||||
<nz-select [(ngModel)]="categoryId" [nzLoading]="loading" (ngModelChange)="onCategoryChange($event)"
|
||||
[nzPlaceHolder]="'Add a category to the project'"
|
||||
[nzDisabled]="disabled"
|
||||
[nzAllowClear]="true">
|
||||
<nz-option *ngFor="let item of categories" [nzLabel]="item.name | safeString" [nzValue]="item.id"
|
||||
nzCustomContent>
|
||||
{{item.name}}
|
||||
</nz-option>
|
||||
<nz-option [nzValue]="'add'" nzCustomContent>
|
||||
<button nz-button [nzType]="'dashed'" nzBlock [nzSize]="'small'" (click)="newCategory()">
|
||||
<span nz-icon [nzType]="'plus'" [nzTheme]="'outline'"></span> New Category
|
||||
</button>
|
||||
</nz-option>
|
||||
</nz-select>
|
||||
</ng-container>
|
||||
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
@@ -0,0 +1,21 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ProjectCategoriesAutocompleteComponent} from './project-categories-autocomplete.component';
|
||||
|
||||
describe('ProjectCategoriesAutocompleteComponent', () => {
|
||||
let component: ProjectCategoriesAutocompleteComponent;
|
||||
let fixture: ComponentFixture<ProjectCategoriesAutocompleteComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ProjectCategoriesAutocompleteComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(ProjectCategoriesAutocompleteComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
NgZone,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {NzButtonModule} from "ng-zorro-antd/button";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {NzGridModule} from "ng-zorro-antd/grid";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
import {NzSelectModule} from "ng-zorro-antd/select";
|
||||
import {NzWaveModule} from "ng-zorro-antd/core/wave";
|
||||
import {SafeStringPipe} from "@pipes/safe-string.pipe";
|
||||
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {IProjectCategory} from "@interfaces/project-category";
|
||||
import {log_error} from "@shared/utils";
|
||||
import {ProjectCategoriesApiService} from "@api/project-categories-api.service";
|
||||
import {NzInputModule} from "ng-zorro-antd/input";
|
||||
import {ProjectFormService} from "@services/project-form-service.service";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-project-categories-autocomplete',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NzButtonModule,
|
||||
NzFormModule,
|
||||
NzGridModule,
|
||||
NzIconModule,
|
||||
NzSelectModule,
|
||||
NzWaveModule,
|
||||
SafeStringPipe,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
NzInputModule
|
||||
],
|
||||
templateUrl: './project-categories-autocomplete.component.html',
|
||||
styleUrls: ['./project-categories-autocomplete.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProjectCategoriesAutocompleteComponent implements OnInit {
|
||||
@ViewChild("nameInput", {static: false}) nameInput!: ElementRef;
|
||||
|
||||
@Input() categoryId: string | null = null;
|
||||
@Output() categoryIdChange = new EventEmitter<string | null>();
|
||||
|
||||
@Input() disabled = false;
|
||||
|
||||
loading = false;
|
||||
creating = false;
|
||||
showCategoryInput = false;
|
||||
categories: IProjectCategory[] = [];
|
||||
newCategoryName: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly cdr: ChangeDetectorRef,
|
||||
private readonly api: ProjectCategoriesApiService,
|
||||
private readonly ngZone: NgZone,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
void this.get();
|
||||
}
|
||||
|
||||
async get() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await this.api.get();
|
||||
if (res.done) {
|
||||
this.categories = res.body;
|
||||
}
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
this.loading = false;
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
newCategory() {
|
||||
if (this.showCategoryInput) return;
|
||||
this.newCategoryName = null;
|
||||
this.showCategoryInput = true;
|
||||
this.focusInput();
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
onCategoryChange(categoryId: string) {
|
||||
this.categoryId = categoryId;
|
||||
this.categoryIdChange.emit(this.categoryId);
|
||||
}
|
||||
|
||||
resetInputMode() {
|
||||
this.showCategoryInput = false;
|
||||
this.newCategoryName = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
if (!this.newCategoryName?.trim() || this.creating) return;
|
||||
try {
|
||||
this.creating = true;
|
||||
const body = {
|
||||
name: this.newCategoryName
|
||||
};
|
||||
const res = await this.api.create(body);
|
||||
if (res.done) {
|
||||
await this.get();
|
||||
this.handleCreate(res.body);
|
||||
}
|
||||
this.creating = false;
|
||||
} catch (e) {
|
||||
this.creating = false;
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private handleCreate(category: IProjectCategory) {
|
||||
this.onCategoryChange(category.id as string);
|
||||
this.resetInputMode();
|
||||
}
|
||||
|
||||
private focusInput() {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
// wait for html to display
|
||||
setTimeout(() => {
|
||||
this.nameInput.nativeElement?.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<form [formGroup]="form" [nzLayout]="'vertical'" nz-form>
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">Folder</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<input [formControlName]="'folder'" [nzAutocomplete]="autoComplete" nz-input
|
||||
placeholder="Select a folder for the project"/>
|
||||
<nz-autocomplete #autoComplete>
|
||||
<ng-container [ngSwitch]="isNew">
|
||||
<nz-auto-option *ngSwitchCase="true" [nzValue]="searchValue" (click)="addFolder()">
|
||||
+ Add "{{ searchValue }}"
|
||||
</nz-auto-option>
|
||||
<ng-container *ngSwitchCase="false">
|
||||
<nz-auto-option *ngFor="let item of folders | searchByName: searchValue" [nzValue]="item.id">
|
||||
{{item.name}}
|
||||
</nz-auto-option>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</nz-autocomplete>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
@@ -0,0 +1,21 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ProjectFoldersAutocompleteComponent} from './project-folders-autocomplete.component';
|
||||
|
||||
describe('ProjectFoldersAutocompleteComponent', () => {
|
||||
let component: ProjectFoldersAutocompleteComponent;
|
||||
let fixture: ComponentFixture<ProjectFoldersAutocompleteComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ProjectFoldersAutocompleteComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(ProjectFoldersAutocompleteComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core';
|
||||
import {CommonModule, NgForOf, NgIf} from '@angular/common';
|
||||
import {FormBuilder, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {NzAutocompleteModule} from "ng-zorro-antd/auto-complete";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
import {NzInputModule} from "ng-zorro-antd/input";
|
||||
import {log_error} from "@shared/utils";
|
||||
import {ProjectFoldersApiService} from "@api/project-folders-api.service";
|
||||
import {IProjectFolder} from "@interfaces/project-folder";
|
||||
import {SearchByNamePipe} from "@pipes/search-by-name.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-project-folders-autocomplete',
|
||||
templateUrl: './project-folders-autocomplete.component.html',
|
||||
styleUrls: ['./project-folders-autocomplete.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
NzFormModule,
|
||||
NzAutocompleteModule,
|
||||
NzIconModule,
|
||||
NgIf,
|
||||
NgForOf,
|
||||
NzInputModule,
|
||||
SearchByNamePipe
|
||||
],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProjectFoldersAutocompleteComponent implements OnInit {
|
||||
form!: FormGroup;
|
||||
isNew = false;
|
||||
searchValue: string | null = null;
|
||||
folders: IProjectFolder[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly api: ProjectFoldersApiService,
|
||||
private readonly fb: FormBuilder,
|
||||
private readonly cdr: ChangeDetectorRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
folder: []
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const control = this.form.controls["folder"];
|
||||
control.valueChanges.subscribe((value) => {
|
||||
this.searchValue = value;
|
||||
if (value) {
|
||||
this.isNew = !this.folders.some((i) => i.name === value);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isNew = false;
|
||||
});
|
||||
await this.get();
|
||||
}
|
||||
|
||||
addFolder() {
|
||||
void this.create();
|
||||
}
|
||||
|
||||
private async get() {
|
||||
try {
|
||||
const res = await this.api.get();
|
||||
if (res.done) {
|
||||
this.folders = res.body || [];
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private async create() {
|
||||
if (!this.searchValue) return;
|
||||
try {
|
||||
const body = {
|
||||
name: this.searchValue
|
||||
};
|
||||
const res = await this.api.create(body);
|
||||
if (res.done) {
|
||||
void this.get();
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<nz-drawer
|
||||
(nzOnClose)="handleClose()"
|
||||
[nzClosable]="true"
|
||||
[nzTitle]="title"
|
||||
[nzVisible]="show"
|
||||
[nzPlacement]="'right'"
|
||||
(nzVisibleChange)="onVisibilityChange($event)"
|
||||
>
|
||||
<ng-container *nzDrawerContent>
|
||||
<nz-spin [nzSpinning]="isLoading()">
|
||||
<nz-alert *ngIf="isMember" class="mb-3" [nzType]="'warning'"
|
||||
[nzMessage]="'Members do not have permissions to change settings.'"></nz-alert>
|
||||
|
||||
<form [formGroup]="form" [nzLayout]="'vertical'" nz-form>
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null" nzRequired>Name</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null" nzErrorTip="Please enter a name!">
|
||||
<input #projectName [formControlName]="'name'" nz-input placeholder="Name"/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item *ngIf="projectId">
|
||||
<nz-form-label [nzSpan]="null" nzRequired>Key</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<input [formControlName]="'key'" [maxlength]="5" (keyup)="onKeyChange()" nz-input
|
||||
placeholder="Key"/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label class="pb-0" [nzSpan]="null" nzRequired>Project color
|
||||
<nz-tag [nzColor]="activeColorCode" nz-dropdown [nzDropdownMenu]="menu" [nzDisabled]="isMember"
|
||||
[nzTrigger]="'click'"
|
||||
class="ms-2 rounded-circle cursor-pointer"
|
||||
style="width: 20px;height: 20px;">
|
||||
</nz-tag>
|
||||
</nz-form-label>
|
||||
<nz-dropdown-menu #menu="nzDropdownMenu">
|
||||
<ul style="max-height: 200px;overflow: hidden;overflow-y: auto;" nz-menu nzSelectable>
|
||||
<li nz-menu-item *ngFor="let item of COLOR_CODES" (click)="setColorCode(item)">
|
||||
<nz-tag
|
||||
[nzColor]="item"
|
||||
class="me-1 w-100 rounded-pill"
|
||||
style="height: 16px !important;width: 16px !important;">
|
||||
</nz-tag>
|
||||
</li>
|
||||
</ul>
|
||||
</nz-dropdown-menu>
|
||||
<!-- <nz-form-control [nzSpan]="null">-->
|
||||
<!-- <nz-select [formControlName]="'color_code'">-->
|
||||
<!-- <nz-option *ngFor="let item of colorCodes" [nzLabel]="'Select a color'" [nzValue]="item" nzCustomContent>-->
|
||||
<!-- <nz-tag [nzColor]="item" class="me-1 w-100 rounded-pill" style="height: 16px;"> </nz-tag>-->
|
||||
<!-- </nz-option>-->
|
||||
<!-- </nz-select>-->
|
||||
<!-- </nz-form-control>-->
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">Status</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<nz-select [formControlName]="'status_id'" [nzLoading]="loadingProjStatuses">
|
||||
<nz-option *ngFor="let item of statuses" [nzLabel]="item.name | safeString" [nzValue]="item.id"
|
||||
nzCustomContent>
|
||||
<span nz-icon [nzType]="item.icon | safeString" [style.color]="item.color_code"></span>
|
||||
{{item.name}}
|
||||
</nz-option>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">Health</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<nz-select [formControlName]="'health_id'" [nzLoading]="loadingProjHealths">
|
||||
<nz-option *ngFor="let item of healths" [nzLabel]="item.name | safeString" [nzValue]="item.id"
|
||||
nzCustomContent>
|
||||
<nz-badge [nzColor]="item.color_code+'69'" [nzText]="item.name"></nz-badge>
|
||||
</nz-option>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<worklenz-project-categories-autocomplete
|
||||
[(categoryId)]="categoryId"
|
||||
[disabled]="isMember"
|
||||
></worklenz-project-categories-autocomplete>
|
||||
|
||||
<!-- <nz-form-item>-->
|
||||
<!-- <nz-form-label [nzSpan]="null">Folder</nz-form-label>-->
|
||||
<!-- <nz-form-control [nzSpan]="null">-->
|
||||
<!-- <nz-select [formControlName]="'folder_id'" [nzPlaceHolder]="'Select a folder for the project'"-->
|
||||
<!-- [nzLoading]="loadingFolders" nzAllowClear>-->
|
||||
<!-- <nz-option *ngFor="let item of folders" [nzLabel]="item.name | safeString" [nzValue]="item.id"-->
|
||||
<!-- nzCustomContent>-->
|
||||
<!-- {{item.name}}-->
|
||||
<!-- </nz-option>-->
|
||||
<!-- <nz-option [nzValue]="'add'" nzCustomContent>-->
|
||||
<!-- <button nz-button [nzType]="'dashed'" nzBlock [nzSize]="'small'" (click)="newFolder()">-->
|
||||
<!-- <span nz-icon [nzType]="'plus'" [nzTheme]="'outline'"></span> New folder-->
|
||||
<!-- </button>-->
|
||||
<!-- </nz-option>-->
|
||||
<!-- </nz-select>-->
|
||||
<!-- </nz-form-control>-->
|
||||
<!-- </nz-form-item>-->
|
||||
|
||||
<!-- <worklenz-project-folders-autocomplete></worklenz-project-folders-autocomplete>-->
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">Notes</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<textarea [formControlName]="'notes'" nz-input placeholder="Notes"></textarea>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<worklenz-clients-autocomplete
|
||||
*ngIf="!isMember && (!edit || !loading)"
|
||||
[name]="clientName"
|
||||
(nameChange)="onNameChangeSubmit($event)"
|
||||
></worklenz-clients-autocomplete>
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">
|
||||
Project Manager
|
||||
<div (nzVisibleChange)="handleOwnerVisibleChange($event)" [nzClickHide]="false"
|
||||
[nzDropdownMenu]="projectManagerDropDown"
|
||||
[nzTrigger]="'click'" [nzDisabled]="isMember || (!isOwnerOrAdmin() && isProjectManager())" nz-dropdown>
|
||||
<div class="d-flex h-100 align-items-center manager-input" #projectManagerSelector>
|
||||
<ng-container *ngIf="projectManager">
|
||||
<nz-avatar
|
||||
[nzSize]="24"
|
||||
[nzSrc]="projectManager.avatar_url"
|
||||
[nzText]="projectManager.name | firstCharUpper"
|
||||
[nzTooltipPlacement]="'top'"
|
||||
[nzTooltipTitle]="projectManager.name"
|
||||
[style.background-color]="projectManager.avatar_url ? '#ececec' : projectManager.color_code"
|
||||
class="mt-auto mb-auto mx-2"
|
||||
nz-tooltip></nz-avatar>
|
||||
<span nz-typography>{{projectManager.name}}</span>
|
||||
<span class="mx-2 remove-icon" (click)="handleMemberChange(null)" nz-typography>
|
||||
<span nz-icon nzType="close-circle" nzTheme="fill"></span>
|
||||
</span>
|
||||
</ng-container>
|
||||
<span *ngIf="!projectManager" nz-typography nzType="secondary">
|
||||
<nz-avatar [nzSize]="26" class="avatar-dashed mx-2 bg-white" [nzIcon]="'plus'"></nz-avatar>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nz-dropdown-menu #projectManagerDropDown="nzDropdownMenu">
|
||||
<ul *ngIf="teamMembers.length" class="members-dropdown pt-0"
|
||||
nz-menu>
|
||||
<li class="px-3 py-2 position-sticky top-0 bg-white z-top">
|
||||
<input class="border-default-color dropdown-search-input"
|
||||
nz-input placeholder="Search by name" type="text"
|
||||
(input)="getTeamMembers()"
|
||||
#memberSearchInput>
|
||||
</li>
|
||||
<li *ngFor="let item of teamMembers | searchByName: searchingName; trackBy: trackById"
|
||||
[nzDisabled]="item.pending_invitation"
|
||||
(click)="handleMemberChange(item)"
|
||||
class="m-0"
|
||||
nz-menu-item>
|
||||
<div class="d-flex align-items-center justify-content-center user-select-none">
|
||||
<nz-avatar
|
||||
[nzSize]="24"
|
||||
[nzSrc]="item.avatar_url"
|
||||
[nzText]="item.name | firstCharUpper"
|
||||
[nzTooltipPlacement]="'top'"
|
||||
[nzTooltipTitle]="item.name"
|
||||
[style.background-color]="item.avatar_url ? '#ececec' : item.color_code"
|
||||
class="mt-auto mb-auto me-2"
|
||||
nz-tooltip></nz-avatar>
|
||||
<div style="line-height: 15px;">
|
||||
<span class="d-block mem-name" nz-typography>{{item.name}}</span>
|
||||
<small nz-typography nzType="secondary">
|
||||
{{item.email}} <small *ngIf="item.pending_invitation" nz-typography nzType="danger">(Pending
|
||||
Invitation)</small>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nz-dropdown-menu>
|
||||
</div>
|
||||
</nz-form-label>
|
||||
</nz-form-item>
|
||||
|
||||
<div #outsideClicker></div>
|
||||
|
||||
<nz-input-group [nzSize]="'default'">
|
||||
<div nz-row [nzGutter]="8">
|
||||
<div nz-col [nzSpan]="12">
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">Start date</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<nz-date-picker
|
||||
[formControlName]="'start_date'" [nzDisabledDate]="utils.checkForMaxDate(endDate)"
|
||||
class="w-100" (nzOnOpenChange)="calculateManDays()"></nz-date-picker>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</div>
|
||||
<div nz-col [nzSpan]="12">
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null">End date</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null">
|
||||
<nz-date-picker
|
||||
[formControlName]="'end_date'"
|
||||
[nzDisabledDate]="utils.checkForMinDate(startDate)"
|
||||
class="w-100" (nzOnOpenChange)="calculateManDays()"></nz-date-picker>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</div>
|
||||
<div nz-col [nzSpan]="24">
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null" nzRequired class="star-none">Estimated working days</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null" nzErrorTip="Field cannot be empty!">
|
||||
<nz-input-number [formControlName]="'working_days'" [nzMin]="0" [maxlength]="5" nz-input
|
||||
placeholder="Estimated Working Days" class="w-100"></nz-input-number>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</div>
|
||||
<div nz-col [nzSpan]="24">
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null" nzRequired class="star-none">Estimated man days</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null" nzErrorTip="Field cannot be empty!">
|
||||
<nz-input-number [formControlName]="'man_days'" [nzMin]="0" [maxlength]="10" nz-input
|
||||
placeholder="Estimated Man Days" class="w-100"></nz-input-number>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</div>
|
||||
<div nz-col [nzSpan]="24">
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="null" nzRequired class="star-none">Hours per day</nz-form-label>
|
||||
<nz-form-control [nzSpan]="null" nzErrorTip="Field cannot be empty!">
|
||||
<nz-input-number [formControlName]="'hours_per_day'" [nzMin]="0" [nzMax]="24" [maxlength]="2" nz-input
|
||||
placeholder="Hours Per Day" class="w-100"></nz-input-number>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</nz-input-group>
|
||||
|
||||
<ng-container *ngIf="!isMember">
|
||||
<button [nzLoading]="updatingProject" nz-button nzBlock [nzType]="'primary'"
|
||||
(click)="submit()"
|
||||
type="button">{{submitButtonText}}</button>
|
||||
<button (nzOnConfirm)="delete()" *ngIf="projectId" [nzLoading]="deletingProject" class="mt-2"
|
||||
nz-button nz-popconfirm nzDanger
|
||||
nzBlock [nzOkText]="'ok'" [nzPopconfirmTitle]="'Are you sure?'" [nzType]="'dashed'" type="button">
|
||||
Delete Project
|
||||
</button>
|
||||
</ng-container>
|
||||
</form>
|
||||
|
||||
<ng-container *ngIf="projectId">
|
||||
<nz-divider class="mt-3 mb-2"></nz-divider>
|
||||
<div class="pb-1">
|
||||
<div *ngIf="model.created_at" class="mb-0" nz-typography
|
||||
nzType="secondary">
|
||||
<small nz-tooltip [nzTooltipPlacement]="'right'"
|
||||
[nzTooltipTitle]="model.created_at | date: 'medium'">
|
||||
Created {{model.created_at | fromNow}} by {{model.project_owner}}
|
||||
</small>
|
||||
</div>
|
||||
<div *ngIf="model.updated_at" class="mb-0"
|
||||
nz-typography
|
||||
nzType="secondary">
|
||||
<small nz-tooltip [nzTooltipPlacement]="'right'"
|
||||
[nzTooltipTitle]="model.updated_at | date: 'medium'">
|
||||
Updated {{model.updated_at | fromNow}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</nz-spin>
|
||||
</ng-container>
|
||||
</nz-drawer>
|
||||
|
||||
<worklenz-team-members-form (onCreateOrUpdate)="getTeamMembers()"
|
||||
[(show)]="showTeamMemberModal"></worklenz-team-members-form>
|
||||
@@ -0,0 +1,36 @@
|
||||
.remove-icon {
|
||||
color: #00000040;
|
||||
background: #fff;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
transition: 0.25s all;
|
||||
&:hover {
|
||||
color: #00000073;
|
||||
}
|
||||
}
|
||||
|
||||
.manager-input {
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 6px;
|
||||
transition: 0.25s all;
|
||||
margin-left: 8px;
|
||||
min-height: 32px;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
& .remove-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.z-top {
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ProjectFormModalComponent} from './project-form-modal.component';
|
||||
|
||||
describe('ProjectFormModalComponent', () => {
|
||||
let component: ProjectFormModalComponent;
|
||||
let fixture: ComponentFixture<ProjectFormModalComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ProjectFormModalComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProjectFormModalComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
NgZone,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {ProjectsDefaultColorCodes} from "@shared/constants";
|
||||
import {ITeamMemberViewModel} from "@interfaces/api-models/team-members-get-response";
|
||||
import {ITeamMember} from "@interfaces/team-member";
|
||||
import {ITask} from "@interfaces/task";
|
||||
import {ProjectsApiService} from "@api/projects-api.service";
|
||||
import {AppService} from "@services/app.service";
|
||||
import {TeamMembersApiService} from "@api/team-members-api.service";
|
||||
import {IProject} from "@interfaces/project";
|
||||
import {dispatchProjectChange} from "@shared/events";
|
||||
import {UtilsService} from "@services/utils.service";
|
||||
import {log_error} from "@shared/utils";
|
||||
import {ProjectStatusesApiService} from "@api/project-statuses-api.service";
|
||||
import {IProjectStatus} from "@interfaces/project-status";
|
||||
import {NzSelectModule} from "ng-zorro-antd/select";
|
||||
import {NzDrawerModule} from "ng-zorro-antd/drawer";
|
||||
import {NzTagModule} from "ng-zorro-antd/tag";
|
||||
import {NzSpinModule} from "ng-zorro-antd/spin";
|
||||
import {ClientsAutocompleteComponent} from "../clients-autocomplete/clients-autocomplete.component";
|
||||
import {NzInputModule} from "ng-zorro-antd/input";
|
||||
import {NzButtonModule} from "ng-zorro-antd/button";
|
||||
import {DatePipe, NgForOf, NgIf} from "@angular/common";
|
||||
import {NzPopconfirmModule} from "ng-zorro-antd/popconfirm";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
import {TeamMembersFormComponent} from "../team-members-form/team-members-form.component";
|
||||
import {NzDatePickerModule} from "ng-zorro-antd/date-picker";
|
||||
import {NzDropDownModule} from "ng-zorro-antd/dropdown";
|
||||
import {SafeStringPipe} from "@pipes/safe-string.pipe";
|
||||
import {FromNowPipe} from "@pipes/from-now.pipe";
|
||||
import {NzToolTipModule} from "ng-zorro-antd/tooltip";
|
||||
import {NzTypographyModule} from "ng-zorro-antd/typography";
|
||||
import {IProjectViewModel} from "@interfaces/api-models/project-view-model";
|
||||
import {NzDividerModule} from "ng-zorro-antd/divider";
|
||||
import {AuthService} from "@services/auth.service";
|
||||
import {NzAlertModule} from "ng-zorro-antd/alert";
|
||||
import {ProjectFoldersApiService} from "@api/project-folders-api.service";
|
||||
import {IProjectFolder} from "@interfaces/project-folder";
|
||||
import {
|
||||
ProjectFoldersAutocompleteComponent
|
||||
} from "@admin/components/project-folders-autocomplete/project-folders-autocomplete.component";
|
||||
import {
|
||||
ProjectsFolderFormDrawerService
|
||||
} from "../../projects/projects/projects-folder-form-drawer/projects-folder-form-drawer.service";
|
||||
import {
|
||||
ProjectCategoriesAutocompleteComponent
|
||||
} from "@admin/components/project-categories-autocomplete/project-categories-autocomplete.component";
|
||||
import {ProjectFormService} from "@services/project-form-service.service";
|
||||
import {NzBadgeModule} from "ng-zorro-antd/badge";
|
||||
import {IProjectHealth} from "@interfaces/project-health";
|
||||
import {ProjectHealthsApiService} from "@api/project-healths-api.service";
|
||||
import moment from "moment";
|
||||
import {NzInputNumberModule} from "ng-zorro-antd/input-number";
|
||||
import {AvatarsComponent} from "@admin/components/avatars/avatars.component";
|
||||
import {FirstCharUpperPipe} from "@pipes/first-char-upper.pipe";
|
||||
import {NzAvatarModule} from "ng-zorro-antd/avatar";
|
||||
import {NzCheckboxModule} from "ng-zorro-antd/checkbox";
|
||||
import {SearchByNamePipe} from "@pipes/search-by-name.pipe";
|
||||
import {ProjectsService} from "../../projects/projects.service";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-project-form-modal',
|
||||
templateUrl: './project-form-modal.component.html',
|
||||
styleUrls: ['./project-form-modal.component.scss'],
|
||||
imports: [
|
||||
NzSelectModule,
|
||||
NzDrawerModule,
|
||||
NzTagModule,
|
||||
NzSpinModule,
|
||||
ClientsAutocompleteComponent,
|
||||
ReactiveFormsModule,
|
||||
NzInputModule,
|
||||
NzButtonModule,
|
||||
NgIf,
|
||||
NzPopconfirmModule,
|
||||
NzFormModule,
|
||||
NgForOf,
|
||||
NzIconModule,
|
||||
TeamMembersFormComponent,
|
||||
NzDatePickerModule,
|
||||
NzDropDownModule,
|
||||
SafeStringPipe,
|
||||
DatePipe,
|
||||
FromNowPipe,
|
||||
NzToolTipModule,
|
||||
NzTypographyModule,
|
||||
NzDividerModule,
|
||||
NzAlertModule,
|
||||
ProjectFoldersAutocompleteComponent,
|
||||
ProjectCategoriesAutocompleteComponent,
|
||||
NzBadgeModule,
|
||||
NzInputNumberModule,
|
||||
AvatarsComponent,
|
||||
FirstCharUpperPipe,
|
||||
NzAvatarModule,
|
||||
NzCheckboxModule,
|
||||
SearchByNamePipe,
|
||||
FormsModule
|
||||
],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProjectFormModalComponent {
|
||||
@ViewChild('projectName', {static: false}) projectName!: ElementRef;
|
||||
@ViewChild('memberSearchInput', {static: false}) memberSearchInput!: ElementRef;
|
||||
@ViewChild('projectManagerSelector', {static: false}) projectManagerSelector!: ElementRef;
|
||||
@ViewChild('outsideClicker', {static: false}) outsideClicker!: ElementRef;
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
@Output() onCreate = new EventEmitter<IProject | null>();
|
||||
@Output() onUpdate = new EventEmitter<IProject | null>();
|
||||
@Output() onProjectManagerUpdate = new EventEmitter<void>();
|
||||
@Output() onDelete = new EventEmitter();
|
||||
|
||||
readonly COLOR_CODES = ProjectsDefaultColorCodes;
|
||||
|
||||
show = false;
|
||||
edit = false;
|
||||
loading = false;
|
||||
searching = false;
|
||||
showTeamMemberModal = false;
|
||||
updatingProject = false;
|
||||
deletingProject = false;
|
||||
loadingTeamMembers = false;
|
||||
loadingProjStatuses = false;
|
||||
loadingProjHealths = false;
|
||||
isMember = false;
|
||||
isManager = false;
|
||||
loadingFolders = false;
|
||||
|
||||
clientName: string | null = null;
|
||||
projectManager: ITeamMemberViewModel | null = null;
|
||||
projectId: string | null = null;
|
||||
|
||||
teamMembers: ITeamMemberViewModel[] = [];
|
||||
projectMembers: ITeamMemberViewModel[] = [];
|
||||
removedMembersList: ITeamMember[] = [];
|
||||
removedTasks: ITask[] = [];
|
||||
newTasks: ITask[] = [];
|
||||
statuses: IProjectStatus[] = [];
|
||||
folders: IProjectFolder[] = [];
|
||||
healths: IProjectHealth[] = [];
|
||||
|
||||
public searchingName: string | null = null;
|
||||
model: IProjectViewModel = {};
|
||||
|
||||
get categoryId() {
|
||||
return this.form.controls["category_id"].value || null;
|
||||
}
|
||||
|
||||
set categoryId(value: string | null) {
|
||||
this.form.controls["category_id"].setValue(value);
|
||||
}
|
||||
|
||||
get startDate() {
|
||||
return this.form.value.start_date || null;
|
||||
}
|
||||
|
||||
get endDate() {
|
||||
return this.form.value.end_date || null;
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.projectId ? "Update Project" : "Create Project";
|
||||
}
|
||||
|
||||
get submitButtonText() {
|
||||
return this.projectId ? "Save Changes" : "Create";
|
||||
}
|
||||
|
||||
get activeColorCode() {
|
||||
return this.form.controls['color_code'].value;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly api: ProjectsApiService,
|
||||
private readonly fb: FormBuilder,
|
||||
private readonly membersApi: TeamMembersApiService,
|
||||
private readonly app: AppService,
|
||||
private readonly statusesApi: ProjectStatusesApiService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly ngZone: NgZone,
|
||||
private readonly foldersApi: ProjectFoldersApiService,
|
||||
private readonly folderFormService: ProjectsFolderFormDrawerService,
|
||||
private readonly cdr: ChangeDetectorRef,
|
||||
public readonly utils: UtilsService,
|
||||
private readonly projectFormService: ProjectFormService,
|
||||
private readonly healthsApi: ProjectHealthsApiService,
|
||||
private readonly projectsService: ProjectsService
|
||||
) {
|
||||
this.createForm();
|
||||
}
|
||||
|
||||
private createForm() {
|
||||
this.form = this.fb.group({
|
||||
name: [null, [Validators.required]],
|
||||
key: [null, [Validators.max(5)]],
|
||||
notes: [null, []],
|
||||
start_date: [],
|
||||
project_manager: [null, []],
|
||||
end_date: [],
|
||||
status_id: [],
|
||||
health_id: [],
|
||||
folder_id: [],
|
||||
category_id: [],
|
||||
color_code: [ProjectsDefaultColorCodes[1], [Validators.required]],
|
||||
working_days: [0, [Validators.required]],
|
||||
man_days: [0, [Validators.required]],
|
||||
hours_per_day: [8, [Validators.required]],
|
||||
// Internal use
|
||||
_select_team_member_input: [null, []]
|
||||
});
|
||||
|
||||
if (this.isMember) {
|
||||
this.form.disable();
|
||||
}
|
||||
|
||||
this.form.controls["_select_team_member_input"]
|
||||
.valueChanges.subscribe((value) => {
|
||||
this.searchingName = value;
|
||||
void this.searchMembers();
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.clientName = null;
|
||||
this.projectId = null;
|
||||
this.projectManager = null;
|
||||
this.teamMembers = [];
|
||||
this.projectMembers = [];
|
||||
this.removedMembersList = [];
|
||||
this.removedTasks = [];
|
||||
this.newTasks = [];
|
||||
this.deletingProject = false;
|
||||
this.updatingProject = false;
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.reset();
|
||||
this.show = false;
|
||||
}
|
||||
|
||||
isOwnerOrAdmin() {
|
||||
return this.auth.getCurrentSession()?.owner || this.auth.getCurrentSession()?.is_admin;
|
||||
}
|
||||
|
||||
isProjectManager() {
|
||||
if (this.projectsService.projectOwnerTeamMemberId) return this.auth.getCurrentSession()?.team_member_id === this.projectsService.projectOwnerTeamMemberId;
|
||||
return false;
|
||||
}
|
||||
|
||||
public async open(id?: string, edit = false) {
|
||||
this.isMember = !this.isOwnerOrAdmin() && !this.isProjectManager();
|
||||
|
||||
this.show = true;
|
||||
this.edit = edit;
|
||||
this.createForm();
|
||||
|
||||
void this.getProjectStatuses();
|
||||
void this.getProjectHealths();
|
||||
|
||||
if (id) {
|
||||
this.projectId = id;
|
||||
void this.get(this.projectId);
|
||||
}
|
||||
|
||||
void this.getTeamMembers();
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.loadingTeamMembers;
|
||||
}
|
||||
|
||||
async getFolders() {
|
||||
try {
|
||||
this.loadingFolders = true;
|
||||
const res = await this.foldersApi.get();
|
||||
if (res.done) {
|
||||
this.folders = res.body;
|
||||
}
|
||||
this.loadingFolders = false;
|
||||
} catch (e) {
|
||||
this.loadingFolders = false;
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async getTeamMembers() {
|
||||
try {
|
||||
this.loadingTeamMembers = true;
|
||||
const res = await this.membersApi.get(1, 5, null, null, this.memberSearchInput ? this.memberSearchInput.nativeElement.value : null, true);
|
||||
if (res.done) {
|
||||
this.teamMembers = res.body.data || [];
|
||||
this.teamMembers = this.teamMembers.filter(m => m.active);
|
||||
this.teamMembers.sort((a, b) => {
|
||||
return Number(a.pending_invitation) - Number(b.pending_invitation);
|
||||
});
|
||||
}
|
||||
this.loadingTeamMembers = false;
|
||||
} catch (e) {
|
||||
this.loadingTeamMembers = false;
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async getProjectStatuses() {
|
||||
try {
|
||||
this.loadingProjStatuses = true;
|
||||
const res = await this.statusesApi.get();
|
||||
if (res.done) {
|
||||
this.statuses = res.body;
|
||||
const defaultStatus = this.statuses.find(s => s.is_default);
|
||||
// Set default status in create mode
|
||||
if (!this.projectId && defaultStatus && defaultStatus.id)
|
||||
this.form.controls["status_id"].setValue(defaultStatus.id);
|
||||
}
|
||||
this.loadingProjStatuses = false;
|
||||
} catch (e) {
|
||||
this.loadingProjStatuses = false;
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async getProjectHealths() {
|
||||
try {
|
||||
this.loadingProjHealths = true;
|
||||
const res = await this.healthsApi.get();
|
||||
if (res) {
|
||||
this.healths = res.body;
|
||||
const defaultHealth = this.healths.find(s => s.is_default);
|
||||
// Set default health in create mode
|
||||
if (!this.projectId && defaultHealth && defaultHealth.id)
|
||||
this.form.controls["health_id"].setValue(defaultHealth.id);
|
||||
}
|
||||
this.loadingProjHealths = false;
|
||||
} catch (e) {
|
||||
this.loadingProjHealths = false;
|
||||
log_error(e);
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async delete() {
|
||||
if (!this.projectId) return;
|
||||
try {
|
||||
this.deletingProject = true;
|
||||
const res = await this.api.delete(this.projectId);
|
||||
if (res.done) {
|
||||
this.handleClose();
|
||||
this.onDelete?.emit();
|
||||
}
|
||||
this.deletingProject = false;
|
||||
} catch (e) {
|
||||
this.deletingProject = false;
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async create() {
|
||||
try {
|
||||
this.updatingProject = true;
|
||||
const body = {
|
||||
name: this.form.controls['name'].value,
|
||||
client_name: this.clientName,
|
||||
notes: this.form.controls['notes'].value,
|
||||
project_manager: this.projectManager,
|
||||
color_code: this.form.controls['color_code'].value,
|
||||
status_id: this.form.controls['status_id'].value,
|
||||
health_id: this.form.controls['health_id'].value,
|
||||
start_date: this.form.controls["start_date"].value,
|
||||
end_date: this.form.controls["end_date"].value,
|
||||
folder_id: this.form.controls["folder_id"].value,
|
||||
category_id: this.form.controls["category_id"].value,
|
||||
working_days: this.form.controls["working_days"].value,
|
||||
man_days: this.form.controls["man_days"].value,
|
||||
hours_per_day: this.form.controls["hours_per_day"].value
|
||||
};
|
||||
const res = await this.api.create(body);
|
||||
if (res.done) {
|
||||
this.handleClose();
|
||||
this.onCreate.emit(res.body);
|
||||
dispatchProjectChange();
|
||||
}
|
||||
this.updatingProject = false;
|
||||
} catch (e) {
|
||||
this.updatingProject = false;
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async update(id: string) {
|
||||
try {
|
||||
this.updatingProject = true;
|
||||
const body = {
|
||||
name: this.form.controls['name'].value,
|
||||
client_name: this.clientName,
|
||||
notes: this.form.controls['notes'].value,
|
||||
project_manager: this.projectManager,
|
||||
key: this.form.controls['key'].value,
|
||||
color_code: this.form.controls['color_code'].value,
|
||||
status_id: this.form.controls['status_id'].value,
|
||||
health_id: this.form.controls['health_id'].value,
|
||||
start_date: this.form.controls["start_date"].value,
|
||||
end_date: this.form.controls["end_date"].value,
|
||||
folder_id: this.form.controls["folder_id"].value,
|
||||
category_id: this.form.controls["category_id"].value,
|
||||
working_days: this.form.controls["working_days"].value,
|
||||
man_days: this.form.controls["man_days"].value,
|
||||
hours_per_day: this.form.controls["hours_per_day"].value
|
||||
};
|
||||
|
||||
const res = await this.api.update(id, body);
|
||||
if (res.done) {
|
||||
this.handleClose();
|
||||
this.onUpdate.emit(res.body);
|
||||
dispatchProjectChange();
|
||||
this.projectFormService.emitProjectUpdate();
|
||||
return true;
|
||||
}
|
||||
this.updatingProject = false;
|
||||
} catch (e) {
|
||||
this.updatingProject = false;
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
return false;
|
||||
}
|
||||
|
||||
async get(id: string | undefined) {
|
||||
if (!id) return;
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await this.api.getById(id);
|
||||
if (res.done) {
|
||||
this.model = res.body;
|
||||
this.form.patchValue(this.model);
|
||||
this.clientName = res.body.client_name as string;
|
||||
this.projectManager = this.model.project_manager ? this.model.project_manager : null;
|
||||
this.projectMembers = this.model.members ?? [];
|
||||
this.newTasks = this.model.tasks ?? [];
|
||||
this.categoryId = this.model.category_id || null;
|
||||
}
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
this.loading = false;
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (this.isMember) return;
|
||||
if (this.form.valid) {
|
||||
if (this.projectId) {
|
||||
const updated = await this.update(this.projectId);
|
||||
// if (updated)
|
||||
// window.location.reload();
|
||||
} else {
|
||||
void this.create();
|
||||
}
|
||||
} else {
|
||||
this.app.displayErrorsOf(this.form);
|
||||
}
|
||||
}
|
||||
|
||||
async searchMembers() {
|
||||
this.searching = true;
|
||||
await this.getTeamMembers();
|
||||
this.searching = false;
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
onVisibilityChange(visible: boolean) {
|
||||
if (visible) {
|
||||
void this.getFolders();
|
||||
if (this.isMember) return;
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
setTimeout(() => {
|
||||
const element = this.projectName.nativeElement as HTMLInputElement;
|
||||
if (element)
|
||||
element.focus();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleOwnerVisibleChange(visible: boolean) {
|
||||
if (visible) {
|
||||
try {
|
||||
setTimeout(() => {
|
||||
this.projectManagerSelector.nativeElement.classList.add('highlight');
|
||||
}, 100)
|
||||
this.cdr.markForCheck();
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
} else {
|
||||
this.projectManagerSelector.nativeElement.classList.remove('highlight');
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
setColorCode(colorCode: string) {
|
||||
this.form.controls["color_code"].setValue(colorCode);
|
||||
}
|
||||
|
||||
onNameChangeSubmit(name: string) {
|
||||
this.clientName = name || null;
|
||||
}
|
||||
|
||||
onKeyChange() {
|
||||
const value = this.form.controls["key"].value;
|
||||
if (value)
|
||||
this.form.controls["key"].setValue(value.toUpperCase());
|
||||
}
|
||||
|
||||
newFolder() {
|
||||
this.folderFormService.create((folder?: IProjectFolder) => {
|
||||
if (folder) {
|
||||
this.updateFolders(folder);
|
||||
this.form.controls["folder_id"]?.setValue(folder.id);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateFolders(folder: IProjectFolder) {
|
||||
const folders = [...this.folders];
|
||||
folders.push(folder);
|
||||
folders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
this.folders = folders;
|
||||
}
|
||||
|
||||
calculateManDays() {
|
||||
const start = this.form.controls["start_date"].value;
|
||||
const end = this.form.controls["end_date"].value;
|
||||
if (start && end) {
|
||||
const s = moment(start);
|
||||
const e = moment(end);
|
||||
let days = e.diff(s, "days") + 1;
|
||||
if (e.isoWeekday() > 5) days -= e.isoWeekday() % 5;
|
||||
if (s.isoWeekday() > 5) days -= (3 - (s.isoWeekday() % 5));
|
||||
if (days > 5) {
|
||||
const weeks = (days - (days % 7)) / 7;
|
||||
days -= (weeks * 2);
|
||||
}
|
||||
this.form.controls["working_days"].setValue(days);
|
||||
}
|
||||
}
|
||||
|
||||
trackById(index: number, item: ITeamMemberViewModel) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
handleMemberChange(item: ITeamMemberViewModel | null) {
|
||||
if (item?.pending_invitation || this.isMember || (!this.isOwnerOrAdmin() && this.isProjectManager())) return;
|
||||
this.projectManager = item;
|
||||
this.focusOut();
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
focusOut() {
|
||||
setTimeout(() => {
|
||||
this.outsideClicker.nativeElement.click();
|
||||
}, 50)
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<nz-drawer
|
||||
(nzOnClose)="closeModal()"
|
||||
[(nzVisible)]="show"
|
||||
(nzVisibleChange)="onVisibleChange($event)"
|
||||
[nzClosable]="true"
|
||||
[nzPlacement]="'right'"
|
||||
[nzTitle]="'Project Members'"
|
||||
>
|
||||
<ng-container *nzDrawerContent>
|
||||
<nz-spin [nzSpinning]="false">
|
||||
<form [nzLayout]="'vertical'" nz-form>
|
||||
<worklenz-team-members-autocomplete
|
||||
(membersChange)="membersChange($event)"
|
||||
(refresh)="getMembers()"
|
||||
[label]="'Add members by adding their name or email'"
|
||||
[placeholder]="'Type name or email...'"
|
||||
[autofocus]="autofocus"
|
||||
[disableTeamInvites]="adminAndManager()"
|
||||
></worklenz-team-members-autocomplete>
|
||||
</form>
|
||||
<nz-list *ngIf="members.length" class="scrollable-list mb-4" nzBordered nzSize="small">
|
||||
<nz-list-item *ngFor="let member of members; trackBy: trackById" class="default-list-item-p-11">
|
||||
<div class="d-flex align-items-center user-select-none">
|
||||
<nz-avatar
|
||||
nz-tooltip
|
||||
[nzSize]="28"
|
||||
[nzText]="member.name | firstCharUpper"
|
||||
[nzTooltipTitle]="member.name | safeString"
|
||||
[style.background-color]="member.avatar_url ? '#ececec' : member.color_code"
|
||||
[nzSrc]="member.avatar_url"
|
||||
[nzTooltipPlacement]="'top'"
|
||||
class="mt-auto mb-auto me-2"
|
||||
></nz-avatar>
|
||||
<div style="line-height: 15px;">
|
||||
<span class="d-block" nz-typography>{{member.name}}</span>
|
||||
<small nz-typography nzType="secondary">{{member.email}}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
(nzOnConfirm)="removeMember(member.id)"
|
||||
nz-button
|
||||
nz-popconfirm
|
||||
nz-tooltip
|
||||
[nzOkText]="'OK'"
|
||||
[nzPopconfirmTitle]="'Member will also removes from assigned tasks.'"
|
||||
[nzSize]="'small'"
|
||||
[nzTooltipPlacement]="'top'"
|
||||
[nzTooltipTitle]="'Remove from project'"
|
||||
[nzType]="'default'"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
</button>
|
||||
</nz-list-item>
|
||||
</nz-list>
|
||||
</nz-spin>
|
||||
</ng-container>
|
||||
</nz-drawer>
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ProjectMembersFormComponent} from './project-members-form.component';
|
||||
|
||||
describe('ProjectMembersFormComponent', () => {
|
||||
let component: ProjectMembersFormComponent;
|
||||
let fixture: ComponentFixture<ProjectMembersFormComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ProjectMembersFormComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProjectMembersFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import {ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
|
||||
import {ProjectMembersApiService} from "@api/project-members-api.service";
|
||||
import {IProjectMemberViewModel} from "@interfaces/task-form-view-model";
|
||||
import {TeamMembersAutocompleteComponent} from "../team-members-autocomplete/team-members-autocomplete.component";
|
||||
import {log_error} from "@shared/utils";
|
||||
import {NzSpinModule} from "ng-zorro-antd/spin";
|
||||
import {NzListModule} from "ng-zorro-antd/list";
|
||||
import {NzDrawerModule} from "ng-zorro-antd/drawer";
|
||||
import {NgForOf, NgIf} from "@angular/common";
|
||||
import {NzToolTipModule} from "ng-zorro-antd/tooltip";
|
||||
import {NzTypographyModule} from "ng-zorro-antd/typography";
|
||||
import {NzPopconfirmModule} from "ng-zorro-antd/popconfirm";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {NzButtonModule} from "ng-zorro-antd/button";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
import {NzAvatarModule} from "ng-zorro-antd/avatar";
|
||||
import {FirstCharUpperPipe} from "@pipes/first-char-upper.pipe";
|
||||
import {TaskListV2Service} from "../../modules/task-list-v2/task-list-v2.service";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {SafeStringPipe} from "@pipes/safe-string.pipe";
|
||||
import {ProjectFormService} from "@services/project-form-service.service";
|
||||
import {AuthService} from "@services/auth.service";
|
||||
import {ProjectsService} from "../../projects/projects.service";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-project-members-form',
|
||||
templateUrl: './project-members-form.component.html',
|
||||
styleUrls: ['./project-members-form.component.scss'],
|
||||
imports: [
|
||||
NzSpinModule,
|
||||
NzListModule,
|
||||
NzDrawerModule,
|
||||
NgIf,
|
||||
NzToolTipModule,
|
||||
NzTypographyModule,
|
||||
NgForOf,
|
||||
NzPopconfirmModule,
|
||||
NzFormModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
TeamMembersAutocompleteComponent,
|
||||
NzAvatarModule,
|
||||
FirstCharUpperPipe,
|
||||
SafeStringPipe
|
||||
],
|
||||
standalone: true
|
||||
})
|
||||
export class ProjectMembersFormComponent {
|
||||
@ViewChild(TeamMembersAutocompleteComponent) teamMembersAutocomplete!: TeamMembersAutocompleteComponent;
|
||||
|
||||
@Input() show = false;
|
||||
@Output() showChange = new EventEmitter<boolean>();
|
||||
@Output() onUpdate = new EventEmitter();
|
||||
|
||||
@Input() projectId: string | null = null;
|
||||
@Input() isProjectManager = false;
|
||||
|
||||
loading = false;
|
||||
autofocus = false;
|
||||
|
||||
members: IProjectMemberViewModel[] = [];
|
||||
isProjectManagerAndAdmin = false;
|
||||
|
||||
constructor(
|
||||
private readonly api: ProjectMembersApiService,
|
||||
private readonly list: TaskListV2Service,
|
||||
private readonly projectFormService: ProjectFormService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private readonly auth: AuthService
|
||||
) {
|
||||
this.list.onInviteClick$
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(() => {
|
||||
this.autofocus = true;
|
||||
this.show = true;
|
||||
this.showChange.emit(true);
|
||||
});
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.projectFormService.emitMemberAssignOrRemoveReProject();
|
||||
this.autofocus = false;
|
||||
this.show = false;
|
||||
this.showChange.emit(false);
|
||||
}
|
||||
|
||||
adminAndManager() {
|
||||
if(this.isProjectManager && this.auth.isOwnerOrAdmin()) {
|
||||
return this.isProjectManagerAndAdmin = false
|
||||
}
|
||||
if(this.isProjectManager && !this.auth.isOwnerOrAdmin()) {
|
||||
return this.isProjectManagerAndAdmin = true
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async getMembers() {
|
||||
if (!this.projectId) return;
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await this.api.getByProjectId(this.projectId);
|
||||
if (res.done) {
|
||||
this.members = res.body;
|
||||
}
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
this.loading = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async addMember(teamMemberId: string) {
|
||||
if (!teamMemberId || !this.projectId) return;
|
||||
try {
|
||||
this.teamMembersAutocomplete.reset();
|
||||
const res = await this.api.create({team_member_id: teamMemberId, project_id: this.projectId});
|
||||
if (res.done) {
|
||||
this.onUpdate?.emit();
|
||||
void this.getMembers();
|
||||
this.projectFormService.emitMemberAssignOrRemoveReProject();
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
onVisibleChange(visible: boolean) {
|
||||
if (visible) {
|
||||
void this.getMembers();
|
||||
}
|
||||
}
|
||||
|
||||
membersChange(memberId: string | string[]) {
|
||||
if (Array.isArray(memberId) && !memberId.length) return;
|
||||
const id = (Array.isArray(memberId) && memberId.length) ? memberId[0] : memberId;
|
||||
if (id) {
|
||||
void this.addMember(id as string);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
async removeMember(id?: string) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await this.api.deleteById(id, this.projectId as string);
|
||||
if (res.done) {
|
||||
this.onUpdate?.emit();
|
||||
await this.getMembers();
|
||||
this.projectFormService.emitMemberAssignOrRemoveReProject();
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
trackById(index: number, item: IProjectMemberViewModel) {
|
||||
return item.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<nz-drawer
|
||||
[nzClosable]="true"
|
||||
[nzVisible]="show"
|
||||
nzPlacement="right"
|
||||
[nzWidth]="420"
|
||||
[nzTitle]="'Save as template'"
|
||||
[nzFooter]="footer"
|
||||
(nzOnClose)="close()"
|
||||
>
|
||||
<div *nzDrawerContent>
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="24" nzRequired>Template name</nz-form-label>
|
||||
<nz-form-control [nzSpan]="24">
|
||||
<input nz-input [placeholder]="'Enter template name'" class="w-100"
|
||||
[ngClass]="showErrorText ? 'error-input' : ''" [(ngModel)]="templateName"
|
||||
(ngModelChange)="nameChange($event)" #templateNameInput>
|
||||
<span *ngIf="showErrorText" class="empty-error-text">Name cannot be empty!</span>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<div class="ant-form-item">
|
||||
<span nz-typography>What should be included in the template from the project ?</span>
|
||||
<div class="ms-2">
|
||||
<div class="my-2">
|
||||
<label nz-checkbox [nzChecked]="pStatusCheck" [(ngModel)]="pStatusCheck" [nzDisabled]="checked"
|
||||
(ngModelChange)="projectCheckChange('pStatuses', $event)">Statuses</label>
|
||||
</div>
|
||||
<div>
|
||||
<label nz-checkbox [nzChecked]="pPhasesCheck" [(ngModel)]="pPhasesCheck"
|
||||
(ngModelChange)="projectCheckChange('pPhases', $event)">Phases</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label nz-checkbox [nzChecked]="pLabelsCheck" [(ngModel)]="pLabelsCheck"
|
||||
(ngModelChange)="projectCheckChange('pLabels', $event)">Labels</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span nz-typography>What should be included in tasks ?</span>
|
||||
<div class="ms-2">
|
||||
<div class="my-2">
|
||||
<label nz-checkbox [nzChecked]="true" [(ngModel)]="checked" [nzDisabled]="checked">Name</label>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label nz-checkbox [nzChecked]="true" [(ngModel)]="checked" [nzDisabled]="checked">Priority</label>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<ng-container *ngIf="pStatusCheck">
|
||||
<label nz-checkbox [nzChecked]="tStatusCheck" [(ngModel)]="tStatusCheck"
|
||||
[nzDisabled]="checked">Status</label>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<ng-container *ngIf="pPhasesCheck">
|
||||
<label nz-checkbox [nzChecked]="tPhaseCheck" [(ngModel)]="tPhaseCheck">Phase</label>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<ng-container *ngIf="pLabelsCheck">
|
||||
<label nz-checkbox [nzChecked]="tLabelsCheck" [(ngModel)]="tLabelsCheck">Labels</label>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<ng-container>
|
||||
<label nz-checkbox [nzChecked]="tEstimationCheck" [(ngModel)]="tEstimationCheck">Time Estimation</label>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<ng-container>
|
||||
<label nz-checkbox [nzChecked]="tDescriptionCheck" [(ngModel)]="tDescriptionCheck">Description</label>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div>
|
||||
<ng-container>
|
||||
<label nz-checkbox [nzChecked]="tSubTasksCheck" [(ngModel)]="tSubTasksCheck">Sub Tasks</label>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #footer>
|
||||
<div style="float: right">
|
||||
<button nz-button style="margin-right: 8px;" (click)="close()">Cancel</button>
|
||||
<button nz-button nzType="primary"
|
||||
(click)="saveTemplate()"
|
||||
[nzLoading]="creating"
|
||||
[disabled]="disableBtnActive">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
</nz-drawer>
|
||||
@@ -0,0 +1,18 @@
|
||||
.required-star{
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
font-family: SimSun, sans-serif;
|
||||
line-height: 1;
|
||||
content: "*";
|
||||
}
|
||||
|
||||
.empty-error-text {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.error-input {
|
||||
border-color: #ff4d4f;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProjectTemplateCreateDrawerComponent } from './project-template-create-drawer.component';
|
||||
|
||||
describe('ProjectTemplateCreateDrawerComponent', () => {
|
||||
let component: ProjectTemplateCreateDrawerComponent;
|
||||
let fixture: ComponentFixture<ProjectTemplateCreateDrawerComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ProjectTemplateCreateDrawerComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(ProjectTemplateCreateDrawerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
NgZone,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {NzDrawerModule} from "ng-zorro-antd/drawer";
|
||||
import {NzFormModule} from "ng-zorro-antd/form";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {NzTypographyModule} from "ng-zorro-antd/typography";
|
||||
import {NzCheckboxModule} from "ng-zorro-antd/checkbox";
|
||||
import {NzInputModule} from 'ng-zorro-antd/input';
|
||||
import {NzButtonModule} from 'ng-zorro-antd/button';
|
||||
import {log_error} from "@shared/utils";
|
||||
import {ICustomProjectTemplateCreateRequest} from "@interfaces/project-template";
|
||||
import {ProjectTemplateApiService} from "@api/project-template-api.service";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-project-template-create-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NzDrawerModule, NzFormModule, FormsModule, NzTypographyModule, NzCheckboxModule, NzInputModule, NzButtonModule],
|
||||
templateUrl: './project-template-create-drawer.component.html',
|
||||
styleUrls: ['./project-template-create-drawer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectTemplateCreateDrawerComponent {
|
||||
@ViewChild('templateNameInput') templateNameInput!: ElementRef;
|
||||
@Input({required: true}) projectId: string | null = null;
|
||||
|
||||
templateName: string | null = null;
|
||||
|
||||
show = false;
|
||||
checked = true;
|
||||
showErrorText = false;
|
||||
disableBtnActive = true;
|
||||
|
||||
pStatusCheck = true;
|
||||
pPhasesCheck = true;
|
||||
pLabelsCheck = true;
|
||||
|
||||
tStatusCheck = true;
|
||||
tPhaseCheck = true;
|
||||
tEstimationCheck = true;
|
||||
tLabelsCheck = true;
|
||||
tDescriptionCheck = true;
|
||||
tSubTasksCheck = true;
|
||||
|
||||
creating = false;
|
||||
|
||||
constructor(
|
||||
private readonly cdr: ChangeDetectorRef,
|
||||
private readonly api: ProjectTemplateApiService,
|
||||
private readonly ngZone: NgZone,
|
||||
) {
|
||||
}
|
||||
|
||||
public async open() {
|
||||
this.show = true;
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
setTimeout(() => {
|
||||
this.templateNameInput.nativeElement.focus();
|
||||
}, 100)
|
||||
})
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.show = false;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
nameChange(text: string) {
|
||||
if (text.trim() === "") {
|
||||
this.showErrorText = true;
|
||||
return;
|
||||
}
|
||||
this.disableBtnActive = false;
|
||||
this.showErrorText = false;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
projectCheckChange(key: string, value: boolean) {
|
||||
switch (key) {
|
||||
case 'pStatuses':
|
||||
if (!value)
|
||||
this.tStatusCheck = false;
|
||||
break;
|
||||
case 'pPhases':
|
||||
if (!value)
|
||||
this.tPhaseCheck = false;
|
||||
break;
|
||||
case 'pLabels':
|
||||
if (!value)
|
||||
this.tLabelsCheck = false;
|
||||
break;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async saveTemplate() {
|
||||
if (!this.templateName || this.templateName.trim() === "" || !this.projectId) {
|
||||
this.showErrorText = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.creating = true;
|
||||
const body: ICustomProjectTemplateCreateRequest = {
|
||||
project_id: this.projectId,
|
||||
templateName: this.templateName,
|
||||
projectIncludes: {
|
||||
statuses: this.pStatusCheck,
|
||||
phases: this.pPhasesCheck,
|
||||
labels: this.pLabelsCheck
|
||||
},
|
||||
taskIncludes: {
|
||||
status: this.tStatusCheck,
|
||||
phase: this.tPhaseCheck,
|
||||
labels: this.tLabelsCheck,
|
||||
estimation: this.tEstimationCheck,
|
||||
description: this.tDescriptionCheck,
|
||||
subtasks: this.tSubTasksCheck
|
||||
}
|
||||
}
|
||||
const res = await this.api.createCustomTemplate(body);
|
||||
if (res.done) {
|
||||
this.creating = false;
|
||||
this.show = false;
|
||||
}
|
||||
this.creating = false;
|
||||
this.cdr.markForCheck();
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<div class="w-35">
|
||||
<nz-input-group [nzSuffix]="suffixIconSearch">
|
||||
<input [(ngModel)]="teamSearchText" (ngModelChange)="detectChanges()" type="text"
|
||||
placeholder="Search by template name" nz-input #searchInput>
|
||||
</nz-input-group>
|
||||
|
||||
<ng-template #suffixIconSearch>
|
||||
<span nz-icon nzType="search"></span>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<nz-skeleton [nzActive]="true" [nzLoading]="loading" class="mt-3">
|
||||
<nz-list nzBordered nzSize="small" class="temp-details" *ngIf="templateList.length">
|
||||
<nz-list-item *ngFor="let item of templateList | searchByName: searchInput.value; let i = index"
|
||||
(click)="changeSelectedTemplate(item.id, i)" class="actions-row"
|
||||
[ngClass]="item.selected ? 'selected' : ''">
|
||||
<nz-space>
|
||||
<nz-badge *nzSpaceItem [nzColor]="item.color_code" [nzText]=""></nz-badge>
|
||||
<span *nzSpaceItem class="template-name">{{item.name}}</span>
|
||||
</nz-space>
|
||||
</nz-list-item>
|
||||
</nz-list>
|
||||
<nz-empty *ngIf="!templateList.length" [nzNotFoundContent]="noCustomTemplates">
|
||||
</nz-empty>
|
||||
<ng-template #noCustomTemplates>
|
||||
No custom templates are found!
|
||||
</ng-template>
|
||||
</nz-skeleton>
|
||||
@@ -0,0 +1,31 @@
|
||||
.w-35 {
|
||||
max-width: 35%;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
cursor: pointer;
|
||||
transition: 0.25s background;
|
||||
|
||||
&:hover {
|
||||
& .template-name {
|
||||
color: #188fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nz-list-item {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: #e6f7ff;
|
||||
|
||||
& .template-name {
|
||||
color: #188fff;
|
||||
}
|
||||
}
|
||||
|
||||
.temp-details {
|
||||
max-height: calc(100vh - 245px);
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CustomTemplateListComponent } from './custom-template-list.component';
|
||||
|
||||
describe('CustomTemplateListComponent', () => {
|
||||
let component: CustomTemplateListComponent;
|
||||
let fixture: ComponentFixture<CustomTemplateListComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CustomTemplateListComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(CustomTemplateListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit, Output} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {log_error} from "@shared/utils";
|
||||
import {ProjectTemplateApiService} from "@api/project-template-api.service";
|
||||
import {NzSkeletonModule} from "ng-zorro-antd/skeleton";
|
||||
import {NzListModule} from "ng-zorro-antd/list";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {NzCheckboxModule} from "ng-zorro-antd/checkbox";
|
||||
import {NzInputModule} from "ng-zorro-antd/input";
|
||||
import {NzMenuModule} from "ng-zorro-antd/menu";
|
||||
import {SearchByNamePipe} from "@pipes/search-by-name.pipe";
|
||||
import {NzBadgeModule} from "ng-zorro-antd/badge";
|
||||
import {NzSpaceModule} from "ng-zorro-antd/space";
|
||||
import {NzIconModule} from "ng-zorro-antd/icon";
|
||||
import {ICustomTemplate} from "@interfaces/api-models/project-template";
|
||||
import {NzEmptyModule} from "ng-zorro-antd/empty";
|
||||
|
||||
@Component({
|
||||
selector: 'worklenz-custom-template-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NzSkeletonModule, NzListModule, FormsModule, NzCheckboxModule, NzInputModule, NzMenuModule, SearchByNamePipe, NzBadgeModule, NzSpaceModule, NzIconModule, NzEmptyModule],
|
||||
templateUrl: './custom-template-list.component.html',
|
||||
styleUrls: ['./custom-template-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CustomTemplateListComponent implements OnInit {
|
||||
@Output() selectTemplate: EventEmitter<string> = new EventEmitter<string>();
|
||||
|
||||
teamSearchText: string | null = null;
|
||||
|
||||
loading = false;
|
||||
|
||||
templateList: ICustomTemplate[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly cdr: ChangeDetectorRef,
|
||||
private readonly api: ProjectTemplateApiService
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
void this.get();
|
||||
}
|
||||
|
||||
async get() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await this.api.getWorklenzCustomTemplates();
|
||||
if(res.done) {
|
||||
this.templateList = res.body;
|
||||
this.loading = false;
|
||||
}
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
log_error(e)
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
changeSelectedTemplate(templateId: string | undefined, index: number) {
|
||||
for (let i = 0; i < this.templateList.length; i++) {
|
||||
this.templateList[i].selected = false;
|
||||
}
|
||||
|
||||
this.templateList[index].selected = true;
|
||||
this.selectTemplate.emit(templateId);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
detectChanges() {
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user