Initial import

This commit is contained in:
Flatlogic Bot 2026-03-12 12:54:03 +00:00
commit 4249de6cd8
756 changed files with 57762 additions and 0 deletions

8
.browserslistrc Normal file
View 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
View 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
View File

@ -0,0 +1,2 @@
build
node_modules

54
.eslintrc.json Normal file
View 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
View 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
View 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

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22

10
.prettierrc Normal file
View 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
View 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
View 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 dont 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
View 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
View 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

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

84
package.json Normal file
View 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
View File

@ -0,0 +1,3 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();

View File

@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

9
src/app/app.component.ts Normal file
View 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
View 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
View 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 {}

View 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
View 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
View 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
View File

@ -0,0 +1 @@
export const AUTO_COMPLETE_LIMIT = 100;

4
src/app/consts/index.ts Normal file
View 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
View 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',
}

View File

@ -0,0 +1,2 @@
export const AUTH_TOKEN_STORAGE_KEY = 'token';
export const AUTH_USER_STORAGE_KEY = 'user';

View 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 {}

View 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 {}

View File

@ -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>

View 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;
}

View 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]);
}
}

View 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>

View 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;
}

View 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]);
},
});
}
}

View 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>

View 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;
}

View 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';
}
}

View 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();
});
});

View 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,
},
];

View File

@ -0,0 +1,2 @@
export * from './login-form/login-form.component';
export * from './sign-form/sign-form.component';

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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);
});
});

View File

@ -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());
}
}
}

View File

@ -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>

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
});
}
}

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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' });
});
});

View File

@ -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' });
}
}

View File

@ -0,0 +1 @@
export * from './auth-page/auth-page.component';

View 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);
}
});
});

View 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);
};

View File

@ -0,0 +1 @@
export * from './auth.guard';

View File

@ -0,0 +1,5 @@
export interface Email {
name: string;
time: string;
message: string
}

View File

@ -0,0 +1,2 @@
export * from './user';
export * from './email';

View File

@ -0,0 +1,4 @@
export interface User {
name: string;
lastName: string;
}

View File

@ -0,0 +1 @@
export * from './year.pipe';

View 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');
}
}

View 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'}
])
}
}

View File

@ -0,0 +1 @@
export * from './email.service';

View File

@ -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>

View File

@ -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;
}

View File

@ -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,
);
}
}

View 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';

View File

@ -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>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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
},
]
}]
};
}
}

View File

@ -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>

View File

@ -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%;
}
}
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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}`;
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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
}
};
}
}

View 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>';

View File

@ -0,0 +1 @@
export * from './custom-tooltip';

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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;
});
}
}

View File

@ -0,0 +1 @@
export * from './dashboard-page/dashboard-page.component';

View File

@ -0,0 +1,10 @@
import { Routes } from '@angular/router';
import { DashboardPageComponent } from './containers';
export const DASHBOARD_ROUTES: Routes = [
{
path: '',
component: DashboardPageComponent,
},
];

View 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[];
}

View 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';

View File

@ -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