Initial import
This commit is contained in:
commit
4249de6cd8
8
.browserslistrc
Normal file
8
.browserslistrc
Normal file
@ -0,0 +1,8 @@
|
||||
# Modern browser targets aligned with Angular 21 support.
|
||||
last 2 Chrome versions
|
||||
last 1 Firefox version
|
||||
last 2 Edge major versions
|
||||
last 2 Safari major versions
|
||||
last 2 iOS major versions
|
||||
Firefox ESR
|
||||
not IE 11
|
||||
16
.editorconfig
Normal file
16
.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
|
||||
2
.eslintignore
Normal file
2
.eslintignore
Normal file
@ -0,0 +1,2 @@
|
||||
build
|
||||
node_modules
|
||||
54
.eslintrc.json
Normal file
54
.eslintrc.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["build/**/*", "node_modules/**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"parserOptions": {
|
||||
"project": ["tsconfig.json"],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@angular-eslint/recommended",
|
||||
"plugin:@angular-eslint/template/process-inline-templates"
|
||||
],
|
||||
"rules": {
|
||||
"@angular-eslint/directive-selector": "off",
|
||||
"@angular-eslint/component-selector": "off",
|
||||
"@angular-eslint/no-empty-lifecycle-method": "off",
|
||||
"@angular-eslint/prefer-inject": "off",
|
||||
"@angular-eslint/prefer-standalone": "off",
|
||||
"@angular-eslint/use-lifecycle-interface": "off",
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-extra-semi": "off",
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
"@typescript-eslint/no-wrapper-object-types": "off",
|
||||
"@typescript-eslint/no-inferrable-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"no-extra-boolean-cast": "off",
|
||||
"no-extra-semi": "off",
|
||||
"prefer-const": "off",
|
||||
"prefer-spread": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"extends": ["plugin:@angular-eslint/template/recommended"],
|
||||
"rules": {
|
||||
"@angular-eslint/template/prefer-control-flow": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
57
.github/workflows/ci.yml
vendored
Normal file
57
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint -- --quiet
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm run test -- --watch=false --browsers=ChromeHeadless
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: quality
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: E2E smoke
|
||||
run: npm run e2e
|
||||
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/build
|
||||
/tmp
|
||||
/out-tsc
|
||||
# Only exists if Bazel was run
|
||||
/bazel-out
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# profiling files
|
||||
chrome-profiler-events*.json
|
||||
speed-measure-plugin*.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
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
yarn.lock
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/.angular
|
||||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"quoteProps": "as-needed",
|
||||
"jsxSingleQuote": true,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 FLATLOGIC.COM
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
116
README.md
Normal file
116
README.md
Normal file
@ -0,0 +1,116 @@
|
||||
# 🚀 Angular Material Admin - A Free Angular Material Dashboard!
|
||||
|
||||
[View Demo](https://flatlogic.com/templates/angular-material-admin-full/demo) | [Download](https://github.com/flatlogic/angular-material-admin-full/archive/refs/heads/master.zip) | [More Templates](https://flatlogic.com/templates) | [Discord Community](https://discord.gg/flatlogic-community) | [Support Forum](https://flatlogic.com/forum)
|
||||
|
||||
**Originally a premium product priced at $69+, made available for free in January 2025!** 🎉
|
||||
|
||||
Looking for a perfect codebase generator for your Startup? Try [Flatlogic AI Web App Generator](https://flatlogic.com/generator) - our new tool, sort of a template++.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Why Angular Material Admin?
|
||||
- **Ex-Premium**: This template was previously paid. Enjoy it for free now. 😉
|
||||
- **Material Design**: Built with Angular Material, following Google's design principles.
|
||||
- **Join the Community**: [Flatlogic Discord](https://discord.gg/flatlogic-community) is where the action happens.
|
||||
- **Free Node.js Backend**: Pair it up with [this backend](https://github.com/flatlogic/nodejs-backend) to go full-stack.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **Clone the repo**
|
||||
```bash
|
||||
git clone https://github.com/flatlogic/angular-material-admin-full.git
|
||||
cd angular-material-admin-full
|
||||
```
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. **Run the app**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
Navigate to http://localhost:3000/.
|
||||
|
||||
4. **Build for production**
|
||||
```bash
|
||||
npm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Features
|
||||
|
||||
- Fully Responsive Layout
|
||||
- Angular 13
|
||||
- No jQuery and Bootstrap
|
||||
- Modular Architecture
|
||||
- Styled Angular Material Components
|
||||
- Multiple Dashboards
|
||||
- Authentication Pages
|
||||
- Charts (Apexcharts, Amcharts)
|
||||
- Static & Hover Sidebar
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Built With
|
||||
- Angular 13
|
||||
- Angular Material
|
||||
- TypeScript
|
||||
- Webpack
|
||||
- JavaScript (ES6)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Pages
|
||||
- Dashboard
|
||||
- E-Commerce (Product Management, Product Grid, Product Page)
|
||||
- User Management (User List, Add, Edit)
|
||||
- Forms (Elements, Validation)
|
||||
- Charts (Line, Bar, Pie)
|
||||
- Tables (Basic, Dynamic)
|
||||
- Maps (Google, Vector)
|
||||
- Core (Typography, Colors, Grid)
|
||||
- Extra (Calendar, Invoice, Gallery, Search Result, Timeline, Chat)
|
||||
- Authentication (Login, Error Pages)
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Available Variants
|
||||
|
||||
| | **Material** | **Transparent** | **Classic** | **Sofia** | **Flatlogic** |
|
||||
|---------------|-----------------------------------------------------------|---------------------------------------------------------|-------------------------------------------------------|-----------------------------------------------------|----------------------------------------------------|
|
||||
| **React** | [React Material Admin](https://github.com/flatlogic/react-material-admin-full) | [Light Blue React](https://github.com/flatlogic/light-blue-react) | [Sing App React](https://github.com/flatlogic/sing-app-react) | [Sofia React](https://github.com/flatlogic/sofia-react) | [One React](https://github.com/flatlogic/one-react) |
|
||||
| **Angular** | [Angular Material Admin](https://github.com/flatlogic/angular-material-admin-full) | [Light Blue Angular](https://github.com/flatlogic/light-blue-angular) | [Sing App Angular](https://github.com/flatlogic/sing-app-angular) | - | - |
|
||||
| **Vue** | [Material Vue](https://github.com/flatlogic/material-vue-full) | [Light Blue Vue](https://github.com/flatlogic/light-blue-vue) | [Sing App Vue](https://github.com/flatlogic/sing-app-vue) | - | - |
|
||||
| **Bootstrap** | - | [Light Blue HTML5](https://github.com/flatlogic/light-blue-html5) | [Sing App HTML5](https://github.com/flatlogic/sing-app-html5) | - | [One Bootstrap](https://github.com/flatlogic/one-bootstrap-template-full) |
|
||||
|
||||
|
||||
Additionally, these templates are tailored for specific business needs:
|
||||
- [E-Commerce Frontend (React)](https://github.com/flatlogic/ecommerce-frontend) - A complete e-commerce solution.
|
||||
- [Bookkeeper UI (React)](https://github.com/flatlogic/bookkeeper-ui) - Accounting dashboard for finance management.
|
||||
- [User Management Template (React)](https://github.com/flatlogic/user-management-template) - User authentication and management.
|
||||
|
||||
---
|
||||
|
||||
## 👨💻 How to Contribute
|
||||
- **Star this repo ⭐** - show some love.
|
||||
- **Report bugs** - but be nice.
|
||||
- **Join the [Discord](<insert-discord-invite-link>)** - meet fellow devs.
|
||||
|
||||
---
|
||||
|
||||
## 🔥 About Flatlogic
|
||||
[Flatlogic AI Software Engineer](https://flatlogic.com/ai-software-development-agent) builds modern business software so you don't have to. Our AI Software Development Agent helps you generate, deploy, and maintain enterprise applications with minimal effort.
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
This template is free to use. Modify it, break it, make it your own. Just don’t try to sell it back to us. 😎
|
||||
|
||||
---
|
||||
|
||||
> **Questions or feedback?**
|
||||
> Join our [Flatlogic Community Discord](https://discord.gg/flatlogic-community) or visit our [support forum](https://flatlogic.com/forum). We might even reply!
|
||||
|
||||
152
angular.json
Normal file
152
angular.json
Normal file
@ -0,0 +1,152 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"cli": {
|
||||
"analytics": "e08c68e1-44a7-45ac-b4bd-9a6e0380538f"
|
||||
},
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"angular-material-admin": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "build",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"aot": true,
|
||||
"assets": ["src/favicon.ico", "src/assets"],
|
||||
"styles": [
|
||||
"./node_modules/@fortawesome/fontawesome-free/css/all.min.css",
|
||||
"./node_modules/@fortawesome/fontawesome-free/css/v4-shims.min.css",
|
||||
"./node_modules/ngx-toastr/toastr.css",
|
||||
"./node_modules/angular-calendar/css/angular-calendar.css",
|
||||
"./node_modules/leaflet/dist/leaflet.css",
|
||||
"src/custom-theme.scss"
|
||||
],
|
||||
"allowedCommonJsDependencies": [
|
||||
"leaflet"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
"scripts": true,
|
||||
"styles": {
|
||||
"minify": true,
|
||||
"inlineCritical": true
|
||||
},
|
||||
"fonts": false
|
||||
},
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "12mb",
|
||||
"maximumError": "15mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hmr": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.hmr.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.backend.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"port": 3000,
|
||||
"buildTarget": "angular-material-admin:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "angular-material-admin:build:production"
|
||||
},
|
||||
"hmr": {
|
||||
"hmr": true,
|
||||
"buildTarget": "angular-material-admin:build:hmr"
|
||||
},
|
||||
"backend": {
|
||||
"buildTarget": "angular-material-admin:build:backend"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "angular-material-admin:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
143
changelog.md
Normal file
143
changelog.md
Normal file
@ -0,0 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## [2.0.0] - 02/03/2026
|
||||
### Updated
|
||||
- Migrated the project to a modern Angular architecture and refreshed routing/config setup.
|
||||
- Upgraded core framework dependencies and synchronized the platform stack.
|
||||
- Upgraded UI and charting ecosystem packages and aligned dependent libraries.
|
||||
- Upgraded calendar ecosystem packages and resolved related peer dependency issues.
|
||||
- Stabilized the documentation module for Angular 21 compatibility (module wiring, styles, shared imports).
|
||||
- Applied a broad set of UI fixes across key pages and widgets.
|
||||
- Modernized testing/tooling setup with Jest (unit) and Playwright (e2e) including CI workflow.
|
||||
- Added dependency security audit scripts and a security exception register for internal tracking.
|
||||
- Validated changes with successful `build`, `test`, and `e2e` runs.
|
||||
|
||||
### Current stack versions
|
||||
- Node: `22.x`
|
||||
- npm: `>=10`
|
||||
- TypeScript: `5.9.3`
|
||||
- Angular framework (`@angular/*`): `^21.2.0`
|
||||
- Angular CLI: `^21.2.0`
|
||||
- Angular build system (`@angular-devkit/build-angular`): `^21.2.0`
|
||||
- Angular Material/CDK: `^21.2.0`
|
||||
- Angular Google Maps: `^21.2.0`
|
||||
- Angular ESLint: `21.2.0`
|
||||
- ESLint: `^9.39.3`
|
||||
- `@types/node`: `^25.3.3`
|
||||
- Font Awesome: `^7.2.0`
|
||||
- `@ng-select/ng-select`: `^21.5.2`
|
||||
- ECharts: `^6.0.0`
|
||||
- `ngx-echarts`: `^21.0.0`
|
||||
- `angular-calendar`: `^0.32.0`
|
||||
- `angular-draggable-droppable`: `^9.0.1`
|
||||
- `angular-resizable-element`: `^8.0.1`
|
||||
- `date-fns`: `^4.1.0`
|
||||
- `moment`: `^2.30.1`
|
||||
- `zone.js`: `^0.16.1`
|
||||
- `rxjs`: `7.8.2`
|
||||
- `swiper`: `^12.1.2`
|
||||
- `ngx-toastr`: `^20.0.5`
|
||||
- `leaflet`: `^1.9.4`
|
||||
- Jest: `^30.2.0`
|
||||
- Playwright: `^1.58.2`
|
||||
|
||||
## [1.2.1] - 25/11/2024
|
||||
### Updated
|
||||
- Updated packages;
|
||||
|
||||
## [1.2.0] - 11/06/2022
|
||||
### Updated
|
||||
|
||||
- Updated the Angular builder;
|
||||
- Updated the Angular CLI;
|
||||
- Update Angular Material;
|
||||
- Updated packages
|
||||
|
||||
## [1.1.0] - 11/11/2021
|
||||
### Updated
|
||||
|
||||
- Updated apexcharts package;
|
||||
|
||||
###Updated packages
|
||||
apexcharts: 3.19.3 -> 3.26.0
|
||||
|
||||
## [1.0.9] - 09/27/2021
|
||||
### Updated
|
||||
|
||||
- Updated auth page;
|
||||
- Refactored code, fixed chat;
|
||||
|
||||
## [1.0.8] - 09/14/2021
|
||||
### Updated
|
||||
|
||||
- Updated login page;
|
||||
- Updated register page;
|
||||
- Added delete confirmation popup;
|
||||
|
||||
## [1.0.7] - 05/05/2021
|
||||
### Updated
|
||||
|
||||
- Updated the Angular builder;
|
||||
- Updated the Angular CLI;
|
||||
- Update Angular Material;
|
||||
|
||||
###Updated packages
|
||||
@angular/animations: 10.2.5 -> 11.2.12
|
||||
@angular/cdk: 9.2.4 -> 11.2.11
|
||||
@angular/common: 10.2.5 -> 11.2.12
|
||||
@angular/compiler: 10.2.5 -> 11.2.12
|
||||
@angular/core: 10.2.5 -> 11.2.12
|
||||
@angular/forms: 10.2.5 -> 11.2.12
|
||||
@angular/material: 9.2.4 -> 11.2.11
|
||||
@angular/platform-browser: 10.2.5 -> 11.2.12
|
||||
@angular/platform-browser-dynamic: 10.2.5 -> 11.2.12
|
||||
@angular/router: 10.2.5 -> 11.2.12
|
||||
@angular-devkit/build-angular: 0.1002.3 -> 0.1102.11
|
||||
@angular/cli: 10.2.3 -> 11.2.11
|
||||
@angular/compiler-cli: 10.2.5 -> 11.2.12
|
||||
@angular/language-service: 10.2.5 -> 11.2.12
|
||||
@types/jasmine: 3.5.14 -> 3.6.0
|
||||
codelyzer: 5.1.2 -> 6.0.0
|
||||
typescript: 4.0.7 -> 4.1.5
|
||||
|
||||
## [1.0.6]
|
||||
|
||||
### Fixed
|
||||
- Fix links in settings
|
||||
|
||||
## [1.0.5]
|
||||
|
||||
- Updated documentation
|
||||
|
||||
## [1.0.4]
|
||||
|
||||
### Fixed
|
||||
- Change badge text in the sidebar
|
||||
|
||||
## [1.0.3]
|
||||
|
||||
### Fixed
|
||||
- Fix send message button in header
|
||||
- Several fixes in user edit/add/list
|
||||
- Update badges/buttons
|
||||
- Change line color in timeline
|
||||
- Fix images in search result
|
||||
- Fix button in calendar
|
||||
- Change collapse box shadow
|
||||
- Fix titles in notification page
|
||||
|
||||
## [1.0.2]
|
||||
|
||||
### Updated
|
||||
- Minor package updates
|
||||
|
||||
## [1.0.1]
|
||||
|
||||
### Updated
|
||||
- Updated project documentation
|
||||
- Updated styles in project.
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
### Added
|
||||
- Added project
|
||||
20
docs/security-exceptions.md
Normal file
20
docs/security-exceptions.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Security Exceptions Register
|
||||
|
||||
Last reviewed: 2026-03-02
|
||||
|
||||
## GHSA-5c6j-r48x-rmvq (`serialize-javascript` <= 7.0.2)
|
||||
|
||||
- Severity: High
|
||||
- Status: Accepted temporarily
|
||||
- Scope: Development/build toolchain only (webpack via `@angular-devkit/build-angular`)
|
||||
- Runtime impact: Not loaded in production runtime bundle of the app
|
||||
- Upstream fix: Not available at the time of review (`npm audit` reports `No fix available`)
|
||||
- Mitigation:
|
||||
- Track Angular CLI / `@angular-devkit/build-angular` updates and re-run audit after each upgrade
|
||||
- Use `npm run audit:prod` in CI as the production risk gate
|
||||
- Keep `npm run audit:full` informational until upstream fix exists
|
||||
|
||||
## Review policy
|
||||
|
||||
- Re-check this register on every dependency upgrade cycle.
|
||||
- Remove exceptions immediately once an upstream fix is available and applied.
|
||||
23
e2e/playwright.config.cjs
Normal file
23
e2e/playwright.config.cjs
Normal file
@ -0,0 +1,23 @@
|
||||
// Smoke config with built-in web server bootstrap for local and CI runs.
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: /.*\.spec\.js/,
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3001',
|
||||
headless: true,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run start:nobackend -- --host 127.0.0.1 --port 3001',
|
||||
url: process.env.E2E_BASE_URL || 'http://127.0.0.1:3001',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 300_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
});
|
||||
79
e2e/smoke.spec.js
Normal file
79
e2e/smoke.spec.js
Normal file
@ -0,0 +1,79 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function mockAuthenticatedSession(page) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('token', 'e2e-token');
|
||||
window.localStorage.setItem(
|
||||
'user',
|
||||
JSON.stringify({ email: 'admin@flatlogic.com' }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function openProtectedRoute(page, path) {
|
||||
await page.goto('/dashboard', { waitUntil: 'networkidle' });
|
||||
await page.evaluate((targetPath) => {
|
||||
window.history.pushState({}, '', targetPath);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, path);
|
||||
}
|
||||
|
||||
test.describe('Core smoke', () => {
|
||||
test('login page renders', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.getByRole('button', { name: /login/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('dashboard page renders', async ({ page }) => {
|
||||
await mockAuthenticatedSession(page);
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByText(/overview/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('users list route renders', async ({ page }) => {
|
||||
await mockAuthenticatedSession(page);
|
||||
await openProtectedRoute(page, '/admin/users');
|
||||
await expect(page).toHaveURL(/\/admin\/users(\/list)?$/);
|
||||
await expect(
|
||||
page.locator(
|
||||
'button:has-text("New"), button:has-text("Add filter"), button:has-text("Add"), p.table-title:has-text("Users"), table.table, table[mat-table]',
|
||||
).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('users list -> create -> back to list', async ({ page }) => {
|
||||
await mockAuthenticatedSession(page);
|
||||
await openProtectedRoute(page, '/admin/users');
|
||||
await expect(page).toHaveURL(/\/admin\/users(\/list)?$/);
|
||||
|
||||
await page.getByRole('button', { name: /^\s*new\s*$/i }).first().click();
|
||||
await expect(page).toHaveURL(/\/admin\/users\/new$/);
|
||||
await expect(
|
||||
page.locator('h4:has-text("New Users"), button:has-text("Create")').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /^\s*cancel\s*$/i }).first().click();
|
||||
await expect(page).toHaveURL(/\/admin\/users(\/list)?$/);
|
||||
});
|
||||
|
||||
test('profile route renders', async ({ page }) => {
|
||||
await mockAuthenticatedSession(page);
|
||||
await openProtectedRoute(page, '/user/profile');
|
||||
await expect(page).toHaveURL(/\/user\/profile$/);
|
||||
await expect(
|
||||
page.locator('p:has-text("Views"), p:has-text("Updates")').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('change-password route renders', async ({ page }) => {
|
||||
await mockAuthenticatedSession(page);
|
||||
await openProtectedRoute(page, '/app/change-password');
|
||||
await expect(page).toHaveURL(/\/app\/change-password$/);
|
||||
await expect(page.getByRole('heading', { name: /change password/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /change password/i }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
12
jest.config.cjs
Normal file
12
jest.config.cjs
Normal file
@ -0,0 +1,12 @@
|
||||
const { createCjsPreset } = require('jest-preset-angular/presets');
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...createCjsPreset({
|
||||
diagnostics: false,
|
||||
}),
|
||||
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
|
||||
testMatch: ['<rootDir>/src/**/*.spec.ts'],
|
||||
moduleFileExtensions: ['ts', 'html', 'js', 'json', 'mjs'],
|
||||
clearMocks: true,
|
||||
};
|
||||
20511
package-lock.json
generated
Normal file
20511
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
84
package.json
Normal file
84
package.json
Normal file
@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "angularmaterialadminfull",
|
||||
"description": "Angular-Material-Admin-Full",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"start:nobackend": "ng serve",
|
||||
"start:backend": "ng serve --configuration backend",
|
||||
"build": "ng build --configuration production",
|
||||
"lint": "ng lint",
|
||||
"lint:fix": "ng lint --fix",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "jest --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"e2e": "playwright test -c e2e/playwright.config.cjs",
|
||||
"e2e:headed": "playwright test -c e2e/playwright.config.cjs --headed",
|
||||
"audit:prod": "npm audit --omit=dev",
|
||||
"audit:full": "npm audit",
|
||||
"hmr": "ng serve --configuration hmr"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@10",
|
||||
"engines": {
|
||||
"node": "22.x",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.0",
|
||||
"@angular/cdk": "^21.2.0",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/google-maps": "^21.2.0",
|
||||
"@angular/material": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/platform-browser-dynamic": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@danielmoncada/angular-datetime-picker": "^21.0.0",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@fullcalendar/angular": "^6.1.20",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@ng-select/ng-select": "^21.5.2",
|
||||
"angular-calendar": "^0.32.0",
|
||||
"angular-draggable-droppable": "^9.0.1",
|
||||
"angular-resizable-element": "^8.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"echarts": "^6.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"moment": "^2.30.1",
|
||||
"ngx-echarts": "^21.0.0",
|
||||
"ngx-toastr": "^20.0.5",
|
||||
"rxjs": "7.8.2",
|
||||
"swiper": "^12.1.2",
|
||||
"tslib": "^2.0.0",
|
||||
"zone.js": "^0.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^21.2.0",
|
||||
"@angular-eslint/builder": "21.2.0",
|
||||
"@angular-eslint/eslint-plugin": "21.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "21.2.0",
|
||||
"@angular-eslint/schematics": "21.2.0",
|
||||
"@angular-eslint/template-parser": "21.2.0",
|
||||
"@angular/cli": "^21.2.0",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"@angular/language-service": "^21.2.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"eslint": "^9.39.3",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"jest-preset-angular": "^16.1.1",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
3
setup-jest.ts
Normal file
3
setup-jest.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv();
|
||||
1
src/app/app.component.html
Normal file
1
src/app/app.component.html
Normal file
@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
0
src/app/app.component.scss
Normal file
0
src/app/app.component.scss
Normal file
9
src/app/app.component.ts
Normal file
9
src/app/app.component.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class AppComponent {}
|
||||
44
src/app/app.config.ts
Normal file
44
src/app/app.config.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export interface AppRuntimeConfig {
|
||||
version: string;
|
||||
remote: string;
|
||||
isBackend: boolean;
|
||||
hostApi: string;
|
||||
portApi: string;
|
||||
baseURLApi: string;
|
||||
auth: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
const buildRuntimeConfig = (): AppRuntimeConfig => {
|
||||
const hostApi = environment.production
|
||||
? 'https://sing-generator-node.flatlogic.com'
|
||||
: 'http://localhost';
|
||||
const portApi = environment.production ? '' : '8080';
|
||||
const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}`;
|
||||
|
||||
return {
|
||||
version: '1.2.0',
|
||||
remote: 'https://sing-generator-node.flatlogic.com',
|
||||
isBackend: environment.backend,
|
||||
hostApi,
|
||||
portApi,
|
||||
baseURLApi,
|
||||
auth: {
|
||||
email: 'admin@flatlogic.com',
|
||||
password: 'password',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const APP_RUNTIME_CONFIG = new InjectionToken<AppRuntimeConfig>(
|
||||
'APP_RUNTIME_CONFIG',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: buildRuntimeConfig,
|
||||
},
|
||||
);
|
||||
53
src/app/app.module.ts
Normal file
53
src/app/app.module.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import {
|
||||
PreloadAllModules,
|
||||
RouterModule,
|
||||
provideRouter,
|
||||
withPreloading,
|
||||
} from '@angular/router';
|
||||
import { ToastrModule } from 'ngx-toastr';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { APP_ROUTES } from './app.routes';
|
||||
import { NotFoundComponent } from './shared/not-found/not-found.component';
|
||||
import { CrudModule } from './modules/CRUD/crud.module';
|
||||
import { CalendarModule, DateAdapter } from 'angular-calendar';
|
||||
import { adapterFactory } from 'angular-calendar/date-adapters/date-fns';
|
||||
import { MAT_SELECT_CONFIG } from '@angular/material/select';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { httpInterceptor } from './shared/services/http-interceptor.service';
|
||||
import { NgxEchartsModule } from 'ngx-echarts';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
NotFoundComponent,
|
||||
CrudModule,
|
||||
BrowserAnimationsModule,
|
||||
RouterModule,
|
||||
ToastrModule.forRoot(),
|
||||
NgxEchartsModule.forRoot({
|
||||
echarts: () => import('echarts'),
|
||||
}),
|
||||
CalendarModule.forRoot({
|
||||
provide: DateAdapter,
|
||||
useFactory: adapterFactory,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_SELECT_CONFIG,
|
||||
useValue: {
|
||||
hideSingleSelectionIndicator: true,
|
||||
panelWidth: null,
|
||||
},
|
||||
},
|
||||
provideRouter(APP_ROUTES, withPreloading(PreloadAllModules)),
|
||||
provideHttpClient(withInterceptors([httpInterceptor])),
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
src/app/app.routes.spec.ts
Normal file
38
src/app/app.routes.spec.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { APP_ROUTES } from './app.routes';
|
||||
import { routes } from './consts';
|
||||
|
||||
describe('APP_ROUTES', () => {
|
||||
it('redirects root path to dashboard with full match', () => {
|
||||
const rootRedirect = APP_ROUTES.find(
|
||||
(route) => route.path === '' && route.redirectTo,
|
||||
);
|
||||
|
||||
expect(rootRedirect?.redirectTo).toBe(routes.DASHBOARD);
|
||||
expect(rootRedirect?.pathMatch).toBe('full');
|
||||
});
|
||||
|
||||
it('contains required top-level feature paths under layout', () => {
|
||||
const layoutRoute = APP_ROUTES.find(
|
||||
(route) => route.path === '' && Array.isArray(route.children),
|
||||
);
|
||||
const childPaths = (layoutRoute?.children ?? []).map((route) => route.path);
|
||||
|
||||
expect(childPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'dashboard',
|
||||
'admin',
|
||||
'app',
|
||||
'user',
|
||||
'e-commerce',
|
||||
'charts',
|
||||
'maps',
|
||||
'extra',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects unknown paths to 404', () => {
|
||||
const wildcardRoute = APP_ROUTES.find((route) => route.path === '**');
|
||||
expect(wildcardRoute?.redirectTo).toBe('404');
|
||||
});
|
||||
});
|
||||
128
src/app/app.routes.ts
Normal file
128
src/app/app.routes.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { NotFoundComponent } from './shared/not-found/not-found.component';
|
||||
import { authGuard } from './modules/auth/guards';
|
||||
import { LayoutComponent } from './shared/layout/layout.component';
|
||||
import { routes } from './consts';
|
||||
|
||||
const ROUTES: typeof routes = routes;
|
||||
|
||||
export const APP_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: ROUTES.DASHBOARD,
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
loadChildren: () =>
|
||||
import('./modules/auth/auth.routes').then((m) => m.AUTH_ROUTES),
|
||||
},
|
||||
{
|
||||
path: '404',
|
||||
component: NotFoundComponent,
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
component: LayoutComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
pathMatch: 'full',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/dashboard/dashboard.routes').then(
|
||||
(m) => m.DASHBOARD_ROUTES,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'documentation',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/documentation/documentation.module').then(
|
||||
(m) => m.DocumentationModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'e-commerce',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/e-commerce/e-commerce.module').then(
|
||||
(m) => m.ECommerceModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'core',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/templates/core/core.module').then((m) => m.CoreModule),
|
||||
},
|
||||
{
|
||||
path: 'tables',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/templates/tables/tables.module').then(
|
||||
(m) => m.TablesModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'ui',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/templates/ui-elements/ui-elements.module').then(
|
||||
(m) => m.UiElementsModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'forms',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/templates/forms/forms.module').then(
|
||||
(m) => m.FormsModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'charts',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/templates/charts/charts.module').then(
|
||||
(m) => m.ChartsModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'maps',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/templates/maps/maps.module').then((m) => m.MapsModule),
|
||||
},
|
||||
{
|
||||
path: 'extra',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/templates/extra/extra.module').then(
|
||||
(m) => m.ExtraModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
loadChildren: () =>
|
||||
import('./modules/CRUD/crud.module').then((m) => m.CrudModule),
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('./modules/user/user.module').then((m) => m.UserModule),
|
||||
},
|
||||
{
|
||||
path: 'app',
|
||||
loadChildren: () =>
|
||||
import('./modules/pages/pages.routes').then((m) => m.PAGES_ROUTES),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: '404',
|
||||
},
|
||||
];
|
||||
10
src/app/consts/colors.ts
Normal file
10
src/app/consts/colors.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export enum colors {
|
||||
YELLOW = '#ffc260',
|
||||
BLUE = '#536DFE',
|
||||
LIGHT_BLUE = '#F8F9FF',
|
||||
DARK_BLUE = '#7C90FF',
|
||||
PINK = '#ff4081',
|
||||
GREEN = '#3CD4A0',
|
||||
VIOLET = '#9013FE',
|
||||
GREY = '#E0E0E0',
|
||||
}
|
||||
1
src/app/consts/common.ts
Normal file
1
src/app/consts/common.ts
Normal file
@ -0,0 +1 @@
|
||||
export const AUTO_COMPLETE_LIMIT = 100;
|
||||
4
src/app/consts/index.ts
Normal file
4
src/app/consts/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './routes';
|
||||
export * from './colors';
|
||||
export * from './common';
|
||||
export * from './storage';
|
||||
86
src/app/consts/routes.ts
Normal file
86
src/app/consts/routes.ts
Normal file
@ -0,0 +1,86 @@
|
||||
export enum routes {
|
||||
DASHBOARD = '/dashboard',
|
||||
PROFILE = '/user/profile',
|
||||
CHANGE_PASSWORD = '/app/change-password',
|
||||
LOGIN = '/login',
|
||||
|
||||
// --- CRUD module ---//
|
||||
|
||||
Users = '/admin/users',
|
||||
Users_CREATE = '/admin/users/new',
|
||||
Users_EDIT = '/admin/users/edit',
|
||||
|
||||
// --- E-commerce ---//
|
||||
|
||||
MANAGEMENT = '/e-commerce/management',
|
||||
PRODUCT_EDIT = '/e-commerce/edit',
|
||||
PRODUCT_CREATE = '/e-commerce/create',
|
||||
PRODUCTS = '/e-commerce/products',
|
||||
PRODUCT = '/e-commerce/product',
|
||||
|
||||
// --- Documentation ---//
|
||||
|
||||
LIBS = '/documentation/libs',
|
||||
STRUCTURE = '/documentation/structure',
|
||||
OVERVIEW = '/documentation/overview',
|
||||
LICENCES = '/documentation/licences',
|
||||
QUICK_START = '/documentation/quick-start',
|
||||
CHARTS = '/documentation/charts',
|
||||
FORMS = '/documentation/forms',
|
||||
UI = '/documentation/ui',
|
||||
MAPS = '/documentation/maps',
|
||||
TABLES = '/documentation/tables',
|
||||
|
||||
// --- Core module ---//
|
||||
|
||||
TYPOGRAPHY = '/core/typography',
|
||||
COLORS = '/core/colors',
|
||||
GRID = '/core/grid',
|
||||
|
||||
// --- Tables module ---//
|
||||
|
||||
TABLES_BASIC = '/tables/basic',
|
||||
TABLES_DYNAMIC = '/tables/dynamic',
|
||||
|
||||
// --- Ui Elements module --- //
|
||||
|
||||
ICONS = '/ui/icons',
|
||||
BADGE = '/ui/badge',
|
||||
CAROUSEL = '/ui/carousel',
|
||||
CARDS = '/ui/cards',
|
||||
MODAL = '/ui/modal',
|
||||
NOTIFICATION = '/ui/notification',
|
||||
NAVBAR = '/ui/navbar',
|
||||
TOOLTIPS = '/ui/tooltips',
|
||||
TABS = '/ui/tabs',
|
||||
PAGINATION = '/ui/pagination',
|
||||
PROGRESS = '/ui/progress',
|
||||
WIDGET = '/ui/widget',
|
||||
|
||||
// --- Forms module ---//
|
||||
|
||||
FORMS_ELEMENTS = '/forms/elements',
|
||||
FORMS_VALIDATION = '/forms/validation',
|
||||
|
||||
// --- Charts module ---//
|
||||
|
||||
BAR_CHARTS = '/charts/bar',
|
||||
LINE_CHARTS = '/charts/line',
|
||||
PIE_CHARTS = '/charts/pie',
|
||||
OVERVIEW_CHARTS = '/charts/overview',
|
||||
|
||||
// --- Maps module --- //
|
||||
|
||||
GOOGLE_MAP = '/maps/google',
|
||||
VECTOR_MAP = '/maps/vector',
|
||||
|
||||
// --- Extra module ---//
|
||||
|
||||
CALENDAR = '/extra/calendar',
|
||||
INVOICE = '/extra/invoice',
|
||||
LOGIN_PAGE = '/extra/login',
|
||||
ERROR_PAGE = '/extra/error',
|
||||
GALLERY = '/extra/gallery',
|
||||
SEARCH_RESULT = '/extra/search-result',
|
||||
TIME_LINE = '/extra/time-line',
|
||||
}
|
||||
2
src/app/consts/storage.ts
Normal file
2
src/app/consts/storage.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const AUTH_TOKEN_STORAGE_KEY = 'token';
|
||||
export const AUTH_USER_STORAGE_KEY = 'user';
|
||||
31
src/app/modules/CRUD/crud-routing.module.ts
Normal file
31
src/app/modules/CRUD/crud-routing.module.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
import { UsersCreateComponent } from './users-create/users-create.component';
|
||||
import { UsersEditComponent } from './users-edit/users-edit.component';
|
||||
import { UsersListComponent } from './users-list/users-list.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: 'users',
|
||||
component: UsersListComponent,
|
||||
},
|
||||
{
|
||||
path: 'users/edit',
|
||||
component: UsersEditComponent,
|
||||
},
|
||||
{
|
||||
path: 'users/edit/:id',
|
||||
component: UsersEditComponent,
|
||||
},
|
||||
{
|
||||
path: 'users/new',
|
||||
component: UsersCreateComponent,
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class CrudRoutingModule {}
|
||||
62
src/app/modules/CRUD/crud.module.ts
Normal file
62
src/app/modules/CRUD/crud.module.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import {
|
||||
OwlDateTimeModule,
|
||||
OwlNativeDateTimeModule,
|
||||
} from '@danielmoncada/angular-datetime-picker';
|
||||
import { CrudRoutingModule } from './crud-routing.module';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { BreadcrumbComponent } from '../../shared/ui-elements';
|
||||
import { ImageUploaderComponent } from '../../shared/uploaders/image-uploader/image-uploader.component';
|
||||
import { FilterComponent } from '../../shared/filter/filter.component';
|
||||
import { DeletePopupComponent } from '../../shared/popups/delete-popup/delete-popup.component';
|
||||
|
||||
import { UsersCreateComponent } from './users-create/users-create.component';
|
||||
import { UsersEditComponent } from './users-edit/users-edit.component';
|
||||
import { UsersListComponent } from './users-list/users-list.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [UsersCreateComponent, UsersEditComponent, UsersListComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
CrudRoutingModule,
|
||||
NgSelectModule,
|
||||
BreadcrumbComponent,
|
||||
ImageUploaderComponent,
|
||||
FilterComponent,
|
||||
DeletePopupComponent,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatTableModule,
|
||||
MatTooltipModule,
|
||||
MatCheckboxModule,
|
||||
MatSortModule,
|
||||
MatPaginatorModule,
|
||||
MatRadioModule,
|
||||
MatDialogModule,
|
||||
OwlDateTimeModule,
|
||||
OwlNativeDateTimeModule,
|
||||
],
|
||||
})
|
||||
export class CrudModule {}
|
||||
@ -0,0 +1,92 @@
|
||||
<app-breadcrumb [path]="routes.Users_CREATE"></app-breadcrumb>
|
||||
|
||||
<mat-card appearance="outlined" class="card">
|
||||
<mat-card-title>
|
||||
<h4 class="h4">New Users</h4>
|
||||
</mat-card-title>
|
||||
|
||||
<mat-card-content [formGroup]="form">
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">First Name</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="firstName"
|
||||
placeholder="First Name"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">Last Name</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="lastName"
|
||||
placeholder="Last Name"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">Phone Number</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="phoneNumber"
|
||||
placeholder="Phone Number"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">E-Mail</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="email"
|
||||
placeholder="E-Mail"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<p class="input-title">Role</p>
|
||||
|
||||
<div class="checkbox-wrapper">
|
||||
<mat-radio-group formControlName="role">
|
||||
<mat-radio-button color="primary" [value]="'admin'">
|
||||
admin
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
<mat-radio-group formControlName="role">
|
||||
<mat-radio-button color="primary" [value]="'user'">
|
||||
user
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-wrapper">
|
||||
<div class="checkbox-item">
|
||||
<p class="checkbox-item-title">Disabled</p>
|
||||
<mat-checkbox color="primary" formControlName="disabled"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-image-uploader
|
||||
(imageUploaded)="avatarAdd($event)"
|
||||
(imageDeleted)="avatarDel($event)"
|
||||
[imageList]="form.controls.avatar.value"
|
||||
[entityName]="'users'"
|
||||
[propertyName]="'avatar'"
|
||||
>
|
||||
</app-image-uploader>
|
||||
|
||||
<button (click)="onCreate()" mat-flat-button color="success">Create</button>
|
||||
<button (click)="onCancel()" mat-flat-button color="default">Cancel</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
132
src/app/modules/CRUD/users-create/users-create.component.scss
Normal file
132
src/app/modules/CRUD/users-create/users-create.component.scss
Normal file
@ -0,0 +1,132 @@
|
||||
@use 'src/app/styles/font' as *;
|
||||
@use 'src/app/styles/variables' as *;
|
||||
|
||||
.card {
|
||||
margin-top: 24px;
|
||||
box-shadow: 0 3px 11px 0 $shadow-white, 0 3px 3px -2px $shadow-grey,
|
||||
0 1px 8px 0 $shadow-dark-grey;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 80%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16px 0 8px 0;
|
||||
|
||||
@media (max-width: $small) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.input-title {
|
||||
font-size: $fs-regular;
|
||||
color: $dark-grey;
|
||||
margin: 0 16px 0 0;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 80%;
|
||||
font-size: $fs-xs;
|
||||
|
||||
@media (max-width: $small) {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
.input-text,
|
||||
textarea {
|
||||
font-size: $fs-regular;
|
||||
}
|
||||
|
||||
/* TODO(mdc-migration): The following rule targets internal classes of form-field that may no longer apply for the MDC version. */
|
||||
.mat-form-field-wrapper {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* TODO(mdc-migration): The following rule targets internal classes of form-field that may no longer apply for the MDC version. */
|
||||
.mat-form-field-appearance-outline .mat-form-field-infix {
|
||||
padding: 20px 0 16px 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.input-select {
|
||||
border: none;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.input-panel {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.input-img {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
button.mat-mdc-unelevated-button + button.mat-flat-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
margin: 18px 0;
|
||||
display: flex;
|
||||
|
||||
@media (max-width: $medium) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-item-title {
|
||||
font-size: $fs-regular;
|
||||
color: $dark-grey;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* TODO(mdc-migration): The following rule targets internal classes of checkbox that may no longer apply for the MDC version. */
|
||||
.mat-mdc-checkbox,
|
||||
.mat-checkbox-inner-container,
|
||||
.mat-checkbox-inner-container-no-side-margin {
|
||||
margin-left: 10px !important;
|
||||
}
|
||||
|
||||
.mat-mdc-radio-button {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.inputs-wrapper {
|
||||
margin-top: 36px;
|
||||
|
||||
@media (max-width: $normal) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-success {
|
||||
background-color: $green;
|
||||
color: white;
|
||||
}
|
||||
|
||||
mat-form-field + mat-form-field {
|
||||
margin-left: 24px;
|
||||
|
||||
@media (max-width: $normal) {
|
||||
margin-top: 24px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 191px;
|
||||
height: 141px;
|
||||
background-size: cover;
|
||||
background-position: 50% center;
|
||||
}
|
||||
85
src/app/modules/CRUD/users-create/users-create.component.ts
Normal file
85
src/app/modules/CRUD/users-create/users-create.component.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
} from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { routes, AUTO_COMPLETE_LIMIT } from '../../../consts';
|
||||
import { UsersService } from '../../../shared/services/users.service';
|
||||
import { take } from 'rxjs';
|
||||
|
||||
type AvatarItem = {
|
||||
id?: string;
|
||||
publicUrl?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type UsersCreateFormControls = {
|
||||
firstName: FormControl<string>;
|
||||
lastName: FormControl<string>;
|
||||
phoneNumber: FormControl<string>;
|
||||
email: FormControl<string>;
|
||||
role: FormControl<string>;
|
||||
disabled: FormControl<boolean>;
|
||||
avatar: FormControl<AvatarItem[]>;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-create',
|
||||
templateUrl: './users-create.component.html',
|
||||
styleUrls: ['./users-create.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class UsersCreateComponent implements OnInit {
|
||||
loading = false;
|
||||
public routes: typeof routes = routes;
|
||||
public form: FormGroup<UsersCreateFormControls>;
|
||||
AUTO_COMPLETE_LIMIT = AUTO_COMPLETE_LIMIT;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private toastr: ToastrService,
|
||||
private usersService: UsersService,
|
||||
) {
|
||||
this.form = new FormGroup<UsersCreateFormControls>({
|
||||
firstName: new FormControl('', { nonNullable: true }),
|
||||
lastName: new FormControl('', { nonNullable: true }),
|
||||
phoneNumber: new FormControl('', { nonNullable: true }),
|
||||
email: new FormControl('', { nonNullable: true }),
|
||||
role: new FormControl('user', { nonNullable: true }),
|
||||
disabled: new FormControl(false, { nonNullable: true }),
|
||||
avatar: new FormControl<AvatarItem[]>([], { nonNullable: true }),
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
public avatarAdd(val: AvatarItem): void {
|
||||
const currentAvatar = this.form.controls.avatar.value;
|
||||
this.form.controls.avatar.setValue([...currentAvatar, val]);
|
||||
}
|
||||
|
||||
public avatarDel(id: string): void {
|
||||
const nextAvatar = this.form.controls.avatar.value.filter(
|
||||
(img) => img.id !== id,
|
||||
);
|
||||
this.form.controls.avatar.setValue(nextAvatar);
|
||||
}
|
||||
|
||||
onCreate(): void {
|
||||
this.usersService.create(this.form.getRawValue()).pipe(take(1)).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Users created successfully');
|
||||
this.router.navigate([this.routes.Users]);
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Something was wrong. Try again');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.router.navigate([this.routes.Users]);
|
||||
}
|
||||
}
|
||||
104
src/app/modules/CRUD/users-edit/users-edit.component.html
Normal file
104
src/app/modules/CRUD/users-edit/users-edit.component.html
Normal file
@ -0,0 +1,104 @@
|
||||
<app-breadcrumb [path]="routes.Users_EDIT"></app-breadcrumb>
|
||||
|
||||
<mat-card appearance="outlined" class="card">
|
||||
<mat-card-title>
|
||||
<h4 class="h4">Edit Users</h4>
|
||||
</mat-card-title>
|
||||
|
||||
<mat-card-content [formGroup]="form">
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">First Name</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="firstName"
|
||||
placeholder="First Name"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">Last Name</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="lastName"
|
||||
placeholder="Last Name"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">Phone Number</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="phoneNumber"
|
||||
placeholder="Phone Number"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">E-Mail</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="email"
|
||||
placeholder="E-Mail"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<p class="input-title">Role</p>
|
||||
|
||||
<div class="checkbox-wrapper">
|
||||
<mat-radio-group formControlName="role">
|
||||
<mat-radio-button color="primary" [value]="'admin'">
|
||||
admin
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
<mat-radio-group formControlName="role">
|
||||
<mat-radio-button color="primary" [value]="'user'">
|
||||
user
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-wrapper">
|
||||
<div class="checkbox-item">
|
||||
<p class="checkbox-item-title">Disabled</p>
|
||||
<mat-checkbox color="primary" formControlName="disabled"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-image-uploader
|
||||
(imageUploaded)="avatarAdd($event)"
|
||||
(imageDeleted)="avatarDel($event)"
|
||||
[imageList]="form.controls.avatar.value"
|
||||
[entityName]="'users'"
|
||||
[propertyName]="'avatar'"
|
||||
>
|
||||
</app-image-uploader>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<p class="input-title">Password</p>
|
||||
<mat-form-field class="input-field">
|
||||
<input
|
||||
matInput
|
||||
formControlName="password"
|
||||
placeholder="Password"
|
||||
class="input-text"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<button (click)="onSave()" mat-flat-button color="success">Save</button>
|
||||
<button (click)="onCancel()" mat-flat-button color="default">Cancel</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
132
src/app/modules/CRUD/users-edit/users-edit.component.scss
Normal file
132
src/app/modules/CRUD/users-edit/users-edit.component.scss
Normal file
@ -0,0 +1,132 @@
|
||||
@use 'src/app/styles/font' as *;
|
||||
@use 'src/app/styles/variables' as *;
|
||||
|
||||
.card {
|
||||
margin-top: 24px;
|
||||
box-shadow: 0 3px 11px 0 $shadow-white, 0 3px 3px -2px $shadow-grey,
|
||||
0 1px 8px 0 $shadow-dark-grey;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 80%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16px 0 8px 0;
|
||||
|
||||
@media (max-width: $small) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.input-title {
|
||||
font-size: $fs-regular;
|
||||
color: $dark-grey;
|
||||
margin: 0 16px 0 0;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 80%;
|
||||
font-size: $fs-xs;
|
||||
|
||||
@media (max-width: $small) {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
.input-text,
|
||||
textarea {
|
||||
font-size: $fs-regular;
|
||||
}
|
||||
|
||||
/* TODO(mdc-migration): The following rule targets internal classes of form-field that may no longer apply for the MDC version. */
|
||||
.mat-form-field-wrapper {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* TODO(mdc-migration): The following rule targets internal classes of form-field that may no longer apply for the MDC version. */
|
||||
.mat-form-field-appearance-outline .mat-form-field-infix {
|
||||
padding: 20px 0 16px 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.input-select {
|
||||
border: none;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.input-panel {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.input-img {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
button.mat-mdc-unelevated-button + button.mat-flat-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
margin: 18px 0;
|
||||
display: flex;
|
||||
|
||||
@media (max-width: $medium) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-item-title {
|
||||
font-size: $fs-regular;
|
||||
color: $dark-grey;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* TODO(mdc-migration): The following rule targets internal classes of checkbox that may no longer apply for the MDC version. */
|
||||
.mat-mdc-checkbox,
|
||||
.mat-checkbox-inner-container,
|
||||
.mat-checkbox-inner-container-no-side-margin {
|
||||
margin-left: 10px !important;
|
||||
}
|
||||
|
||||
.mat-mdc-radio-button {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.inputs-wrapper {
|
||||
margin-top: 36px;
|
||||
|
||||
@media (max-width: $normal) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-success {
|
||||
background-color: $green;
|
||||
color: white;
|
||||
}
|
||||
|
||||
mat-form-field + mat-form-field {
|
||||
margin-left: 24px;
|
||||
|
||||
@media (max-width: $normal) {
|
||||
margin-top: 24px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 191px;
|
||||
height: 141px;
|
||||
background-size: cover;
|
||||
background-position: 50% center;
|
||||
}
|
||||
176
src/app/modules/CRUD/users-edit/users-edit.component.ts
Normal file
176
src/app/modules/CRUD/users-edit/users-edit.component.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
} from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { routes, AUTO_COMPLETE_LIMIT } from '../../../consts';
|
||||
import { UsersService } from '../../../shared/services/users.service';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { take } from 'rxjs';
|
||||
|
||||
type AvatarItem = {
|
||||
id?: string;
|
||||
publicUrl?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type UsersEditFormControls = {
|
||||
firstName: FormControl<string>;
|
||||
lastName: FormControl<string>;
|
||||
phoneNumber: FormControl<string>;
|
||||
email: FormControl<string>;
|
||||
role: FormControl<string>;
|
||||
disabled: FormControl<boolean>;
|
||||
avatar: FormControl<AvatarItem[]>;
|
||||
password: FormControl<string>;
|
||||
};
|
||||
|
||||
type UsersEditFormValue = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
role: string;
|
||||
disabled: boolean;
|
||||
avatar: AvatarItem[];
|
||||
password: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-edit',
|
||||
templateUrl: './users-edit.component.html',
|
||||
styleUrls: ['./users-edit.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class UsersEditComponent implements OnInit {
|
||||
loading = false;
|
||||
public routes: typeof routes = routes;
|
||||
public form: FormGroup<UsersEditFormControls>;
|
||||
AUTO_COMPLETE_LIMIT = AUTO_COMPLETE_LIMIT;
|
||||
selectedId: string | null = null;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private toastr: ToastrService,
|
||||
private usersService: UsersService,
|
||||
) {
|
||||
this.form = new FormGroup<UsersEditFormControls>({
|
||||
firstName: new FormControl('', { nonNullable: true }),
|
||||
lastName: new FormControl('', { nonNullable: true }),
|
||||
phoneNumber: new FormControl('', { nonNullable: true }),
|
||||
email: new FormControl('', { nonNullable: true }),
|
||||
role: new FormControl('user', { nonNullable: true }),
|
||||
disabled: new FormControl(false, { nonNullable: true }),
|
||||
avatar: new FormControl<AvatarItem[]>([], { nonNullable: true }),
|
||||
password: new FormControl('', { nonNullable: true }),
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((params) => {
|
||||
this.selectedId = params.get('id');
|
||||
|
||||
if (this.selectedId) {
|
||||
this.getUsersById();
|
||||
return;
|
||||
}
|
||||
|
||||
this.redirectToFirstUser();
|
||||
});
|
||||
}
|
||||
|
||||
public avatarAdd(val: AvatarItem): void {
|
||||
const currentAvatar = this.form.controls.avatar.value;
|
||||
this.form.controls.avatar.setValue([...currentAvatar, val]);
|
||||
}
|
||||
|
||||
public avatarDel(id: string): void {
|
||||
const nextAvatar = this.form.controls.avatar.value.filter(
|
||||
(img) => img.id !== id,
|
||||
);
|
||||
this.form.controls.avatar.setValue(nextAvatar);
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (!this.selectedId) {
|
||||
this.toastr.error('User is not selected');
|
||||
this.router.navigate([this.routes.Users]);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: UsersEditFormValue = this.form.getRawValue();
|
||||
this.usersService.update(payload, this.selectedId).pipe(take(1)).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Users updated successfully');
|
||||
this.router.navigate([this.routes.Users]);
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Something was wrong. Try again');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.router.navigate([this.routes.Users]);
|
||||
}
|
||||
|
||||
private getUsersById(): void {
|
||||
if (!this.selectedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.usersService.getById(this.selectedId).pipe(take(1)).subscribe({
|
||||
next: (res) => {
|
||||
if (!res) {
|
||||
this.toastr.error('User not found');
|
||||
this.router.navigate([this.routes.Users]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.patchValue({
|
||||
firstName: res.firstName ?? '',
|
||||
lastName: res.lastName ?? '',
|
||||
phoneNumber: res.phoneNumber ?? '',
|
||||
email: res.email ?? '',
|
||||
role: typeof res.role === 'string' ? res.role : 'user',
|
||||
disabled: Boolean(res.disabled),
|
||||
password: res.password ?? '',
|
||||
});
|
||||
this.form.controls.avatar.setValue(
|
||||
Array.isArray(res.avatar) ? (res.avatar as AvatarItem[]) : [],
|
||||
);
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Failed to load user');
|
||||
this.router.navigate([this.routes.Users]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private redirectToFirstUser(): void {
|
||||
this.usersService.getAll().pipe(take(1)).subscribe({
|
||||
next: (res) => {
|
||||
const firstUserId = res?.rows?.[0]?.id;
|
||||
if (!firstUserId) {
|
||||
this.toastr.error('No users available for editing');
|
||||
this.router.navigate([this.routes.Users]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate([this.routes.Users_EDIT, firstUserId], {
|
||||
replaceUrl: true,
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Failed to load users');
|
||||
this.router.navigate([this.routes.Users]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
250
src/app/modules/CRUD/users-list/users-list.component.html
Normal file
250
src/app/modules/CRUD/users-list/users-list.component.html
Normal file
@ -0,0 +1,250 @@
|
||||
<app-breadcrumb [path]="routes.Users"></app-breadcrumb>
|
||||
|
||||
<div class="filter-form">
|
||||
<button mat-flat-button color="success" [routerLink]="routes.Users_CREATE">
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="success"
|
||||
class="filter-button"
|
||||
(click)="addFilter()"
|
||||
>
|
||||
Add filter
|
||||
</button>
|
||||
|
||||
@if (showFilters) {
|
||||
<mat-card appearance="outlined" class="card">
|
||||
<app-filter
|
||||
[filters]="filters"
|
||||
[config]="config"
|
||||
(clearFilterConfirmed)="clearFilters()"
|
||||
(deleteFilterConfirmed)="delFilter()"
|
||||
(submitConfirmed)="submitHandler($event)"
|
||||
>
|
||||
</app-filter>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-card appearance="outlined" class="card">
|
||||
<mat-card-content class="card-content">
|
||||
<a href="{{ redirectToSwagger() }}">API documentation for users</a>
|
||||
<div class="table-title-wrapper">
|
||||
<p class="table-title">Users</p>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
matSort
|
||||
matSortDisableClear="true"
|
||||
class="table"
|
||||
(matSortChange)="sort($event)"
|
||||
>
|
||||
<!-- First Name Column -->
|
||||
|
||||
<ng-container matColumnDef="firstName">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
class="table-header"
|
||||
mat-sort-header
|
||||
>
|
||||
First Name
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let row"
|
||||
class="table-body"
|
||||
(click)="edit(row)"
|
||||
>
|
||||
{{ row.firstName }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Last Name Column -->
|
||||
|
||||
<ng-container matColumnDef="lastName">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
class="table-header"
|
||||
mat-sort-header
|
||||
>
|
||||
Last Name
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let row"
|
||||
class="table-body"
|
||||
(click)="edit(row)"
|
||||
>
|
||||
{{ row.lastName }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Phone Number Column -->
|
||||
|
||||
<ng-container matColumnDef="phoneNumber">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
class="table-header"
|
||||
mat-sort-header
|
||||
>
|
||||
Phone Number
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let row"
|
||||
class="table-body"
|
||||
(click)="edit(row)"
|
||||
>
|
||||
{{ row.phoneNumber }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- E-Mail Column -->
|
||||
|
||||
<ng-container matColumnDef="email">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
class="table-header"
|
||||
mat-sort-header
|
||||
>
|
||||
E-Mail
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let row"
|
||||
class="table-body"
|
||||
(click)="edit(row)"
|
||||
>
|
||||
{{ row.email }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Role Column -->
|
||||
|
||||
<ng-container matColumnDef="role">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
class="table-header"
|
||||
mat-sort-header
|
||||
>
|
||||
Role
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let row"
|
||||
class="table-body"
|
||||
(click)="edit(row)"
|
||||
>
|
||||
{{ row.role }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Disabled Column -->
|
||||
|
||||
<ng-container matColumnDef="disabled">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
class="table-header"
|
||||
mat-sort-header
|
||||
>
|
||||
Disabled
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let row"
|
||||
class="table-body"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
[checked]="row.disabled"
|
||||
></mat-checkbox>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Avatar Column -->
|
||||
|
||||
<ng-container matColumnDef="avatar">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
class="table-header"
|
||||
mat-sort-header
|
||||
>
|
||||
Avatar
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let row"
|
||||
class="table-body"
|
||||
(click)="edit(row)"
|
||||
>
|
||||
@if (row.avatar.length) {
|
||||
<img
|
||||
[src]="dataFormatterService.oneImageFormatter(row.avatar)"
|
||||
alt="..."
|
||||
class="table-img"
|
||||
title="image"
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Action Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="table-header">
|
||||
Actions
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let element"
|
||||
class="table-body"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="table-buttons-wrapper">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="success"
|
||||
class="table-button"
|
||||
(click)="edit(element)"
|
||||
>
|
||||
edit
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="warn"
|
||||
class="table-button"
|
||||
(click)="openDeleteModal(element.id)"
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
(click)="edit(row)"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[pageSizeOptions]="[10, 20, 50, 100]"
|
||||
showFirstLastButtons
|
||||
(page)="setLimit($event)"
|
||||
></mat-paginator>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
209
src/app/modules/CRUD/users-list/users-list.component.scss
Normal file
209
src/app/modules/CRUD/users-list/users-list.component.scss
Normal file
@ -0,0 +1,209 @@
|
||||
@use 'src/app/styles/font' as *;
|
||||
@use 'src/app/styles/variables' as *;
|
||||
|
||||
.card,
|
||||
.filter-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.card-title-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: $small) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: -30px 0 0 0;
|
||||
|
||||
@media (max-width: $small) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: $fw-lighter;
|
||||
color: $grey;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 62%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16px 0 8px 0;
|
||||
|
||||
@media (max-width: $small) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.text-field {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
font-size: 14px;
|
||||
|
||||
@media (max-width: $small) {
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: $light-grey;
|
||||
margin: 0 8px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-title-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: $fs-regular;
|
||||
font-weight: $fw-lighter;
|
||||
color: $dark-grey;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-title-icon {
|
||||
color: $dark-grey;
|
||||
cursor: pointer;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: $black-04;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
font-size: 15px;
|
||||
font-weight: $fw-normal;
|
||||
color: $dark-grey;
|
||||
padding: 14px 38px 14px 24px;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
font-size: 15px;
|
||||
font-weight: $fw-lighter;
|
||||
color: $dark-grey;
|
||||
padding: 14px 38px 14px 24px;
|
||||
}
|
||||
|
||||
.table-buttons-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.table-button {
|
||||
min-height: 30px;
|
||||
height: 30px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
&:first-child {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
color: $blue;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.rating-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-id {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.product-pop {
|
||||
font-size: $fs-normal;
|
||||
color: $yellow;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-pop-icon {
|
||||
font-size: 23px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mat-success {
|
||||
background-color: $green;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 191px;
|
||||
height: 141px;
|
||||
background-size: cover;
|
||||
background-position: 50% center;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 191px;
|
||||
height: 141px;
|
||||
background-size: cover;
|
||||
background-position: 50% center;
|
||||
}
|
||||
.table-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.file {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
151
src/app/modules/CRUD/users-list/users-list.component.ts
Normal file
151
src/app/modules/CRUD/users-list/users-list.component.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { DataFormatterService } from '../../../shared/services/data-formatter.service';
|
||||
import { UsersService } from '../../../shared/services/users.service';
|
||||
import { routes } from '../../../consts';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DeletePopupComponent } from '../../../shared/popups/delete-popup/delete-popup.component';
|
||||
import { Users } from '../../../shared/models/users.model';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { FilterConfig, FilterItems } from '../../../shared/models/common';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { take } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-list',
|
||||
templateUrl: './users-list.component.html',
|
||||
styleUrls: ['./users-list.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class UsersListComponent implements OnInit {
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
|
||||
users: Users[];
|
||||
loading = false;
|
||||
selectedId: string;
|
||||
public routes: typeof routes = routes;
|
||||
public displayedColumns: string[] = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'phoneNumber',
|
||||
'email',
|
||||
'role',
|
||||
'disabled',
|
||||
'avatar',
|
||||
'actions',
|
||||
];
|
||||
public dataSource: MatTableDataSource<Users>;
|
||||
config: FilterConfig[] = [];
|
||||
showFilters = false;
|
||||
filters: FilterItems[] = [
|
||||
{ label: 'First Name', title: 'firstName' },
|
||||
{ label: 'Last Name', title: 'lastName' },
|
||||
{ label: 'Phone Number', title: 'phoneNumber' },
|
||||
{ label: 'E-Mail', title: 'email' },
|
||||
];
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private toastr: ToastrService,
|
||||
public dialog: MatDialog,
|
||||
public dataFormatterService: DataFormatterService,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.getUsers();
|
||||
}
|
||||
|
||||
addFilter(): void {
|
||||
!this.showFilters ? (this.showFilters = true) : null;
|
||||
this.config.push({});
|
||||
}
|
||||
|
||||
submitHandler(request: string): void {
|
||||
this.usersService.getFilteredData(request).pipe(take(1)).subscribe({
|
||||
next: (res) => {
|
||||
this.setUsersData(res.rows);
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Failed to load users');
|
||||
this.setUsersData([]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.getUsers();
|
||||
}
|
||||
|
||||
delFilter() {
|
||||
this.config.length === 0 ? (this.showFilters = false) : null;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.router.navigate([this.routes.Users_CREATE]);
|
||||
}
|
||||
|
||||
edit(row: Users): void {
|
||||
this.router.navigate([routes.Users_EDIT, row.id]);
|
||||
}
|
||||
|
||||
openDeleteModal(id: string): void {
|
||||
this.selectedId = id;
|
||||
const dialogRef = this.dialog.open(DeletePopupComponent, {
|
||||
width: '512px',
|
||||
});
|
||||
|
||||
dialogRef.componentInstance.deleteConfirmed
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
this.onDelete(this.selectedId);
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(id: string): void {
|
||||
this.usersService.delete(id).pipe(take(1)).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Users deleted successfully');
|
||||
this.getUsers();
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Something was wrong. Try again');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sort(e): void {
|
||||
this.submitHandler(`?field=${e.active}&sort=${e.direction}`);
|
||||
}
|
||||
|
||||
setLimit(e): void {
|
||||
this.submitHandler(`?limit=${e.pageSize}`);
|
||||
}
|
||||
|
||||
private getUsers(): void {
|
||||
this.usersService.getAll().pipe(take(1)).subscribe({
|
||||
next: (res) => {
|
||||
this.setUsersData(res.rows);
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Failed to load users');
|
||||
this.setUsersData([]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private setUsersData(rows: Users[]): void {
|
||||
this.users = rows;
|
||||
this.dataSource = new MatTableDataSource(rows);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
}
|
||||
|
||||
redirectToSwagger() {
|
||||
return environment.production
|
||||
? window.location.origin + '/api-docs/#/Users'
|
||||
: 'http://localhost:8080/api-docs/#/Users';
|
||||
}
|
||||
}
|
||||
26
src/app/modules/auth/auth.routes.spec.ts
Normal file
26
src/app/modules/auth/auth.routes.spec.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { AUTH_ROUTES } from './auth.routes';
|
||||
|
||||
describe('AUTH_ROUTES', () => {
|
||||
it('contains root auth page route', () => {
|
||||
const rootRoute = AUTH_ROUTES.find((route) => route.path === '');
|
||||
|
||||
expect(rootRoute).toBeDefined();
|
||||
expect(rootRoute?.component).toBeDefined();
|
||||
});
|
||||
|
||||
it('redirects /login alias to root auth route', () => {
|
||||
const loginAliasRoute = AUTH_ROUTES.find((route) => route.path === 'login');
|
||||
|
||||
expect(loginAliasRoute?.redirectTo).toBe('');
|
||||
expect(loginAliasRoute?.pathMatch).toBe('full');
|
||||
});
|
||||
|
||||
it('contains verify-email route', () => {
|
||||
const verifyEmailRoute = AUTH_ROUTES.find(
|
||||
(route) => route.path === 'verify-email',
|
||||
);
|
||||
|
||||
expect(verifyEmailRoute).toBeDefined();
|
||||
expect(verifyEmailRoute?.component).toBeDefined();
|
||||
});
|
||||
});
|
||||
20
src/app/modules/auth/auth.routes.ts
Normal file
20
src/app/modules/auth/auth.routes.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { AuthPageComponent } from './containers';
|
||||
import { VerifyEmailComponent } from './components/verify-email/verify-email.component';
|
||||
|
||||
export const AUTH_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AuthPageComponent,
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
redirectTo: '',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'verify-email',
|
||||
component: VerifyEmailComponent,
|
||||
},
|
||||
];
|
||||
2
src/app/modules/auth/components/index.ts
Normal file
2
src/app/modules/auth/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './login-form/login-form.component';
|
||||
export * from './sign-form/sign-form.component';
|
||||
@ -0,0 +1,31 @@
|
||||
<form class="form" [formGroup]="form" (ngSubmit)="login()">
|
||||
<mat-form-field class="form__input">
|
||||
<input
|
||||
matInput
|
||||
placeholder="Email Address"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="form__input">
|
||||
<input
|
||||
matInput
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
formControlName="password"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="form-actions__login"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button class="form-actions__forget" mat-button>Forget password</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -0,0 +1,27 @@
|
||||
@use '../../../../styles/colors' as *;
|
||||
@use '../../../../styles/font' as *;
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&__login {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&__forget {
|
||||
font-size: 12px;
|
||||
font-weight: $fw-lighter;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { APP_RUNTIME_CONFIG, AppRuntimeConfig } from '../../../../app.config';
|
||||
import { LoginFormComponent } from './login-form.component';
|
||||
|
||||
describe('LoginFormComponent', () => {
|
||||
let fixture: ComponentFixture<LoginFormComponent>;
|
||||
let component: LoginFormComponent;
|
||||
|
||||
const config: AppRuntimeConfig = {
|
||||
version: '1.2.0',
|
||||
remote: 'http://localhost:8080',
|
||||
isBackend: true,
|
||||
hostApi: 'http://localhost',
|
||||
portApi: '8080',
|
||||
baseURLApi: 'http://localhost:8080',
|
||||
auth: {
|
||||
email: 'admin@flatlogic.com',
|
||||
password: 'password',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LoginFormComponent],
|
||||
providers: [{ provide: APP_RUNTIME_CONFIG, useValue: config }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(LoginFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('does not emit login payload when form is invalid', () => {
|
||||
const emitSpy = jest.spyOn(component.sendLoginForm, 'emit');
|
||||
component.form.setValue({
|
||||
email: 'not-valid-email',
|
||||
password: '',
|
||||
});
|
||||
|
||||
component.login();
|
||||
|
||||
expect(component.form.invalid).toBe(true);
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits credentials when form is valid', () => {
|
||||
const emitSpy = jest.spyOn(component.sendLoginForm, 'emit');
|
||||
const payload = {
|
||||
email: 'user@example.com',
|
||||
password: 'StrongPassword1',
|
||||
};
|
||||
component.form.setValue(payload);
|
||||
|
||||
component.login();
|
||||
|
||||
expect(component.form.valid).toBe(true);
|
||||
expect(emitSpy).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,52 @@
|
||||
import { Component, EventEmitter, Inject, OnInit, Output } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { APP_RUNTIME_CONFIG, AppRuntimeConfig } from '../../../../app.config';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
type LoginFormValue = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
templateUrl: './login-form.component.html',
|
||||
styleUrls: ['./login-form.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, MatInputModule, MatFormFieldModule, MatButtonModule]
|
||||
})
|
||||
export class LoginFormComponent implements OnInit {
|
||||
@Output() sendLoginForm = new EventEmitter<LoginFormValue>();
|
||||
public form!: FormGroup<{
|
||||
email: FormControl<string>;
|
||||
password: FormControl<string>;
|
||||
}>;
|
||||
public email: string;
|
||||
public password: string;
|
||||
|
||||
constructor(@Inject(APP_RUNTIME_CONFIG) appConfig: AppRuntimeConfig) {
|
||||
this.email = appConfig.auth.email;
|
||||
this.password = appConfig.auth.password;
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.form = new FormGroup({
|
||||
email: new FormControl(this.email, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.email],
|
||||
}),
|
||||
password: new FormControl(this.password, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public login(): void {
|
||||
if (this.form.valid) {
|
||||
this.sendLoginForm.emit(this.form.getRawValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
@if (authService.errorMessage) {
|
||||
<div
|
||||
class="notification notification_transparent-pink"
|
||||
>
|
||||
<div class="notification__icon-wrapper notification__icon-wrapper_solid-pink">
|
||||
<mat-icon>report</mat-icon>
|
||||
</div>
|
||||
<p class="notification__title">{{ authService.errorMessage }}</p>
|
||||
</div>
|
||||
}
|
||||
<form class="form" [formGroup]="form" (ngSubmit)="register()">
|
||||
<mat-form-field class="form__input">
|
||||
<input
|
||||
matInput
|
||||
placeholder="Email Address"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="form__input">
|
||||
<input
|
||||
matInput
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
formControlName="password"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="form__input">
|
||||
<input
|
||||
matInput
|
||||
placeholder="Confirm Password"
|
||||
type="password"
|
||||
formControlName="confirmPassword"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="form-actions__create"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
>
|
||||
Create your account
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -0,0 +1,177 @@
|
||||
@use '../../../../styles/colors' as *;
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
margin-top: -15px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 5px;
|
||||
|
||||
&__create {
|
||||
margin: 0;
|
||||
width: 95%;
|
||||
box-shadow: 0 0 11px 0 $shadow-white, 0 0 0 -2px $shadow-grey,
|
||||
0 1px 8px 0 $shadow-dark-grey;
|
||||
}
|
||||
}
|
||||
|
||||
.notification {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
height: 45px;
|
||||
border-radius: 45px;
|
||||
|
||||
&_solid {
|
||||
&-pink {
|
||||
background-color: $pink;
|
||||
}
|
||||
|
||||
&-blue {
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
&-green {
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
&-yellow {
|
||||
background-color: $yellow;
|
||||
}
|
||||
|
||||
&-violet {
|
||||
background-color: $violet;
|
||||
}
|
||||
}
|
||||
|
||||
&_transparent {
|
||||
&-pink {
|
||||
background-color: $pink-15;
|
||||
}
|
||||
|
||||
&-blue {
|
||||
background-color: $blue-15;
|
||||
}
|
||||
|
||||
&-green {
|
||||
background-color: $green-15;
|
||||
}
|
||||
|
||||
&-yellow {
|
||||
background-color: $yellow-15;
|
||||
}
|
||||
|
||||
&-violet {
|
||||
background-color: $violet-15;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
|
||||
&_white {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-wrapper {
|
||||
height: 45px;
|
||||
width: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&_transparent {
|
||||
color: $white-80;
|
||||
|
||||
&-pink {
|
||||
color: $pink;
|
||||
}
|
||||
|
||||
&-blue {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&-green {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&-yellow {
|
||||
color: $yellow;
|
||||
}
|
||||
|
||||
&-violet {
|
||||
color: $violet;
|
||||
}
|
||||
}
|
||||
|
||||
&_solid {
|
||||
&-pink {
|
||||
color: $pink;
|
||||
}
|
||||
|
||||
&-blue {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&-green {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&-yellow {
|
||||
color: $yellow;
|
||||
}
|
||||
|
||||
&-violet {
|
||||
color: $violet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-wrapper-circle {
|
||||
height: 45px;
|
||||
width: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 45px;
|
||||
margin-right: 16px;
|
||||
|
||||
&_transparent {
|
||||
&-pink {
|
||||
color: $pink;
|
||||
background-color: $pink-15;
|
||||
}
|
||||
|
||||
&-blue {
|
||||
color: $blue;
|
||||
background-color: $blue-15;
|
||||
}
|
||||
|
||||
&-green {
|
||||
color: $green;
|
||||
background-color: $green-15;
|
||||
}
|
||||
|
||||
&-yellow {
|
||||
color: $yellow;
|
||||
background-color: $yellow-15;
|
||||
}
|
||||
|
||||
&-violet {
|
||||
color: $violet;
|
||||
background-color: $violet-15;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { AuthService } from '../../../../shared/services/auth.service';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
type SignFormValue = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-sign-form',
|
||||
templateUrl: './sign-form.component.html',
|
||||
styleUrls: ['./sign-form.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
]
|
||||
})
|
||||
export class SignFormComponent implements OnInit {
|
||||
@Output() sendSignForm = new EventEmitter<SignFormValue>();
|
||||
public form!: FormGroup<{
|
||||
email: FormControl<string>;
|
||||
password: FormControl<string>;
|
||||
confirmPassword: FormControl<string>;
|
||||
}>;
|
||||
|
||||
constructor(public authService: AuthService) {}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.form = new FormGroup({
|
||||
email: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.email],
|
||||
}),
|
||||
password: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
confirmPassword: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public register(): void {
|
||||
const { email } = this.form.getRawValue();
|
||||
|
||||
if (!email) {
|
||||
this.authService.registerError('Email is required!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isPasswordValid()) {
|
||||
this.checkPassword();
|
||||
} else {
|
||||
this.sendSignForm.emit(this.form.getRawValue());
|
||||
}
|
||||
}
|
||||
|
||||
public checkPassword(): void {
|
||||
const { password } = this.form.getRawValue();
|
||||
if (!this.isPasswordValid()) {
|
||||
if (!password) {
|
||||
this.authService.registerError('Password field is empty');
|
||||
} else {
|
||||
this.authService.registerError('Passwords are not equal');
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.authService.registerError('');
|
||||
}, 3 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public isPasswordValid(): boolean {
|
||||
const { password, confirmPassword } = this.form.getRawValue();
|
||||
return password && password === confirmPassword;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { Component, DestroyRef, inject } from '@angular/core';
|
||||
import { AuthService } from '../../../../shared/services/auth.service';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
@Component({
|
||||
selector: 'app-verify-email',
|
||||
template: '',
|
||||
standalone: true
|
||||
})
|
||||
export class VerifyEmailComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(public authService: AuthService, private route: ActivatedRoute) {
|
||||
this.route.queryParams
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((params: { token?: string }) => {
|
||||
if (params.token) {
|
||||
this.authService.verifyEmail(params.token);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__content-block">
|
||||
<div class="auth-page__content-wrapper">
|
||||
<mat-tab-group class="auth-page__group" mat-stretch-tabs>
|
||||
<mat-tab label="Login">
|
||||
<h4 class="auth-page__group-title">Welcome!</h4>
|
||||
<div class="auth-page__google-button-wrapper">
|
||||
<button
|
||||
mat-raised-button
|
||||
class="auth-page__google-button"
|
||||
(click)="googleLogin()"
|
||||
>
|
||||
<img
|
||||
class="auth-page__google-button-icon"
|
||||
src="./assets/auth/google.svg"
|
||||
alt="google"
|
||||
/>Sign in with Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-page__border-wrapper">
|
||||
<div class="auth-page__border-line"></div>
|
||||
<p class="auth-page__border-title">or</p>
|
||||
<div class="auth-page__border-line"></div>
|
||||
</div>
|
||||
|
||||
<app-login-form
|
||||
(sendLoginForm)="sendLoginForm($event)"
|
||||
></app-login-form>
|
||||
</mat-tab>
|
||||
<mat-tab label="New User">
|
||||
<h4 class="auth-page__group-title">Welcome!</h4>
|
||||
<p class="auth-page__group-sub-title">Create you account</p>
|
||||
|
||||
<app-sign-form (sendSignForm)="sendSignForm($event)"></app-sign-form>
|
||||
|
||||
<div class="auth-page__border-wrapper">
|
||||
<div class="auth-page__border-line"></div>
|
||||
<p class="auth-page__border-title">or</p>
|
||||
<div class="auth-page__border-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="auth-page__google-button-wrapper">
|
||||
<button
|
||||
mat-raised-button
|
||||
class="auth-page__google-button"
|
||||
(click)="googleLogin()"
|
||||
>
|
||||
<img
|
||||
class="auth-page__google-button-icon"
|
||||
src="./assets/auth/google.svg"
|
||||
alt="google"
|
||||
/>Sign in with Google
|
||||
</button>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
<p class="auth-page__footer-title">
|
||||
© 2014-{{ todayDate | year }}
|
||||
<a href="https://flatlogic.com">Flatlogic</a>, LLC. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-page__logo">
|
||||
<div class="auth-page__logo-wrapper">
|
||||
<img
|
||||
class="auth-page__logo-img"
|
||||
src="./assets/auth/logo.svg"
|
||||
alt="logo"
|
||||
/>
|
||||
<h6 class="auth-page__logo-title">Angular Material Admin Full</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,151 @@
|
||||
@use '../../../../styles/variables' as *;
|
||||
@use '../../../../styles/font' as *;
|
||||
|
||||
.auth-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
@media (max-width: $medium) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__content-block {
|
||||
width: 37%;
|
||||
height: 100%;
|
||||
background-color: $white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: $medium) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__content-wrapper {
|
||||
width: 45%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: $small) {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
@media (min-width: $medium) and (max-width: $large) {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
&__group {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
font-size: 32px;
|
||||
font-weight: $fw-normal;
|
||||
margin-top: 37px;
|
||||
letter-spacing: -0.7px;
|
||||
text-align: center;
|
||||
line-height: 37px;
|
||||
color: $dark-grey;
|
||||
}
|
||||
|
||||
&__group-sub-title {
|
||||
font-size: $fs-large;
|
||||
font-weight: $fw-normal;
|
||||
margin-bottom: 60px;
|
||||
letter-spacing: -0.5px;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
color: $dark-grey;
|
||||
}
|
||||
|
||||
&__google-button-wrapper {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__google-button {
|
||||
width: 95%;
|
||||
padding: 0;
|
||||
background-color: $white;
|
||||
box-shadow: 0 0 11px 0 $shadow-white, 0 0 0 -2px $shadow-grey,
|
||||
0 1px 8px 0 $shadow-dark-grey;
|
||||
}
|
||||
|
||||
&__google-button-icon {
|
||||
width: 20px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__border-wrapper {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
&__border-line {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: $light-grey;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&__border-title {
|
||||
font-size: $fs-xs;
|
||||
padding: 0 16px;
|
||||
margin: 0;
|
||||
color: $dark-grey;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
width: 63%;
|
||||
height: 100%;
|
||||
background-color: $blue;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: $medium) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__logo-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__logo-img {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
&__logo-title {
|
||||
font-size: 62px;
|
||||
color: $white;
|
||||
font-weight: 500;
|
||||
letter-spacing: -1.5px;
|
||||
line-height: 62px;
|
||||
}
|
||||
|
||||
&__footer-title {
|
||||
color: $blue;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { AuthPageComponent } from './auth-page.component';
|
||||
import {
|
||||
AuthService,
|
||||
LoginCredentials,
|
||||
RegisterCredentials,
|
||||
} from '../../../../shared/services/auth.service';
|
||||
|
||||
describe('AuthPageComponent', () => {
|
||||
let fixture: ComponentFixture<AuthPageComponent>;
|
||||
let component: AuthPageComponent;
|
||||
|
||||
const queryParams$ = new BehaviorSubject<Record<string, string>>({});
|
||||
const authServiceMock = {
|
||||
isAuthenticated: jest.fn(),
|
||||
receiveLogin: jest.fn(),
|
||||
receiveToken: jest.fn(),
|
||||
loginUser: jest.fn(),
|
||||
registerUser: jest.fn(),
|
||||
} as unknown as AuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
(authServiceMock.isAuthenticated as jest.Mock).mockReturnValue(false);
|
||||
queryParams$.next({});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AuthPageComponent],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParams: queryParams$.asObservable(),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideTemplate(AuthPageComponent, '')
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AuthPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls receiveLogin in constructor when already authenticated', () => {
|
||||
(authServiceMock.isAuthenticated as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const localFixture = TestBed.createComponent(AuthPageComponent);
|
||||
localFixture.detectChanges();
|
||||
|
||||
expect(authServiceMock.receiveLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls receiveToken when token is present in query params', () => {
|
||||
queryParams$.next({ token: 'jwt-token' });
|
||||
const localFixture = TestBed.createComponent(AuthPageComponent);
|
||||
localFixture.detectChanges();
|
||||
|
||||
expect(authServiceMock.receiveToken).toHaveBeenCalledWith('jwt-token');
|
||||
});
|
||||
|
||||
it('delegates login form payload to AuthService.loginUser', () => {
|
||||
const creds: LoginCredentials = {
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
component.sendLoginForm(creds);
|
||||
|
||||
expect(authServiceMock.loginUser).toHaveBeenCalledWith(creds);
|
||||
});
|
||||
|
||||
it('delegates sign form payload to AuthService.registerUser', () => {
|
||||
const creds: RegisterCredentials = {
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
confirmPassword: 'secret',
|
||||
};
|
||||
|
||||
component.sendSignForm(creds);
|
||||
|
||||
expect(authServiceMock.registerUser).toHaveBeenCalledWith(creds);
|
||||
});
|
||||
|
||||
it('triggers google social login', () => {
|
||||
component.googleLogin();
|
||||
|
||||
expect(authServiceMock.loginUser).toHaveBeenCalledWith({ social: 'google' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
import { Component, DestroyRef, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Params } from '@angular/router';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { routes } from '../../../../consts';
|
||||
import {
|
||||
AuthService,
|
||||
LoginCredentials,
|
||||
RegisterCredentials,
|
||||
} from '../../../../shared/services/auth.service';
|
||||
import { LoginFormComponent } from '../../components/login-form/login-form.component';
|
||||
import { SignFormComponent } from '../../components/sign-form/sign-form.component';
|
||||
import { YearPipe } from '../../pipes/year.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-page',
|
||||
templateUrl: './auth-page.component.html',
|
||||
styleUrls: ['./auth-page.component.scss'],
|
||||
standalone: true,
|
||||
imports: [MatTabsModule, MatButtonModule, LoginFormComponent, SignFormComponent, YearPipe]
|
||||
})
|
||||
export class AuthPageComponent {
|
||||
public todayDate: Date = new Date();
|
||||
public routers: typeof routes = routes;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.authService.receiveLogin();
|
||||
}
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((params: Params) => {
|
||||
const token = params['token'];
|
||||
if (token) {
|
||||
this.authService.receiveToken(String(token));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public sendLoginForm(creds: LoginCredentials): void {
|
||||
this.authService.loginUser(creds);
|
||||
}
|
||||
|
||||
public sendSignForm(creds: RegisterCredentials): void {
|
||||
this.authService.registerUser(creds);
|
||||
}
|
||||
|
||||
public googleLogin() {
|
||||
this.authService.loginUser({ social: 'google' });
|
||||
}
|
||||
}
|
||||
1
src/app/modules/auth/containers/index.ts
Normal file
1
src/app/modules/auth/containers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './auth-page/auth-page.component';
|
||||
44
src/app/modules/auth/guards/auth.guard.spec.ts
Normal file
44
src/app/modules/auth/guards/auth.guard.spec.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router, UrlTree } from '@angular/router';
|
||||
|
||||
import { authGuard } from './auth.guard';
|
||||
import { AUTH_TOKEN_STORAGE_KEY, routes } from '../../../consts';
|
||||
|
||||
describe('authGuard', () => {
|
||||
const createGuardArgs = (): Parameters<typeof authGuard> => [
|
||||
{} as Parameters<typeof authGuard>[0],
|
||||
{} as Parameters<typeof authGuard>[1],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([])],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it('returns true when token exists in localStorage', () => {
|
||||
localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, 'token');
|
||||
const [route, state] = createGuardArgs();
|
||||
|
||||
const result = TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('redirects to login when token is missing', () => {
|
||||
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
||||
const router = TestBed.inject(Router);
|
||||
const [route, state] = createGuardArgs();
|
||||
|
||||
const result = TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||
|
||||
expect(result instanceof UrlTree).toBe(true);
|
||||
if (result instanceof UrlTree) {
|
||||
expect(router.serializeUrl(result)).toBe(routes.LOGIN);
|
||||
}
|
||||
});
|
||||
});
|
||||
16
src/app/modules/auth/guards/auth.guard.ts
Normal file
16
src/app/modules/auth/guards/auth.guard.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
|
||||
import { AUTH_TOKEN_STORAGE_KEY, routes } from '../../../consts';
|
||||
|
||||
const ROUTES: typeof routes = routes;
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const token = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
|
||||
if (token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const router = inject(Router);
|
||||
return router.parseUrl(ROUTES.LOGIN);
|
||||
};
|
||||
1
src/app/modules/auth/guards/index.ts
Normal file
1
src/app/modules/auth/guards/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './auth.guard';
|
||||
5
src/app/modules/auth/models/email.ts
Normal file
5
src/app/modules/auth/models/email.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface Email {
|
||||
name: string;
|
||||
time: string;
|
||||
message: string
|
||||
}
|
||||
2
src/app/modules/auth/models/index.ts
Normal file
2
src/app/modules/auth/models/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './user';
|
||||
export * from './email';
|
||||
4
src/app/modules/auth/models/user.ts
Normal file
4
src/app/modules/auth/models/user.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface User {
|
||||
name: string;
|
||||
lastName: string;
|
||||
}
|
||||
1
src/app/modules/auth/pipes/index.ts
Normal file
1
src/app/modules/auth/pipes/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './year.pipe';
|
||||
18
src/app/modules/auth/pipes/year.pipe.ts
Normal file
18
src/app/modules/auth/pipes/year.pipe.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'year',
|
||||
standalone: true
|
||||
})
|
||||
export class YearPipe implements PipeTransform {
|
||||
private readonly datePipe = new DatePipe('en-US');
|
||||
|
||||
transform(value: Date | string | number | null | undefined): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.datePipe.transform(value, 'y');
|
||||
}
|
||||
}
|
||||
18
src/app/modules/auth/services/email.service.ts
Normal file
18
src/app/modules/auth/services/email.service.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
|
||||
import { Email } from '../models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class EmailService {
|
||||
public loadEmails(): Observable<Email[]> {
|
||||
return of([
|
||||
{name: 'Jane Hew', time: '9:32', message: 'Hey! How is it going?'},
|
||||
{name: 'Lloyd Brown', time: '9:18', message: 'Check out my new Dashboard'},
|
||||
{name: 'Mark Winstein', time: '9:15', message: 'I want rearrange the appointment'},
|
||||
{name: 'Liana Dutti', time: '9:09', message: 'Good news from sale department'}
|
||||
])
|
||||
}
|
||||
}
|
||||
1
src/app/modules/auth/services/index.ts
Normal file
1
src/app/modules/auth/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './email.service';
|
||||
@ -0,0 +1,41 @@
|
||||
<mat-card class="chart">
|
||||
<mat-card-title class="chart__header">
|
||||
<p class="chart__title">Daily Line Chart</p>
|
||||
<div class="chart-legend">
|
||||
<div class="chart-legend__item">
|
||||
<div class="chart-legend__icon yellow"></div><span class="chart-legend__title">Tablet</span>
|
||||
</div>
|
||||
<div class="chart-legend__item">
|
||||
<div
|
||||
class="chart-legend__icon"
|
||||
[ngClass]="{
|
||||
'blue' : currentTheme === 'blue',
|
||||
'pink' : currentTheme === 'pink',
|
||||
'green' : currentTheme === 'green'
|
||||
}"
|
||||
></div>
|
||||
<span class="chart-legend__title">Mobile</span>
|
||||
</div>
|
||||
<div class="chart-legend__item">
|
||||
<div class="chart-legend__icon light-blue"></div><span class="chart-legend__title">Desktop</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-select class="chart-select" [(ngModel)]="selectedMatSelectValue" (selectionChange)="changedMatSelectionValue()">
|
||||
<mat-option class="select-option" [value]="matSelectFields.daily">Daily</mat-option>
|
||||
<mat-option class="select-option" [value]="matSelectFields.weekly">Weekly</mat-option>
|
||||
<mat-option class="select-option" [value]="matSelectFields.monthly">Monthly</mat-option>
|
||||
</mat-select>
|
||||
|
||||
</mat-card-title>
|
||||
|
||||
<mat-card-content class="chart__content">
|
||||
<div
|
||||
echarts
|
||||
class="chart__content-item"
|
||||
[options]="echartsOptions"
|
||||
[style.height]="chartOptions?.chart?.height | chartSize:'350px'"
|
||||
[style.width]="chartOptions?.chart?.width | chartSize:'100%'"
|
||||
></div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@ -0,0 +1,118 @@
|
||||
@use "../../../../styles/font" as *;
|
||||
@use "../../../../styles/variables" as *;
|
||||
|
||||
.chart {
|
||||
margin: 24px 0;
|
||||
|
||||
@media (max-width: $normal) {
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
|
||||
@media (max-width: $small) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: $grey;
|
||||
font-weight: $fw-normal;
|
||||
font-size: $fs-regular;
|
||||
text-transform: none;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.12px;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
&__content {
|
||||
height: 380px;
|
||||
width: 100%;
|
||||
@media (max-width: $small) {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
&__content-item {
|
||||
@media (max-width: $small) {
|
||||
width: 600px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
order: 2;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 2.24px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: $fw-lighter;
|
||||
text-transform: none;
|
||||
font-size: $fs-regular;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: $small) {
|
||||
margin-top: 20px;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.chart-select {
|
||||
order: 3;
|
||||
border: 0;
|
||||
width: auto;
|
||||
|
||||
@media (max-width: $small) {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
font-size: $fs-normal;
|
||||
font-weight: $fw-lighter;
|
||||
color: $dark-grey;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background-color: $yellow;
|
||||
}
|
||||
|
||||
.blue {
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
.pink {
|
||||
background-color: $pink;
|
||||
}
|
||||
|
||||
.green {
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.light-blue {
|
||||
background-color: $white-blue;
|
||||
}
|
||||
|
||||
@ -0,0 +1,320 @@
|
||||
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
|
||||
import { DailyLineChartData, TimeData } from '../../models';
|
||||
import { colors } from '../../../../consts';
|
||||
import { customTooltip } from '../../consts';
|
||||
import { ChartOptions } from '../../../templates/charts/models/chart-options';
|
||||
import { ChartSizePipe } from '../../../../shared/pipes/chart-size.pipe';
|
||||
import { NgxEchartsModule } from 'ngx-echarts';
|
||||
|
||||
enum matSelectedFields {
|
||||
daily = 'Daily',
|
||||
weekly = 'Weekly',
|
||||
monthly = 'Monthly'
|
||||
}
|
||||
|
||||
type LineSeriesItem = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
data?: unknown[];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-daily-line-chart',
|
||||
templateUrl: './daily-line-chart.component.html',
|
||||
styleUrls: ['./daily-line-chart.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatCardModule,
|
||||
MatSelectModule,
|
||||
ChartSizePipe,
|
||||
NgxEchartsModule,
|
||||
]
|
||||
})
|
||||
export class DailyLineChartComponent implements OnInit, OnChanges {
|
||||
@Input() dailyLineChartData: DailyLineChartData;
|
||||
@Input() currentTheme: string;
|
||||
@Input() currentMode: string;
|
||||
public chartOptions: Partial<ChartOptions> = {};
|
||||
public matSelectFields: typeof matSelectedFields = matSelectedFields;
|
||||
public selectedMatSelectValue = matSelectedFields.monthly;
|
||||
public colors: typeof colors = colors;
|
||||
|
||||
public get echartsOptions(): EChartsOption {
|
||||
const options = this.chartOptions;
|
||||
const labels = Array.isArray(options?.labels) ? options.labels : [];
|
||||
const strokeWidth = this.getNumberArray((options?.stroke as { width?: unknown } | undefined)?.width);
|
||||
const strokeCurve = this.getStringArray((options?.stroke as { curve?: unknown } | undefined)?.curve);
|
||||
const markerSizes = this.getNumberArray((options?.markers as { size?: unknown } | undefined)?.size);
|
||||
const sourceSeries = this.getSeriesArray(options?.series);
|
||||
const isStacked = (options?.chart as { stacked?: boolean } | undefined)?.['stacked'] ?? false;
|
||||
|
||||
const series = sourceSeries.map((item: LineSeriesItem, index: number) => {
|
||||
const isArea = item.type === 'area';
|
||||
return {
|
||||
type: 'line',
|
||||
name: item.name ?? '',
|
||||
data: item.data ?? [],
|
||||
smooth: (strokeCurve[index] ?? strokeCurve[0]) === 'smooth',
|
||||
showSymbol: (markerSizes[index] ?? markerSizes[0] ?? 0) > 0,
|
||||
symbolSize: markerSizes[index] ?? markerSizes[0] ?? 0,
|
||||
lineStyle: { width: strokeWidth[index] ?? strokeWidth[0] ?? 2 },
|
||||
areaStyle: isArea ? { opacity: 0.35 } : undefined,
|
||||
stack: isStacked ? 'total' : undefined
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
color: options?.colors ?? [colors.PINK, colors.LIGHT_BLUE, colors.YELLOW],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { show: false },
|
||||
grid: {
|
||||
top: 8,
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 16,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
boundaryGap: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { show: false }
|
||||
},
|
||||
series
|
||||
} as EChartsOption;
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.initChart(this.dailyLineChartData.monthlyData, this.dailyLineChartData.labels);
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['currentTheme']?.currentValue && this.chartOptions?.series) {
|
||||
this.updateChartOptions();
|
||||
}
|
||||
if (changes['currentMode']?.currentValue && this.chartOptions?.series) {
|
||||
this.updateChartOptions();
|
||||
}
|
||||
}
|
||||
|
||||
private updateChartOptions(): void {
|
||||
this.chartOptions = {
|
||||
...this.chartOptions,
|
||||
colors: [
|
||||
this.currentTheme === 'blue'
|
||||
? colors.BLUE
|
||||
: this.currentTheme === 'green'
|
||||
? colors.GREEN
|
||||
: colors.PINK,
|
||||
this.currentMode === 'dark'
|
||||
? colors.DARK_BLUE
|
||||
: colors.LIGHT_BLUE,
|
||||
colors.YELLOW
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
public initChart(data: TimeData, labels: string[]): void {
|
||||
this.chartOptions = {
|
||||
legend: {
|
||||
show: false
|
||||
},
|
||||
markers: {
|
||||
size: [0, 0, 5]
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Mobile',
|
||||
type: 'line',
|
||||
data: data.mobile,
|
||||
},
|
||||
{
|
||||
name: 'Desktop',
|
||||
type: 'area',
|
||||
data: data.desktop
|
||||
},
|
||||
{
|
||||
name: 'Tablet',
|
||||
type: 'line',
|
||||
data: data.tablet
|
||||
}
|
||||
],
|
||||
colors: [
|
||||
this.currentTheme === 'blue'
|
||||
? colors.BLUE
|
||||
: this.currentTheme === 'green'
|
||||
? colors.GREEN
|
||||
: colors.PINK,
|
||||
colors.LIGHT_BLUE,
|
||||
colors.YELLOW
|
||||
],
|
||||
chart: {
|
||||
toolbar: {
|
||||
show: false
|
||||
},
|
||||
height: 350,
|
||||
width: '100%',
|
||||
type: 'line',
|
||||
stacked: true
|
||||
},
|
||||
stroke: {
|
||||
width: [2, 0, 2],
|
||||
curve: ['smooth', 'smooth', 'straight']
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: '50%'
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
inverseColors: false,
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
opacityFrom: 0.85,
|
||||
opacityTo: 0.55,
|
||||
stops: [0, 100, 100, 100]
|
||||
}
|
||||
},
|
||||
labels,
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
style: {
|
||||
colors: '#4A4A4A',
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: true,
|
||||
labels: {
|
||||
style: {
|
||||
colors: '#4A4A4A',
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
custom: () => customTooltip
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
public changedMatSelectionValue() {
|
||||
switch (this.selectedMatSelectValue) {
|
||||
case matSelectedFields.daily:
|
||||
this.chartOptions = {
|
||||
...this.chartOptions,
|
||||
series: [
|
||||
{
|
||||
name: 'Mobile',
|
||||
type: 'line',
|
||||
data: this.dailyLineChartData.dailyData.mobile,
|
||||
},
|
||||
{
|
||||
name: 'Desktop',
|
||||
type: 'area',
|
||||
data: this.dailyLineChartData.dailyData.desktop,
|
||||
},
|
||||
{
|
||||
name: 'Tablet',
|
||||
type: 'line',
|
||||
data: this.dailyLineChartData.dailyData.tablet,
|
||||
}
|
||||
]
|
||||
};
|
||||
break;
|
||||
case matSelectedFields.weekly:
|
||||
this.chartOptions = {
|
||||
...this.chartOptions,
|
||||
series: [
|
||||
{
|
||||
name: 'Mobile',
|
||||
type: 'line',
|
||||
data: this.dailyLineChartData.weeklyData.mobile,
|
||||
},
|
||||
{
|
||||
name: 'Desktop',
|
||||
type: 'area',
|
||||
data: this.dailyLineChartData.weeklyData.desktop,
|
||||
},
|
||||
{
|
||||
name: 'Tablet',
|
||||
type: 'line',
|
||||
data: this.dailyLineChartData.weeklyData.tablet,
|
||||
}
|
||||
]
|
||||
};
|
||||
break;
|
||||
default:
|
||||
this.chartOptions = {
|
||||
...this.chartOptions,
|
||||
series: [
|
||||
{
|
||||
name: 'Mobile',
|
||||
type: 'line',
|
||||
data: this.dailyLineChartData.monthlyData.mobile,
|
||||
},
|
||||
{
|
||||
name: 'Desktop',
|
||||
type: 'area',
|
||||
data: this.dailyLineChartData.monthlyData.desktop,
|
||||
},
|
||||
{
|
||||
name: 'Tablet',
|
||||
type: 'line',
|
||||
data: this.dailyLineChartData.monthlyData.tablet,
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getNumberArray(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is number => typeof item === 'number');
|
||||
}
|
||||
|
||||
private getStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
private getSeriesArray(value: unknown): LineSeriesItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter(
|
||||
(item): item is LineSeriesItem =>
|
||||
typeof item === 'object' && item !== null,
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/app/modules/dashboard/components/index.ts
Normal file
7
src/app/modules/dashboard/components/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export * from './visits-chart/visits-chart.component';
|
||||
export * from './performance-chart/performance-chart.component';
|
||||
export * from './revenue-chart/revenue-chart.component';
|
||||
export * from './server-chart/server-chart.component';
|
||||
export * from './daily-line-chart/daily-line-chart.component';
|
||||
export * from './support-requests/support-requests.component';
|
||||
export * from './project-stat-chart/project-stat-chart.component';
|
||||
@ -0,0 +1,43 @@
|
||||
<mat-card class="performance-chart">
|
||||
<mat-card-title class="performance-chart__header">
|
||||
<p class="performance-chart__header-title">App Performance</p>
|
||||
|
||||
<app-settings-menu></app-settings-menu>
|
||||
</mat-card-title>
|
||||
|
||||
<mat-card-content class="performance-chart__content">
|
||||
<div class="performance-chart__content-header">
|
||||
<div class="performance-chart__content-header-item">
|
||||
<div
|
||||
class="performance-chart__content-header-item-icon"
|
||||
[ngClass]="{
|
||||
'blue' : currentTheme === 'blue',
|
||||
'pink' : currentTheme === 'pink',
|
||||
'green' : currentTheme === 'green'
|
||||
}"
|
||||
></div>
|
||||
<span class="performance-chart__content-header-item-text">Integration</span>
|
||||
</div>
|
||||
<div class="performance-chart__content-header-item">
|
||||
<div class="performance-chart__content-header-item-icon yellow"></div>
|
||||
<span class="performance-chart__content-header-item-text">SDK</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="performance-chart__progress-wrapper">
|
||||
<h6 class="performance-chart__progress-title">Integration</h6>
|
||||
<mat-progress-bar
|
||||
class="performance-chart__progress-bar"
|
||||
mode="determinate"
|
||||
[value]=performanceChartData.integration
|
||||
></mat-progress-bar>
|
||||
<h6 class="performance-chart__progress-title">SDK</h6>
|
||||
<mat-progress-bar
|
||||
class="performance-chart__progress-bar"
|
||||
mode="determinate"
|
||||
color="accent"
|
||||
[value]=performanceChartData.sdk
|
||||
></mat-progress-bar>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@ -0,0 +1,94 @@
|
||||
@use '../../../../styles/variables' as *;
|
||||
@use '../../../../styles/font' as *;
|
||||
|
||||
.performance-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 252px;
|
||||
|
||||
@media (min-width: 1280px) and (max-width: $xxl) {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&-title {
|
||||
font-size: $fs-regular;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
line-height: 40px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
|
||||
&-item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-right: 16px;
|
||||
|
||||
&-icon {
|
||||
border-radius: 50%;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
margin-left: 8px;
|
||||
color: $grey;
|
||||
font-size: $fs-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-title {
|
||||
margin: 20px 0 5px 0;
|
||||
font-weight: 400;
|
||||
font-size: $fs-normal;
|
||||
color: $grey;
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
margin-bottom: 8px;
|
||||
|
||||
@media ($small) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blue {
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
.pink {
|
||||
background-color: $pink;
|
||||
}
|
||||
|
||||
.green {
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background-color: $yellow;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
|
||||
import { PerformanceChartData } from '../../models';
|
||||
import { SettingsMenuComponent } from '../../../../shared/ui-elements';
|
||||
|
||||
@Component({
|
||||
selector: 'app-performance-chart',
|
||||
templateUrl: './performance-chart.component.html',
|
||||
styleUrls: ['./performance-chart.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatProgressBarModule, SettingsMenuComponent]
|
||||
})
|
||||
export class PerformanceChartComponent {
|
||||
@Input() performanceChartData: PerformanceChartData;
|
||||
@Input() currentTheme: string;
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
<div class="project-stat">
|
||||
<mat-card class="project-stat__item">
|
||||
<mat-card-title class="project-stat__title">
|
||||
<p class="project-stat__title-text">{{selectedStatsLightBlueData.name}}</p>
|
||||
|
||||
<app-date-menu (changeDateType)="changeDateType($event, projectsType.lightBlue)"></app-date-menu>
|
||||
</mat-card-title>
|
||||
<mat-card-content class="project-stat-content">
|
||||
<div class="project-stat-content__total-info-wrapper">
|
||||
<div class="project-stat-content__total-info">
|
||||
<p class="project-stat-content__total-info-users">{{selectedStatsLightBlueData.users}}</p>
|
||||
@if (selectedStatsLightBlueData.percent > 0) {
|
||||
<p
|
||||
class="project-stat-content__total-info-percent"
|
||||
>+{{selectedStatsLightBlueData.percent}}%</p>
|
||||
}
|
||||
@if (selectedStatsLightBlueData.percent < 0) {
|
||||
<p
|
||||
class="project-stat-content__total-info-percent-warn"
|
||||
>{{selectedStatsLightBlueData.percent}}%</p>
|
||||
}
|
||||
</div>
|
||||
<div class="project-stat-content__total-info-chart">
|
||||
<div
|
||||
echarts
|
||||
[options]="lightBlueEchartsOptions"
|
||||
[style.height]="chartOptions?.chart?.height | chartSize:'100px'"
|
||||
[style.width]="chartOptions?.chart?.width | chartSize:'130px'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-wrapper">
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsLightBlueData.registrations}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Registrations</p>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsLightBlueData.bounce}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Bounce Rate</p>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsLightBlueData.views}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon-warn">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Views</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="project-stat__item">
|
||||
<mat-card-title class="project-stat__title">
|
||||
<p class="project-stat__title-text">{{selectedStatsSingAppData.name}}</p>
|
||||
|
||||
<app-date-menu (changeDateType)="changeDateType($event, projectsType.SingApp)"></app-date-menu>
|
||||
</mat-card-title>
|
||||
<mat-card-content class="project-stat-content">
|
||||
<div class="project-stat-content__total-info-wrapper">
|
||||
<div class="project-stat-content__total-info">
|
||||
<p class="project-stat-content__total-info-users">{{selectedStatsSingAppData.users}}</p>
|
||||
@if (selectedStatsSingAppData.percent > 0) {
|
||||
<p
|
||||
class="project-stat-content__total-info-percent"
|
||||
>+{{selectedStatsSingAppData.percent}}%</p>
|
||||
}
|
||||
@if (selectedStatsSingAppData.percent < 0) {
|
||||
<p
|
||||
class="project-stat-content__total-info-percent-warn"
|
||||
>{{selectedStatsSingAppData.percent}}%</p>
|
||||
}
|
||||
</div>
|
||||
<div class="project-stat-content__total-info-chart">
|
||||
<div
|
||||
echarts
|
||||
[options]="singAppEchartsOptions"
|
||||
[style.height]="chartOptions?.chart?.height | chartSize:'100px'"
|
||||
[style.width]="chartOptions?.chart?.width | chartSize:'130px'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-wrapper">
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsSingAppData.registrations}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Registrations</p>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsSingAppData.bounce}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Bounce Rate</p>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsSingAppData.views}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Views</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="project-stat__item">
|
||||
<mat-card-title class="project-stat__title">
|
||||
<p class="project-stat__title-text">{{selectedStatsRNSData.name}}</p>
|
||||
|
||||
<app-date-menu (changeDateType)="changeDateType($event, projectsType.RNS)"></app-date-menu>
|
||||
</mat-card-title>
|
||||
<mat-card-content class="project-stat-content">
|
||||
<div class="project-stat-content__total-info-wrapper">
|
||||
<div class="project-stat-content__total-info">
|
||||
<p class="project-stat-content__total-info-users">{{selectedStatsRNSData.users}}</p>
|
||||
@if (selectedStatsRNSData.percent > 0) {
|
||||
<p
|
||||
class="project-stat-content__total-info-percent"
|
||||
>+{{selectedStatsRNSData.percent}}%</p>
|
||||
}
|
||||
@if (selectedStatsRNSData.percent < 0) {
|
||||
<p
|
||||
class="project-stat-content__total-info-percent-warn"
|
||||
>{{selectedStatsRNSData.percent}}%</p>
|
||||
}
|
||||
</div>
|
||||
<div class="project-stat-content__total-info-chart">
|
||||
<div
|
||||
echarts
|
||||
[options]="rnsEchartsOptions"
|
||||
[style.height]="chartOptions?.chart?.height | chartSize:'100px'"
|
||||
[style.width]="chartOptions?.chart?.width | chartSize:'130px'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-wrapper">
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsRNSData.registrations}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon-warn">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Registrations</p>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsRNSData.bounce}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Bounce Rate</p>
|
||||
</div>
|
||||
|
||||
<div class="project-stat-content__stat-item">
|
||||
<div class="project-stat-content__stat-value-wrapper">
|
||||
<h6 class="project-stat-content__stat-value">{{selectedStatsRNSData.views}}</h6>
|
||||
<mat-icon class="project-stat-content__stat-icon">arrow_upward</mat-icon>
|
||||
</div>
|
||||
<p class="project-stat-content__stat-item-title">Views</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@ -0,0 +1,150 @@
|
||||
@use "../../../../styles/variables" as *;
|
||||
@use "../../../../styles/font" as *;
|
||||
|
||||
.project-stat {
|
||||
display: flex;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&__item {
|
||||
height: 236px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (min-width: $small) and (max-width: $normal) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
& + .project-stat__item {
|
||||
margin-left: 24px;
|
||||
|
||||
@media (max-width: $small) {
|
||||
margin-top: 24px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: $small) and (max-width: $normal) {
|
||||
&:last-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $small) and (max-width: $large) {
|
||||
width: 41.3%;
|
||||
}
|
||||
|
||||
@media (min-width: $large) and (max-width: $normal) {
|
||||
width: 43.6%;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__title-text {
|
||||
font-weight: $fw-lighter;
|
||||
font-size: $fs-medium;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: $small) and (max-width: $normal) {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
@media (max-width: $normal) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.project-stat-content {
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
&__total-info-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
&__total-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&__total-info-users {
|
||||
color: $grey;
|
||||
font-weight: $fw-lighter;
|
||||
font-size: $fs-xxxl;
|
||||
margin: 0;
|
||||
height: 45px;
|
||||
letter-spacing: 0.15px;
|
||||
}
|
||||
|
||||
&__total-info-percent {
|
||||
color: $green;
|
||||
font-weight: $fw-lighter;
|
||||
font-size: $fs-small;
|
||||
margin: 0 0 0 5px;
|
||||
}
|
||||
|
||||
&__total-info-percent-warn {
|
||||
color: $pink;
|
||||
font-weight: $fw-lighter;
|
||||
font-size: $fs-small;
|
||||
margin: 0 0 0 5px;
|
||||
}
|
||||
|
||||
&__total-info-chart {
|
||||
position: relative;
|
||||
right: -9px;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
&__stat-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&__stat-value-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-weight: $fw-lighter;
|
||||
font-size: $fs-regular;
|
||||
color: $dark-grey;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__stat-icon {
|
||||
transform: rotate(45deg);
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&__stat-icon-warn {
|
||||
transform: rotate(135deg);
|
||||
color: $pink;
|
||||
}
|
||||
|
||||
&__stat-item-title {
|
||||
color: $light-grey;
|
||||
font-weight: $fw-lighter;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ProjectStatData, ProjectTimeData } from '../../models';
|
||||
import { colors } from '../../../../consts';
|
||||
import { ChartOptions } from '../../../templates/charts/models/chart-options';
|
||||
import { DateMenuComponent } from '../../../../shared/ui-elements';
|
||||
import { ChartSizePipe } from '../../../../shared/pipes/chart-size.pipe';
|
||||
import { NgxEchartsModule } from 'ngx-echarts';
|
||||
|
||||
enum ProjectsType {
|
||||
lightBlue = 'lightBlue',
|
||||
SingApp = 'SingApp',
|
||||
RNS = 'RNS'
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-stat-chart',
|
||||
templateUrl: './project-stat-chart.component.html',
|
||||
styleUrls: ['./project-stat-chart.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
DateMenuComponent,
|
||||
ChartSizePipe,
|
||||
NgxEchartsModule,
|
||||
]
|
||||
})
|
||||
export class ProjectStatChartComponent implements OnInit {
|
||||
@Input() projectsStatsData: ProjectStatData;
|
||||
public selectedStatsLightBlueData: ProjectTimeData;
|
||||
public selectedStatsSingAppData: ProjectTimeData;
|
||||
public selectedStatsRNSData: ProjectTimeData;
|
||||
public chartOptions: Partial<ChartOptions>;
|
||||
public projectsType: typeof ProjectsType = ProjectsType;
|
||||
public colors: typeof colors = colors;
|
||||
|
||||
public get lightBlueEchartsOptions(): EChartsOption {
|
||||
return this.buildEchartsOptions(this.selectedStatsLightBlueData?.series, colors.BLUE);
|
||||
}
|
||||
|
||||
public get singAppEchartsOptions(): EChartsOption {
|
||||
return this.buildEchartsOptions(this.selectedStatsSingAppData?.series, colors.YELLOW);
|
||||
}
|
||||
|
||||
public get rnsEchartsOptions(): EChartsOption {
|
||||
return this.buildEchartsOptions(this.selectedStatsRNSData?.series, colors.PINK);
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.selectedStatsLightBlueData = this.projectsStatsData.lightBlue.daily;
|
||||
this.selectedStatsSingAppData = this.projectsStatsData.singApp.daily;
|
||||
this.selectedStatsRNSData = this.projectsStatsData.rns.daily;
|
||||
|
||||
this.initChart();
|
||||
}
|
||||
|
||||
public initChart(): void {
|
||||
this.chartOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
height: 100,
|
||||
width: 130,
|
||||
toolbar: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
},
|
||||
grid: {
|
||||
show: false
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '70%',
|
||||
borderRadius: 5
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
stroke: {
|
||||
show: true,
|
||||
width: 2,
|
||||
colors: ['transparent']
|
||||
},
|
||||
xaxis: {
|
||||
categories: [
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug'
|
||||
],
|
||||
labels: {
|
||||
show: false
|
||||
},
|
||||
axisTicks: {
|
||||
show: false
|
||||
},
|
||||
axisBorder: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
show: false
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter(val) {
|
||||
return '$ ' + val + ' thousands';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public changeDateType(dateType: string, projectType: string): void {
|
||||
switch (projectType) {
|
||||
case this.projectsType.lightBlue:
|
||||
switch (dateType) {
|
||||
case 'Weekly':
|
||||
this.selectedStatsLightBlueData = this.projectsStatsData.lightBlue.week;
|
||||
break;
|
||||
case 'Monthly':
|
||||
this.selectedStatsLightBlueData = this.projectsStatsData.lightBlue.monthly;
|
||||
break;
|
||||
default:
|
||||
this.selectedStatsLightBlueData = this.projectsStatsData.lightBlue.daily;
|
||||
}
|
||||
break;
|
||||
case this.projectsType.SingApp:
|
||||
switch (dateType) {
|
||||
case 'Weekly':
|
||||
this.selectedStatsSingAppData = this.projectsStatsData.singApp.week;
|
||||
break;
|
||||
case 'Monthly':
|
||||
this.selectedStatsSingAppData = this.projectsStatsData.singApp.monthly;
|
||||
break;
|
||||
default:
|
||||
this.selectedStatsSingAppData = this.projectsStatsData.singApp.daily;
|
||||
}
|
||||
break;
|
||||
case this.projectsType.RNS:
|
||||
switch (dateType) {
|
||||
case 'Weekly':
|
||||
this.selectedStatsRNSData = this.projectsStatsData.rns.week;
|
||||
break;
|
||||
case 'Monthly':
|
||||
this.selectedStatsRNSData = this.projectsStatsData.rns.monthly;
|
||||
break;
|
||||
default:
|
||||
this.selectedStatsRNSData = this.projectsStatsData.rns.daily;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private buildEchartsOptions(series: ProjectTimeData['series'] | undefined, color: string): EChartsOption {
|
||||
const xaxis = this.chartOptions?.xaxis as { categories?: Array<string | number> } | undefined;
|
||||
const categories = xaxis?.categories ?? [];
|
||||
const rawSeries = (series ?? []) as Array<{ name: string; data: number[] }>;
|
||||
const mappedSeries = rawSeries.map((item) => ({
|
||||
type: 'bar',
|
||||
name: item.name ?? '',
|
||||
data: item.data ?? [],
|
||||
barMaxWidth: 18
|
||||
}));
|
||||
|
||||
return {
|
||||
color: [color],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' }
|
||||
},
|
||||
grid: {
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
containLabel: false
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: mappedSeries
|
||||
} as EChartsOption;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<mat-card class="revenue-chart">
|
||||
<mat-card-title class="revenue-chart__header">
|
||||
<p class="revenue-chart__header-title">Revenue Breakdown</p>
|
||||
|
||||
<app-settings-menu></app-settings-menu>
|
||||
</mat-card-title>
|
||||
|
||||
<mat-card-content class="revenue-chart__content">
|
||||
<div class="revenue-chart__content-chart" echarts [options]="revenueChart"></div>
|
||||
|
||||
<div class="legend-wrapper">
|
||||
<div class="legend-item-wrapper">
|
||||
<div class="legend-icon green"></div><p class="legend-text">Group A</p>
|
||||
</div>
|
||||
<div class="legend-item-wrapper">
|
||||
<div class="legend-icon pink"></div><p class="legend-text">Group B</p>
|
||||
</div>
|
||||
<div class="legend-item-wrapper">
|
||||
<div class="legend-icon yellow"></div><p class="legend-text">Group C</p>
|
||||
</div>
|
||||
<div class="legend-item-wrapper">
|
||||
<div class="legend-icon blue"></div><p class="legend-text">Group D</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@ -0,0 +1,85 @@
|
||||
@use "../../../../styles/font" as *;
|
||||
@use "../../../../styles/variables" as *;
|
||||
|
||||
.revenue-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 252px;
|
||||
|
||||
@media (min-width: 1280px) and (max-width: $xxl) {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&-title {
|
||||
font-size: $fs-regular;
|
||||
font-weight: $fw-lighter;
|
||||
margin: 0;
|
||||
line-height: 40px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&-chart {
|
||||
height: 140px;
|
||||
width: 60%;
|
||||
max-width: 218px
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend-wrapper {
|
||||
|
||||
}
|
||||
|
||||
.legend-item-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.legend-icon {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
margin: 0 0 0 8px;
|
||||
font-size: $fs-normal;
|
||||
font-width: $fw-lighter;
|
||||
color: $grey;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.blue {
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
.pink {
|
||||
background-color: $pink;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background-color: $yellow;
|
||||
}
|
||||
|
||||
.green {
|
||||
background-color: $green;
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
|
||||
import { RevenueChartData } from '../../models';
|
||||
import {colors} from '../../../../consts';
|
||||
import { SettingsMenuComponent } from '../../../../shared/ui-elements';
|
||||
import { NgxEchartsModule } from 'ngx-echarts';
|
||||
|
||||
@Component({
|
||||
selector: 'app-revenue-chart',
|
||||
templateUrl: './revenue-chart.component.html',
|
||||
styleUrls: ['./revenue-chart.component.scss'],
|
||||
standalone: true,
|
||||
imports: [MatCardModule, SettingsMenuComponent, NgxEchartsModule]
|
||||
})
|
||||
export class RevenueChartComponent implements OnInit {
|
||||
@Input() revenueCharData: RevenueChartData;
|
||||
public revenueChart: EChartsOption = {};
|
||||
public colors: typeof colors = colors;
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.initChart();
|
||||
}
|
||||
|
||||
public initChart(): void {
|
||||
this.revenueChart = {
|
||||
color: [colors.GREEN, colors.PINK, colors.YELLOW, colors.BLUE],
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
avoidLabelOverlap: false,
|
||||
data: [
|
||||
{
|
||||
name: 'Group A',
|
||||
value: this.revenueCharData.groupA
|
||||
},
|
||||
{
|
||||
name: 'Group B',
|
||||
value: this.revenueCharData.groupB
|
||||
},
|
||||
{
|
||||
name: 'Group C',
|
||||
value: this.revenueCharData.groupC
|
||||
},
|
||||
{
|
||||
name: 'Group D',
|
||||
value: this.revenueCharData.groupD
|
||||
},
|
||||
]
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<mat-card class="server-chart">
|
||||
<mat-card-title class="server-chart__header">
|
||||
<p class="server-chart__header-title">Server Overview</p>
|
||||
|
||||
<app-settings-menu></app-settings-menu>
|
||||
</mat-card-title>
|
||||
|
||||
<mat-card-content class="server-chart__content">
|
||||
@for (chart of charts; track chart; let i = $index) {
|
||||
<div class="server-chart__content-item">
|
||||
<div class="server-chart__content-item-text-wrapper">
|
||||
<p class="server-chart__content-item-text">{{serverDataTitles[i]}}</p>
|
||||
</div>
|
||||
<div
|
||||
echarts
|
||||
class="server-chart__content-item-chart"
|
||||
[options]="chart"
|
||||
[style.height]="'52px'"
|
||||
[style.width]="'100%'"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@ -0,0 +1,81 @@
|
||||
@use '../../../../styles/font' as *;
|
||||
@use '../../../../styles/variables' as *;
|
||||
|
||||
.server-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 252px;
|
||||
|
||||
@media (min-width: 1280px) and (max-width: $xxl) {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&-title {
|
||||
font-size: $fs-regular;
|
||||
font-weight: $fw-lighter;
|
||||
margin: 0;
|
||||
line-height: 32px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
grid-template-rows: repeat(3, minmax(52px, auto));
|
||||
row-gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&-item {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(64px, 92px);
|
||||
column-gap: 8px;
|
||||
min-height: 52px;
|
||||
|
||||
&-text-wrapper {
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: 1280px) and (max-width: $xxl) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&-text {
|
||||
width: 100%;
|
||||
color: $grey;
|
||||
font-weight: $fw-lighter;
|
||||
font-size: $fs-small;
|
||||
padding-right: 8px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
@media (min-width: 1280px) and (max-width: $xxl) {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&-chart {
|
||||
min-width: 64px;
|
||||
max-width: 92px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { ServerChartData } from '../../models';
|
||||
import {colors} from '../../../../consts';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { SettingsMenuComponent } from '../../../../shared/ui-elements';
|
||||
import { NgxEchartsModule } from 'ngx-echarts';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-chart',
|
||||
templateUrl: './server-chart.component.html',
|
||||
styleUrls: ['./server-chart.component.scss'],
|
||||
standalone: true,
|
||||
imports: [MatCardModule, SettingsMenuComponent, NgxEchartsModule]
|
||||
})
|
||||
export class ServerChartComponent implements OnInit {
|
||||
@Input() serverChartData: ServerChartData;
|
||||
public charts: EChartsOption[] = [];
|
||||
public serverDataTitles: string[];
|
||||
public colors: typeof colors = colors;
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.charts = [
|
||||
this.initChart(this.serverChartData.firstServerChartData, colors.PINK),
|
||||
this.initChart(this.serverChartData.secondServerChartData, colors.BLUE),
|
||||
this.initChart(this.serverChartData.thirdServerChartData, colors.YELLOW)
|
||||
];
|
||||
|
||||
this.serverDataTitles = [
|
||||
this.serverChartData.firstDataTitle,
|
||||
this.serverChartData.secondDataTitle,
|
||||
this.serverChartData.thirdDataTitle,
|
||||
]
|
||||
}
|
||||
|
||||
public initChart(data: number[], color: string): EChartsOption {
|
||||
return {
|
||||
color: [color],
|
||||
tooltip: { show: false },
|
||||
grid: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: this.serverChartData.dates,
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false,
|
||||
max: 50000
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
name: 'STOCK ABC',
|
||||
data,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: { opacity: 0.3 }
|
||||
}
|
||||
]
|
||||
} as EChartsOption;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
<mat-card class="support-requests">
|
||||
<mat-card-title class="support-requests__header">
|
||||
<div>
|
||||
<p class="support-requests__title">Support Requests</p>
|
||||
</div>
|
||||
|
||||
<app-settings-menu></app-settings-menu>
|
||||
</mat-card-title>
|
||||
|
||||
<mat-card-content class="support-requests__content">
|
||||
<table mat-table [dataSource]="dataSource" matSort class="table">
|
||||
|
||||
<!-- Checkbox Column -->
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
<mat-checkbox
|
||||
color="warn"
|
||||
(change)="$event ? masterToggle() : null"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
[aria-label]="checkboxLabel()"
|
||||
></mat-checkbox>
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-checkbox
|
||||
color="warn"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(row) : null"
|
||||
[checked]="selection.isSelected(row)"
|
||||
[aria-label]="checkboxLabel(row)"
|
||||
></mat-checkbox>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Order Column -->
|
||||
<ng-container matColumnDef="id" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef mat-sort-header>
|
||||
<p class="table-header-text">Order id</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<p class="table-body-text">{{element.id}}</p>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Customer Column -->
|
||||
<ng-container matColumnDef="customer" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef mat-sort-header>
|
||||
<p class="table-header-text">Customer</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<div class="customer-column-wrapper">
|
||||
<button
|
||||
mat-mini-fab
|
||||
class="customer-column-icon"
|
||||
color="{{
|
||||
element.status === 'delivered'
|
||||
? 'success'
|
||||
: element.status === 'pending'
|
||||
? 'primary'
|
||||
: element.status === 'progress'
|
||||
? 'accent'
|
||||
: 'warn'
|
||||
}}"
|
||||
>{{element.customer | shortName}}</button>
|
||||
<p class="table-body-text">{{element.customer}}</p>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Office Column -->
|
||||
<ng-container matColumnDef="office" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef mat-sort-header>
|
||||
<p class="table-header-text">Office</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<p class="table-body-text">{{element.office}}</p>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Netto Weight Column -->
|
||||
<ng-container matColumnDef="nettoWeight" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef mat-sort-header>
|
||||
<p class="table-header-text">Netto Weight</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<p class="table-body-text">{{element.nettoWeight}}</p>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Price Column -->
|
||||
<ng-container matColumnDef="price" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef mat-sort-header>
|
||||
<p class="table-header-text">Price</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<p class="table-body-text">{{element.price}}</p>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Date of Purchase Column -->
|
||||
<ng-container matColumnDef="dateOfPurchase" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef mat-sort-header>
|
||||
<p class="table-header-text">Date of purchase</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<p class="table-body-text">{{element.dateOfPurchase}}</p>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Date of Delivery Column -->
|
||||
<ng-container matColumnDef="dateOfDelivery" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef mat-sort-header>
|
||||
<p class="table-header-text">Date of Delivery</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<p class="table-body-text">{{element.dateOfDelivery}}</p>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef mat-sort-header>
|
||||
<p class="table-header-text">Status</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<div class="support-requests__content-badge" [ngClass]="element.status">
|
||||
<span>{{element.status}}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions" >
|
||||
<th class="table-header" mat-header-cell *matHeaderCellDef >
|
||||
<p class="table-header-text">Actions</p>
|
||||
</th>
|
||||
<td class="table-body" mat-cell *matCellDef="let element">
|
||||
<app-settings-menu></app-settings-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@ -0,0 +1,130 @@
|
||||
@use '../../../../styles/font' as *;
|
||||
@use '../../../../styles/variables' as *;
|
||||
|
||||
.support-requests {
|
||||
padding: 0;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 24px 24px 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: $fs-medium;
|
||||
font-weight: $fw-lighter;
|
||||
margin: 0;
|
||||
line-height: 40px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__content {
|
||||
height: auto;
|
||||
overflow-y: hidden;
|
||||
overflow-x: scroll;
|
||||
|
||||
@media (max-width: $small) {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__table-row {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
&__table-row-title {
|
||||
color: $dark-grey;
|
||||
font-size: $fs-small;
|
||||
font-weight: $fw-lighter;
|
||||
line-height: 24px;
|
||||
text-transform: uppercase;
|
||||
padding: 18.4px;
|
||||
}
|
||||
|
||||
&__table-content {
|
||||
color: $dark-grey;
|
||||
font-size: $fs-small;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&__content-badge {
|
||||
width: fit-content;
|
||||
border-radius: 32px;
|
||||
color: $white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
|
||||
font-weight: $fw-lighter;
|
||||
line-height: 1.75;
|
||||
letter-spacing: 0.45px;
|
||||
|
||||
&::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* TODO(mdc-migration): The following rule targets internal classes of menu that may no longer apply for the MDC version. */
|
||||
mat-menu {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.delivered {
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.pending {
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
.canseled {
|
||||
background-color: $pink;
|
||||
}
|
||||
|
||||
.progress {
|
||||
background-color: $yellow;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 14px 40px 14px 24px;
|
||||
}
|
||||
|
||||
.table-header-text {
|
||||
font-size: $fs-small;
|
||||
font-weight: $fw-normal;
|
||||
color: $light-grey;
|
||||
text-transform: uppercase;
|
||||
width: max-content;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
padding: 14px 40px 14px 24px;
|
||||
}
|
||||
|
||||
.table-body-text {
|
||||
font-size: $fs-small;
|
||||
font-weight: $fw-normal;
|
||||
color: $dark-grey;
|
||||
text-transform: uppercase;
|
||||
width: max-content;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.customer-column-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.customer-column-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import {Component, Input, ViewChild} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SupportRequestData } from '../../models/support-request-data';
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { SettingsMenuComponent } from '../../../../shared/ui-elements';
|
||||
import { ShortNamePipe } from '../../../../shared/header/pipes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-support-requests',
|
||||
templateUrl: './support-requests.component.html',
|
||||
styleUrls: ['./support-requests.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatSortModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
SettingsMenuComponent,
|
||||
ShortNamePipe,
|
||||
]
|
||||
})
|
||||
export class SupportRequestsComponent {
|
||||
@Input() supportRequestData: SupportRequestData[];
|
||||
@ViewChild(MatSort, {static: true}) sort: MatSort;
|
||||
public displayedColumns: string[] = [
|
||||
'select',
|
||||
'id',
|
||||
'customer',
|
||||
'office',
|
||||
'nettoWeight',
|
||||
'price',
|
||||
'dateOfPurchase',
|
||||
'dateOfDelivery',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
public selection = new SelectionModel<SupportRequestData>(true, []);
|
||||
public dataSource: MatTableDataSource<SupportRequestData>;
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.dataSource = new MatTableDataSource<SupportRequestData>(this.supportRequestData);
|
||||
|
||||
this.dataSource.sort = this.sort;
|
||||
}
|
||||
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
public isAllSelected(): boolean {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.dataSource.data.length;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
/** Selects all rows if they are not all selected; otherwise clear selection. */
|
||||
public masterToggle(): void {
|
||||
this.isAllSelected() ?
|
||||
this.selection.clear() :
|
||||
this.dataSource.data.forEach(row => this.selection.select(row));
|
||||
}
|
||||
|
||||
/** The label for the checkbox on the passed row */
|
||||
public checkboxLabel(row?: SupportRequestData): string {
|
||||
if (!row) {
|
||||
return `${this.isAllSelected() ? 'select' : 'deselect'} all`;
|
||||
}
|
||||
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${row.id}`;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
<mat-card class="visits-chart">
|
||||
<mat-card-title class="visits-chart__header">
|
||||
<p class="visits-chart__title">Support Tracker</p>
|
||||
|
||||
<app-settings-menu></app-settings-menu>
|
||||
</mat-card-title>
|
||||
|
||||
<mat-card-content class="visits-chart__content">
|
||||
<div class="visits-chart__content-info">
|
||||
<div>
|
||||
<h6 class="visits-chart__content-info-title">{{visitsChartData.all}}</h6>
|
||||
<p>Tickets</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
echarts
|
||||
class="visits-chart__content-info-chart"
|
||||
[options]="echartsOptions"
|
||||
[style.height]="chartOptions?.chart?.height | chartSize:'130px'"
|
||||
[style.width]="chartOptions?.chart?.width | chartSize:'130px'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="visits-chart__content-stats">
|
||||
<div>
|
||||
<p class="visits-chart__content-stats-title">New Tickets</p>
|
||||
<div class="visits-chart__content-stats-wrapper">
|
||||
<p class="visits-chart__content-stats-data">{{visitsChartData.registration}}</p>
|
||||
<div class="visits-chart__content-stats-icon green"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="visits-chart__content-stats-title">Open</p>
|
||||
<div class="visits-chart__content-stats-wrapper">
|
||||
<p class="visits-chart__content-stats-data">{{visitsChartData.signOut}}</p>
|
||||
<div class="visits-chart__content-stats-icon yellow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="visits-chart__content-stats-title">Completed</p>
|
||||
<div class="visits-chart__content-stats-wrapper">
|
||||
<p class="visits-chart__content-stats-data">{{visitsChartData.rate}}</p>
|
||||
<div class="visits-chart__content-stats-icon blue"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@ -0,0 +1,106 @@
|
||||
@use "../../../../styles/font" as *;
|
||||
@use "../../../../styles/variables" as *;
|
||||
|
||||
.visits-chart {
|
||||
display: flex;
|
||||
height: 252px;
|
||||
flex-direction: column;
|
||||
|
||||
@media (min-width: 1280px) and (max-width: $xxl) {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: $fw-lighter;
|
||||
margin: 0;
|
||||
line-height: 40px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&-info {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-height: 96px;
|
||||
justify-content: space-between;
|
||||
|
||||
&-title {
|
||||
margin: 0;
|
||||
font-weight: $fw-normal;
|
||||
font-size: $fs-large;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.15px;
|
||||
color: $dark-grey;
|
||||
}
|
||||
|
||||
&-chart {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&-title {
|
||||
margin:0;
|
||||
color: $light-grey;
|
||||
font-weight: $fw-lighter;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-data {
|
||||
margin:0;
|
||||
line-height: 1.5;
|
||||
font-weight: $fw-lighter;
|
||||
font-size: $fs-normal;
|
||||
color: $dark-grey;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blue {
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background-color: $yellow;
|
||||
}
|
||||
|
||||
.green {
|
||||
background-color: $green;
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { VisitsChartData } from '../../models';
|
||||
import { colors } from '../../../../consts';
|
||||
import { ChartOptions } from '../../../templates/charts/models/chart-options';
|
||||
import { SettingsMenuComponent } from '../../../../shared/ui-elements';
|
||||
import { ChartSizePipe } from '../../../../shared/pipes/chart-size.pipe';
|
||||
import { NgxEchartsModule } from 'ngx-echarts';
|
||||
|
||||
@Component({
|
||||
selector: 'app-visits-chart',
|
||||
templateUrl: './visits-chart.component.html',
|
||||
styleUrls: ['./visits-chart.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
SettingsMenuComponent,
|
||||
ChartSizePipe,
|
||||
NgxEchartsModule,
|
||||
]
|
||||
})
|
||||
export class VisitsChartComponent implements OnInit, OnChanges {
|
||||
@Input() visitsChartData: VisitsChartData;
|
||||
@Input() currentTheme: string;
|
||||
public colors: typeof colors = colors;
|
||||
public chartOptions: Partial<ChartOptions> = {};
|
||||
|
||||
public get echartsOptions(): EChartsOption {
|
||||
const value = Array.isArray(this.chartOptions.series)
|
||||
? Number((this.chartOptions.series as unknown[])[0] ?? 0)
|
||||
: 0;
|
||||
const fill = this.chartOptions.fill as { colors?: string[] } | undefined;
|
||||
const color = fill?.colors?.[0] ?? colors.PINK;
|
||||
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
startAngle: 180,
|
||||
endAngle: 0,
|
||||
center: ['50%', '65%'],
|
||||
min: 0,
|
||||
max: 100,
|
||||
radius: '100%',
|
||||
progress: {
|
||||
show: true,
|
||||
width: 8,
|
||||
roundCap: true,
|
||||
itemStyle: { color }
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 8,
|
||||
color: [[1, 'rgba(74, 74, 74, 0.14)']]
|
||||
}
|
||||
},
|
||||
pointer: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
detail: { show: false },
|
||||
data: [{ value }]
|
||||
}
|
||||
]
|
||||
} as EChartsOption;
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.initChart();
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['currentTheme']?.currentValue) {
|
||||
this.chartOptions = {
|
||||
...this.chartOptions,
|
||||
fill: {
|
||||
colors: [
|
||||
this.currentTheme === 'blue'
|
||||
? colors.BLUE
|
||||
: this.currentTheme === 'green'
|
||||
? colors.GREEN
|
||||
: colors.PINK
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public initChart(): void {
|
||||
this.chartOptions = {
|
||||
series: [77],
|
||||
chart: {
|
||||
height: 130,
|
||||
width: 130,
|
||||
type: 'radialBar',
|
||||
offsetY: -10
|
||||
},
|
||||
plotOptions: {
|
||||
radialBar: {
|
||||
startAngle: -180,
|
||||
endAngle: 180,
|
||||
dataLabels: {
|
||||
name: {
|
||||
fontSize: '16px',
|
||||
color: undefined,
|
||||
offsetY: 120
|
||||
},
|
||||
value: {
|
||||
offsetY: 76,
|
||||
fontSize: '22px',
|
||||
color: undefined,
|
||||
formatter(val) {
|
||||
return val + '%';
|
||||
}
|
||||
},
|
||||
show: false
|
||||
}
|
||||
}
|
||||
},
|
||||
fill: {
|
||||
colors: [
|
||||
this.currentTheme === 'blue'
|
||||
? colors.BLUE
|
||||
: this.currentTheme === 'green'
|
||||
? colors.GREEN
|
||||
: colors.PINK
|
||||
]
|
||||
},
|
||||
stroke: {
|
||||
dashArray: 3
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
6
src/app/modules/dashboard/consts/custom-tooltip.ts
Normal file
6
src/app/modules/dashboard/consts/custom-tooltip.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const customTooltip: string =
|
||||
'<div>' +
|
||||
'<div style="padding: 16px; 16px; display: flex; align-items: center;"> <div style="width: 8px; height: 8px; border-radius: 50%; background-color: #FFC260"></div><span style="color: #4A4A4A; margin-left: 8px">' + 'Tablet' + '</span></div>' +
|
||||
'<div style="padding: 16px; 16px; display: flex; align-items: center;"> <div style="width: 8px; height: 8px; border-radius: 50%; background-color: #536DFE"></div><span style="color: #4A4A4A; margin-left: 8px">' + 'Mobile' + '</span></div>' +
|
||||
'<div style="padding: 16px; 16px; display: flex; align-items: center;"> <div style="width: 8px; height: 8px; border-radius: 50%; background-color: #B1BCFF"></div><span style="color: #4A4A4A; margin-left: 8px">' + 'Desktop' + '</span></div>' +
|
||||
'</div>';
|
||||
1
src/app/modules/dashboard/consts/index.ts
Normal file
1
src/app/modules/dashboard/consts/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './custom-tooltip';
|
||||
@ -0,0 +1,53 @@
|
||||
<mat-card class="card">
|
||||
<mat-toolbar class="page-header" role="heading">
|
||||
<div class="title-inner-wrapper">
|
||||
<p class="title-text">Dashboard</p>
|
||||
|
||||
<mat-tab-group color="warn" class="dashboard-mat-tab">
|
||||
<mat-tab label="Today">
|
||||
<div class="empty-block"></div>
|
||||
</mat-tab>
|
||||
<mat-tab label="This Week">
|
||||
<div class="empty-block"></div>
|
||||
</mat-tab>
|
||||
<mat-tab label="This Month">
|
||||
<div class="empty-block"></div>
|
||||
</mat-tab>
|
||||
<mat-tab label="This Year">
|
||||
<div class="empty-block"></div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
|
||||
<div class="title-inner-wrapper">
|
||||
<div class="title-date">
|
||||
<mat-icon class="title-icon-data" color="primary">calendar_today</mat-icon>
|
||||
|
||||
<p class="title-data-text"> {{todayDate | date: 'dd MMM yyyy, EEEE'}}</p>
|
||||
</div>
|
||||
|
||||
<button mat-flat-button color="warn" class="title-button">Latest Reports</button>
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
</mat-card>
|
||||
|
||||
<div class="charts-wrapper">
|
||||
<app-visits-chart class="chart" [visitsChartData]="visitsChartData$ | async" [currentTheme]="currentTheme"></app-visits-chart>
|
||||
|
||||
<app-performance-chart class="chart" [performanceChartData]="performanceChartData$ | async" [currentTheme]="currentTheme"></app-performance-chart>
|
||||
|
||||
<app-server-chart class="chart" [serverChartData]="serverChartData$ | async"></app-server-chart>
|
||||
|
||||
<app-revenue-chart class="chart" [revenueCharData]="revenueChartData$ | async"></app-revenue-chart>
|
||||
</div>
|
||||
|
||||
<app-daily-line-chart
|
||||
[dailyLineChartData]="dailyLineChartData$ | async"
|
||||
[currentTheme]="currentTheme"
|
||||
[currentMode]="currentMode"
|
||||
></app-daily-line-chart>
|
||||
|
||||
<app-project-stat-chart [projectsStatsData]="projectsStatsData$ | async"></app-project-stat-chart>
|
||||
|
||||
<app-support-requests [supportRequestData]="supportRequestData$ | async"></app-support-requests>
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
@use '../../../../styles/variables' as *;
|
||||
@use '../../../../styles/font' as *;
|
||||
|
||||
.card {
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: $small) {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: $toolbar-padding;
|
||||
background: none;
|
||||
height: auto;
|
||||
|
||||
@media (max-width: $small) {
|
||||
flex-direction: column;
|
||||
align-items: baseline;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.title-inner-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: $normal) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.title-inner-wrapper + .title-inner-wrapper {
|
||||
@media (max-width: $small) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.title-text {
|
||||
margin: 0 24px 0 0;
|
||||
}
|
||||
|
||||
.dashboard-mat-tab {
|
||||
@media (max-width: $normal) {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.title-date {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 24px;
|
||||
|
||||
@media (min-width: $small) and (max-width: $normal) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.title-icon-data {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.title-data-text {
|
||||
margin: 0;
|
||||
font-weight: $fw-lighter;
|
||||
font-size: $fs-normal;
|
||||
}
|
||||
|
||||
.title-button {
|
||||
@media (max-width: $normal) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.charts-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 24px;
|
||||
|
||||
@media (max-width: $normal) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 23.5%;
|
||||
|
||||
@media (max-width: $normal) {
|
||||
width: 48%;
|
||||
|
||||
&:nth-last-child(2n) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $small) {
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { DashboardService } from '../../services';
|
||||
import {
|
||||
DailyLineChartData,
|
||||
PerformanceChartData,
|
||||
ProjectStatData,
|
||||
RevenueChartData,
|
||||
ServerChartData,
|
||||
SupportRequestData,
|
||||
VisitsChartData,
|
||||
} from '../../models';
|
||||
import {SharedService} from '../../../../shared/services/shared.service';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { VisitsChartComponent } from '../../components/visits-chart/visits-chart.component';
|
||||
import { PerformanceChartComponent } from '../../components/performance-chart/performance-chart.component';
|
||||
import { ServerChartComponent } from '../../components/server-chart/server-chart.component';
|
||||
import { RevenueChartComponent } from '../../components/revenue-chart/revenue-chart.component';
|
||||
import { DailyLineChartComponent } from '../../components/daily-line-chart/daily-line-chart.component';
|
||||
import { ProjectStatChartComponent } from '../../components/project-stat-chart/project-stat-chart.component';
|
||||
import { SupportRequestsComponent } from '../../components/support-requests/support-requests.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-page',
|
||||
templateUrl: './dashboard-page.component.html',
|
||||
styleUrls: ['./dashboard-page.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatToolbarModule,
|
||||
MatTabsModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
VisitsChartComponent,
|
||||
PerformanceChartComponent,
|
||||
ServerChartComponent,
|
||||
RevenueChartComponent,
|
||||
DailyLineChartComponent,
|
||||
ProjectStatChartComponent,
|
||||
SupportRequestsComponent,
|
||||
]
|
||||
})
|
||||
export class DashboardPageComponent implements OnInit {
|
||||
public dailyLineChartData$: Observable<DailyLineChartData>;
|
||||
public performanceChartData$: Observable<PerformanceChartData>;
|
||||
public revenueChartData$: Observable<RevenueChartData>;
|
||||
public serverChartData$: Observable<ServerChartData>;
|
||||
public supportRequestData$: Observable<SupportRequestData[]>;
|
||||
public visitsChartData$: Observable<VisitsChartData>;
|
||||
public projectsStatsData$: Observable<ProjectStatData>;
|
||||
public todayDate: Date = new Date();
|
||||
public currentTheme = '';
|
||||
public currentMode = '';
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private service: DashboardService,
|
||||
private sharedService: SharedService
|
||||
) {
|
||||
this.dailyLineChartData$ = this.service.loadDailyLineChartData();
|
||||
this.performanceChartData$ = this.service.loadPerformanceChartData();
|
||||
this.revenueChartData$ = this.service.loadRevenueChartData();
|
||||
this.serverChartData$ = this.service.loadServerChartData();
|
||||
this.supportRequestData$ = this.service.loadSupportRequestData();
|
||||
this.visitsChartData$ = this.service.loadVisitsChartData();
|
||||
this.projectsStatsData$ = this.service.loadProjectsStatsData();
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.sharedService.currentTheme
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((theme: string) => {
|
||||
this.currentTheme = theme;
|
||||
});
|
||||
|
||||
this.sharedService.currentMode
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((mode: string) => {
|
||||
this.currentMode = mode;
|
||||
});
|
||||
}
|
||||
}
|
||||
1
src/app/modules/dashboard/containers/index.ts
Normal file
1
src/app/modules/dashboard/containers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './dashboard-page/dashboard-page.component';
|
||||
10
src/app/modules/dashboard/dashboard.routes.ts
Normal file
10
src/app/modules/dashboard/dashboard.routes.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { DashboardPageComponent } from './containers';
|
||||
|
||||
export const DASHBOARD_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: DashboardPageComponent,
|
||||
},
|
||||
];
|
||||
12
src/app/modules/dashboard/models/daily-line-chart-data.ts
Normal file
12
src/app/modules/dashboard/models/daily-line-chart-data.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface DailyLineChartData {
|
||||
dailyData: TimeData;
|
||||
weeklyData: TimeData;
|
||||
monthlyData: TimeData;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface TimeData {
|
||||
mobile: number[];
|
||||
desktop: number[];
|
||||
tablet: number[];
|
||||
}
|
||||
7
src/app/modules/dashboard/models/index.ts
Normal file
7
src/app/modules/dashboard/models/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export * from './daily-line-chart-data';
|
||||
export * from './performance-chart-data';
|
||||
export * from './revenue-chart-data';
|
||||
export * from './server-chart-data';
|
||||
export * from './support-request-data';
|
||||
export * from './visits-chart-data';
|
||||
export * from './project-stat-data';
|
||||
@ -0,0 +1,4 @@
|
||||
export interface PerformanceChartData {
|
||||
integration: number;
|
||||
sdk: number;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user