feat: Add newProjectConfigureHttpApiModule for API configuration and localization
feat: Implement SampleAppServiceTests for application service testing feat: Create newProjectConfigureApplicationTests project for application layer tests feat: Establish newProjectConfigureDomainTests project for domain layer tests feat: Introduce Entity Framework Core tests for the newProjectConfigure application feat: Develop console test application for HTTP API client demonstration feat: Implement FakeCurrentPrincipalAccessor for testing purposes chore: Set up test base classes and data seed contributors for consistent testing environment
This commit is contained in:
1 parent
7909179fca
commit
b6f2213ccf
279 files changed
+39466
No files matched your search
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": ["../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*", "**/vitest.config.*.timestamp*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"],
|
||||
"rules": {
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"prefix": "abp",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "element",
|
||||
"prefix": "abp",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"extends": ["plugin:@nx/angular-template"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
## ℹ️ Description
|
||||
|
||||
ABP Framework is a complete open-source infrastructure to create modern web applications by following the best practices and conventions of software development. This package is a part of the [ABP Framework](https://abp.io) and contains client-side files.
|
||||
For more information, check out the below links:
|
||||
|
||||
🔗Official Website: https://abp.io
|
||||
|
||||
🔗Commercial Website: https://commercial.abp.io
|
||||
|
||||
🔗Commercial Demo: https://commercial.abp.io/demo
|
||||
|
||||
🔗GitHub Repository: https://github.com/abpframework/abp
|
||||
|
||||
🔗Official Theme: https://www.LeptonTheme.com
|
||||
|
||||
🔗Documentation: https://docs.abp.io
|
||||
|
||||
🔗Community: https://community.abp.io
|
||||
|
||||
🔗Blog: https://blog.abp.io
|
||||
|
||||
🔗Books: https://abp.io/books
|
||||
|
||||
🔗Twitter: https://twitter.com/abpframework
|
||||
|
||||
🔗Discord: https://community.abp.io/discord
|
||||
|
||||
🔗Stackoverflow: https://stackoverflow.com/questions/tagged/abp
|
||||
|
||||
🔗YouTube: https://www.youtube.com/@Volosoft
|
||||
|
||||
|
||||
## 🤔 Why ABP Platform?
|
||||
|
||||
Why should you use the ABP.IO Platform instead of creating a new solution from scratch?
|
||||
|
||||
You can find the answer here 👉🏻 [Why ABP Platform?](https://docs.abp.io/en/commercial/latest/why-abp-io-platform)
|
||||
|
||||
|
||||
## 🚀 Key Features of the ABP Framework
|
||||
|
||||
🟡 Modularity
|
||||
|
||||
🟡 Multi-Tenancy
|
||||
|
||||
🟡 Bootstrap Tag Helpers
|
||||
|
||||
🟡 Dynamic Forms
|
||||
|
||||
🟡 Authentication
|
||||
|
||||
🟡 Authorization
|
||||
|
||||
🟡 Distributed Event Bus
|
||||
|
||||
🟡 BLOB Storing
|
||||
|
||||
🟡 Text Templating
|
||||
|
||||
🟡 Tooling: ABP CLI
|
||||
|
||||
🟡 Cross-Cutting Concerns
|
||||
|
||||
🟡 Bundling & Minification
|
||||
|
||||
🟡 Virtual File System
|
||||
|
||||
🟡 Theming
|
||||
|
||||
🟡 Background Jobs
|
||||
|
||||
🟡 DDD Infrastructure
|
||||
|
||||
🟡 Auto REST APIs
|
||||
|
||||
🟡 Dynamic Client Proxies
|
||||
|
||||
🟡 Multiple Database Providers
|
||||
|
||||
🟡 Data filtering
|
||||
|
||||
🟡 Test Infrastructure
|
||||
|
||||
🟡 Audit Logging
|
||||
|
||||
🟡 Object to Object Mapping
|
||||
|
||||
🟡 Email & SMS Abstractions
|
||||
|
||||
🟡 Localization
|
||||
|
||||
🟡 Setting Management
|
||||
|
||||
🟡 Extension Methods
|
||||
|
||||
🟡 Aspect Oriented Programming
|
||||
|
||||
🟡 Dependency Injection
|
||||
|
||||
|
||||
## 🧐 How It Works?
|
||||
|
||||
The following page explains how you use the ABP.IO Platform as a .NET developer 👉 [How it works?](https://commercial.abp.io/how-it-works)
|
||||
|
||||
|
||||
### 📘 Supported Database Providers
|
||||
|
||||
🔵 Entity Framework Core
|
||||
|
||||
🔵 MongoDB
|
||||
|
||||
🔵 Dapper
|
||||
|
||||
|
||||
### 🎴 Supported UI Frameworks
|
||||
|
||||
🔵 Angular
|
||||
|
||||
🔵 Razor Pages
|
||||
|
||||
🔵 Blazor Web Assembly
|
||||
|
||||
🔵 Blazor Server
|
||||
|
||||
🔵 MAUI with Blazor Hybrid
|
||||
|
||||
|
||||
## 📫 Bug & Support
|
||||
|
||||
Support for open-source ABP Framework client-side packages is available at [GitHub Issues](https://github.com/abpframework/abp/issues), and the commercial support is available at [support.abp.io](https://support.abp.io).
|
||||
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* @deprecated use vitest instead of jest
|
||||
* @see https://vitest.dev/guide/migration.html#jest
|
||||
*/
|
||||
export default {
|
||||
displayName: 'theme-basic',
|
||||
preset: '../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
globals: {},
|
||||
coverageDirectory: '../../coverage/packages/theme-basic',
|
||||
transform: {
|
||||
'^.+.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/packages/theme-basic",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
},
|
||||
"allowedNonPeerDependencies": ["@abp/ng.theme.shared", "@abp/ng.account.core"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@abp/ng.theme.basic",
|
||||
"version": "10.1.0",
|
||||
"homepage": "https://abp.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/abpframework/abp.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abp/ng.account.core": "~10.1.0",
|
||||
"@abp/ng.theme.shared": "~10.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "LGPL-3.0",
|
||||
"keywords": [
|
||||
"aspnetcore",
|
||||
"boilerplate",
|
||||
"framework",
|
||||
"web",
|
||||
"best-practices",
|
||||
"angular",
|
||||
"maui",
|
||||
"blazor",
|
||||
"mvc",
|
||||
"csharp",
|
||||
"webapp"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "theme-basic",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"sourceRoot": "packages/theme-basic/src",
|
||||
"prefix": "abp",
|
||||
"tags": [],
|
||||
"implicitDependencies": ["core", "theme-shared", "account-core"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/angular:package",
|
||||
"outputs": ["{workspaceRoot}/dist/packages/theme-basic"],
|
||||
"options": {
|
||||
"project": "packages/theme-basic/ng-package.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "packages/theme-basic/tsconfig.lib.prod.json"
|
||||
},
|
||||
"development": {
|
||||
"tsConfig": "packages/theme-basic/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"]
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/vitest:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "../../coverage/packages/theme-basic"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<nav
|
||||
class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm flex-column flex-md-row mb-4"
|
||||
id="main-navbar"
|
||||
style="min-height: 4rem"
|
||||
>
|
||||
<div class="container">
|
||||
<abp-logo *abpReplaceableTemplate="{ componentKey: service.logoComponentKey }"></abp-logo>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
[attr.aria-expanded]="!service.isCollapsed"
|
||||
(click)="service.isCollapsed = !service.isCollapsed"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div
|
||||
class="navbar-collapse"
|
||||
[class.overflow-hidden]="service.smallScreen"
|
||||
id="main-navbar-collapse"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="!service.smallScreen ? navigations : null"></ng-container>
|
||||
|
||||
@if (service.smallScreen) {
|
||||
<div
|
||||
[@collapseWithMargin]="service.isCollapsed ? 'collapsed' : 'expanded'"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="navigations"></ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #navigations>
|
||||
<abp-routes
|
||||
*abpReplaceableTemplate="{
|
||||
componentKey: service.routesComponentKey,
|
||||
inputs: {
|
||||
smallScreen: { value: service.smallScreen }
|
||||
}
|
||||
}"
|
||||
class="mx-auto"
|
||||
[smallScreen]="service.smallScreen"
|
||||
></abp-routes>
|
||||
|
||||
<abp-nav-items
|
||||
*abpReplaceableTemplate="{
|
||||
componentKey: service.navItemsComponentKey
|
||||
}"
|
||||
></abp-nav-items>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- [@slideFromBottom]="outlet.isActivated && outlet.activatedRoute?.routeConfig?.path" TODO: throws ExpressionChangedAfterItHasBeenCheck when animation is active. It should be fixed -->
|
||||
<div class="container">
|
||||
<abp-page-alert-container></abp-page-alert-container>
|
||||
<abp-auth-wrapper
|
||||
*abpReplaceableTemplate="{
|
||||
componentKey: authWrapperKey
|
||||
}"
|
||||
>
|
||||
<router-outlet #outlet="outlet"></router-outlet>
|
||||
</abp-auth-wrapper>
|
||||
</div>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { AfterViewInit, Component, inject } from '@angular/core';
|
||||
import { eLayoutType, ReplaceableTemplateDirective, SubscriptionService } from '@abp/ng.core';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { LogoComponent } from '../logo/logo.component';
|
||||
import { RoutesComponent } from '../routes/routes.component';
|
||||
import { NavItemsComponent } from '../nav-items/nav-items.component';
|
||||
import { AuthWrapperComponent } from './auth-wrapper/auth-wrapper.component';
|
||||
import { PageAlertContainerComponent } from '../page-alert-container/page-alert-container.component';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { collapseWithMargin } from '@abp/ng.theme.shared';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-layout-account',
|
||||
templateUrl: './account-layout.component.html',
|
||||
animations: [collapseWithMargin],
|
||||
providers: [LayoutService, SubscriptionService],
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
LogoComponent,
|
||||
RoutesComponent,
|
||||
NavItemsComponent,
|
||||
AuthWrapperComponent,
|
||||
PageAlertContainerComponent,
|
||||
ReplaceableTemplateDirective,
|
||||
RouterOutlet,
|
||||
],
|
||||
})
|
||||
export class AccountLayoutComponent implements AfterViewInit {
|
||||
service = inject(LayoutService);
|
||||
|
||||
// required for dynamic component
|
||||
static type = eLayoutType.account;
|
||||
|
||||
authWrapperKey = 'Account.AuthWrapperComponent';
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.service.subscribeWindowSize();
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<div class="row">
|
||||
<div class="mx-auto col col-md-5">
|
||||
@if ((service.isMultiTenancyEnabled$ | async) && service.isTenantBoxVisible) {
|
||||
<abp-tenant-box
|
||||
*abpReplaceableTemplate="{ componentKey: service.tenantBoxKey }"
|
||||
></abp-tenant-box>
|
||||
}
|
||||
|
||||
<div class="abp-account-container">
|
||||
@if (service.enableLocalLogin$ | async) {
|
||||
<div class="card mt-3 shadow-sm rounded">
|
||||
<div class="card-body p-5">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-warning">
|
||||
<strong>{{ 'AbpAccount::InvalidLoginRequest' | abpLocalization }}</strong>
|
||||
{{ 'AbpAccount::ThereAreNoLoginSchemesConfiguredForThisClient' | abpLocalization }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { AuthWrapperService } from '@abp/ng.account.core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { LocalizationPipe, ReplaceableTemplateDirective } from '@abp/ng.core';
|
||||
import { TenantBoxComponent } from '../tenant-box/tenant-box.component';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-auth-wrapper',
|
||||
templateUrl: './auth-wrapper.component.html',
|
||||
providers: [AuthWrapperService],
|
||||
imports: [AsyncPipe, TenantBoxComponent, ReplaceableTemplateDirective, LocalizationPipe],
|
||||
})
|
||||
export class AuthWrapperComponent {
|
||||
service = inject(AuthWrapperService);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
@if ((service.currentTenant$ | async) || { name: '' }; as currentTenant) {
|
||||
<div class="card shadow-sm rounded mb-3">
|
||||
<div class="card-body px-5">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<span style="font-size: 0.8em" class="text-uppercase text-muted">{{
|
||||
'AbpUiMultiTenancy::Tenant' | abpLocalization
|
||||
}}</span
|
||||
><br />
|
||||
<h6 class="m-0 d-inline-block">
|
||||
<i>{{ currentTenant.name || ('AbpUiMultiTenancy::NotSelected' | abpLocalization) }}</i>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a
|
||||
id="AbpTenantSwitchLink"
|
||||
href="javascript:void(0);"
|
||||
class="btn btn-sm mt-3 btn-outline-primary"
|
||||
(click)="service.onSwitch()"
|
||||
>{{ 'AbpUiMultiTenancy::Switch' | abpLocalization }}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<abp-modal [(visible)]="service.isModalVisible" [busy]="service.modalBusy">
|
||||
<ng-template #abpHeader>
|
||||
<h5>Switch Tenant</h5>
|
||||
</ng-template>
|
||||
<ng-template #abpBody>
|
||||
<form (ngSubmit)="service.save()">
|
||||
<div class="mt-2">
|
||||
<div class="mb-3 form-group">
|
||||
<label for="name" class="form-label">{{
|
||||
'AbpUiMultiTenancy::Name' | abpLocalization
|
||||
}}</label>
|
||||
<input
|
||||
[(ngModel)]="service.name"
|
||||
type="text"
|
||||
id="name"
|
||||
name="tenant"
|
||||
class="form-control"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<p>{{ 'AbpUiMultiTenancy::SwitchTenantHint' | abpLocalization }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</ng-template>
|
||||
<ng-template #abpFooter>
|
||||
<button abpClose type="button" class="btn btn-outline-primary">
|
||||
{{ 'AbpTenantManagement::Cancel' | abpLocalization }}
|
||||
</button>
|
||||
<abp-button
|
||||
type="abp-button"
|
||||
iconClass="fa fa-check"
|
||||
(click)="service.save()"
|
||||
[disabled]="currentTenant?.name === service.name"
|
||||
>
|
||||
<span>{{ 'AbpTenantManagement::Save' | abpLocalization }}</span>
|
||||
</abp-button>
|
||||
</ng-template>
|
||||
</abp-modal>
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { TenantBoxService } from '@abp/ng.account.core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { LocalizationPipe } from '@abp/ng.core';
|
||||
import { ButtonComponent, ModalCloseDirective, ModalComponent } from '@abp/ng.theme.shared';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-tenant-box',
|
||||
templateUrl: './tenant-box.component.html',
|
||||
providers: [TenantBoxService],
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
ModalComponent,
|
||||
LocalizationPipe,
|
||||
ButtonComponent,
|
||||
ModalCloseDirective,
|
||||
],
|
||||
})
|
||||
export class TenantBoxComponent {
|
||||
service = inject(TenantBoxService);
|
||||
}
|
||||
+693
@@ -0,0 +1,693 @@
|
||||
<div
|
||||
class="app-shell"
|
||||
[class.sidebar-collapsed]="sidebarCollapsed"
|
||||
[class.mobile-open]="mobileOpen"
|
||||
>
|
||||
<!-- ══ MOBILE OVERLAY ══════════════════════════════════════════════ -->
|
||||
<div class="mobile-overlay" *ngIf="mobileOpen" (click)="mobileOpen = false"></div>
|
||||
|
||||
<!-- ══ SIDEBAR ═════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- ══ SIDEBAR ═════════════════════════════════════════════════════════ -->
|
||||
<aside class="sidebar" [class.collapsed]="sidebarCollapsed">
|
||||
<!-- ── HEAD: single block for both states ── -->
|
||||
<div class="sidebar__head" [class.sidebar__head--collapsed]="sidebarCollapsed">
|
||||
<!-- Logo (only when expanded) -->
|
||||
<div class="sidebar__logo" *ngIf="!sidebarCollapsed" (click)="navigateToDashboard()">
|
||||
<abp-logo *abpReplaceableTemplate="{ componentKey: service.logoComponentKey }"></abp-logo>
|
||||
</div>
|
||||
|
||||
<!-- Toggle button — chevron left / right -->
|
||||
<button
|
||||
class="sidebar__toggle"
|
||||
[title]="sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
(click)="sidebarCollapsed = !sidebarCollapsed; mobileOpen = false"
|
||||
>
|
||||
<!-- ← when expanded -->
|
||||
<svg
|
||||
*ngIf="!sidebarCollapsed"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
<!-- → when collapsed -->
|
||||
<svg
|
||||
*ngIf="sidebarCollapsed"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── NAV ── -->
|
||||
<nav class="sidebar__nav">
|
||||
<!-- EXPANDED -->
|
||||
<ng-container *ngIf="!sidebarCollapsed">
|
||||
<ng-container *ngFor="let route of visibleRoutes$ | async; trackBy: trackByFn">
|
||||
<ng-container *abpPermission="route.requiredPolicy || ''">
|
||||
<a
|
||||
*ngIf="route.isLeaf"
|
||||
class="nav-item"
|
||||
[routerLink]="[route.path]"
|
||||
[class.nav-item--active]="isRouteActive(route.path)"
|
||||
[title]="route.name | abpLocalization"
|
||||
>
|
||||
<span class="nav-item__indicator"></span>
|
||||
<span class="nav-item__icon" [innerHTML]="getRouteIcon(route)"></span>
|
||||
<span class="nav-item__label">{{ route.name | abpLocalization }}</span>
|
||||
</a>
|
||||
|
||||
<div *ngIf="!route.isLeaf && route.children?.length" class="nav-group">
|
||||
<button
|
||||
class="nav-item nav-item--group"
|
||||
(click)="toggleGroup(route.name)"
|
||||
[class.nav-item--expanded]="isGroupExpanded(route)"
|
||||
[class.nav-item--active]="isGroupRouteActive(route)"
|
||||
>
|
||||
<span class="nav-item__indicator"></span>
|
||||
<span class="nav-item__icon" [innerHTML]="getRouteIcon(route)"></span>
|
||||
<span class="nav-item__label">{{ route.name | abpLocalization }}</span>
|
||||
<svg
|
||||
class="nav-item__chevron"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="nav-group__children" *ngIf="isGroupExpanded(route)">
|
||||
<ng-container *ngFor="let child of route.children">
|
||||
<a
|
||||
*abpPermission="child.requiredPolicy || ''"
|
||||
class="nav-child"
|
||||
[routerLink]="[child.path]"
|
||||
[class.nav-child--active]="isRouteActive(child.path)"
|
||||
>
|
||||
{{ child.name | abpLocalization }}
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<div class="nav-section" *ngIf="showPlatformMenu">
|
||||
<button
|
||||
class="nav-section__toggle"
|
||||
(click)="platformGroupExpanded = !platformGroupExpanded"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
|
||||
/>
|
||||
</svg>
|
||||
<span>Platform</span>
|
||||
<svg
|
||||
class="nav-section__chevron"
|
||||
[class.rotated]="platformGroupExpanded"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="nav-section__items" *ngIf="platformGroupExpanded">
|
||||
<a
|
||||
class="nav-child"
|
||||
routerLink="/platform/tenants"
|
||||
routerLinkActive="nav-child--active"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
>Tenants</a
|
||||
>
|
||||
<a
|
||||
class="nav-child"
|
||||
routerLink="/platform/audit"
|
||||
routerLinkActive="nav-child--active"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
>Audit Log</a
|
||||
>
|
||||
<a
|
||||
class="nav-child"
|
||||
routerLink="/platform/security"
|
||||
routerLinkActive="nav-child--active"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
>Security</a
|
||||
>
|
||||
<a
|
||||
class="nav-child"
|
||||
routerLink="/platform/integrations"
|
||||
routerLinkActive="nav-child--active"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
>Integrations</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- COLLAPSED -->
|
||||
<ng-container *ngIf="sidebarCollapsed">
|
||||
<ng-container *ngFor="let route of visibleRoutes$ | async; trackBy: trackByFn">
|
||||
<ng-container *abpPermission="route.requiredPolicy || ''">
|
||||
<a
|
||||
*ngIf="route.isLeaf"
|
||||
class="nav-icon-item"
|
||||
[routerLink]="[route.path]"
|
||||
[class.nav-icon-item--active]="isRouteActive(route.path)"
|
||||
[title]="route.name | abpLocalization"
|
||||
>
|
||||
<span class="nav-icon-item__indicator"></span>
|
||||
<span [innerHTML]="getRouteIcon(route)"></span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
*ngIf="!route.isLeaf && route.children?.length"
|
||||
class="nav-icon-item"
|
||||
[routerLink]="[route.children[0]?.path]"
|
||||
[class.nav-icon-item--active]="isRouteActive(route.path) || isGroupRouteActive(route)"
|
||||
[title]="route.name | abpLocalization"
|
||||
>
|
||||
<span class="nav-icon-item__indicator"></span>
|
||||
<span [innerHTML]="getRouteIcon(route)"></span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<a
|
||||
*ngIf="showPlatformMenu"
|
||||
class="nav-icon-item"
|
||||
routerLink="/platform/tenants"
|
||||
routerLinkActive="nav-icon-item--active"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
title="Platform"
|
||||
>
|
||||
<span class="nav-icon-item__indicator"></span>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</ng-container>
|
||||
</nav>
|
||||
|
||||
<!-- ── FOOTER ── -->
|
||||
<!-- <div class="sidebar__footer">
|
||||
<a *ngIf="!sidebarCollapsed" href="#" class="nav-item nav-item--help" title="Help & Support">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="nav-item__label">Help & Support</span>
|
||||
</a>
|
||||
<a *ngIf="sidebarCollapsed" href="#" class="nav-icon-item" title="Help & Support">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div> -->
|
||||
</aside>
|
||||
|
||||
<!-- ══ MAIN AREA ════════════════════════════════════════════════════ -->
|
||||
<div class="main-area">
|
||||
<!-- ── TOP NAV ── -->
|
||||
<header class="topnav">
|
||||
<!-- Mobile hamburger -->
|
||||
<button class="icon-btn topnav__hamburger" (click)="mobileOpen = true" title="Open menu">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Search trigger -->
|
||||
<button *abpPermission="'DelightErm.NavbarGroupPermission.Navbar.GlobalSearch'" class="topnav__search" (click)="showSearchDialog = true">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<span>Search anything...</span>
|
||||
<kbd>/</kbd>
|
||||
</button>
|
||||
|
||||
<!-- Right side: ABP nav items + notification + user -->
|
||||
<div class="topnav__right">
|
||||
<!-- ABP Nav Items (language switcher etc.) -->
|
||||
<!-- <abp-nav-items
|
||||
*abpReplaceableTemplate="{ componentKey: service.navItemsComponentKey }">
|
||||
</abp-nav-items> -->
|
||||
|
||||
<!-- Create button -->
|
||||
<button *abpPermission="'DelightErm.NavbarGroupPermission.Navbar.Create'" class="btn-create" (click)="onCreateClick()">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Theme Toggle (Light / Dark) -->
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn topnav__icon-btn theme-toggle-btn"
|
||||
(click)="toggleTheme()"
|
||||
[title]="isDarkMode ? 'Switch to Light mode' : 'Switch to Dark mode'"
|
||||
[attr.aria-label]="isDarkMode ? 'Switch to Light mode' : 'Switch to Dark mode'"
|
||||
>
|
||||
<!-- Sun icon (shown in dark mode to switch to light) -->
|
||||
<svg
|
||||
*ngIf="isDarkMode"
|
||||
class="theme-icon theme-icon--sun"
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
|
||||
<!-- Moon icon (shown in light mode to switch to dark) -->
|
||||
<svg
|
||||
*ngIf="!isDarkMode"
|
||||
class="theme-icon theme-icon--moon"
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<div class="notif-wrap" #notificationRef>
|
||||
<button
|
||||
class="icon-btn topnav__icon-btn"
|
||||
(click)="toggleNotifications()"
|
||||
title="Notifications"
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
|
||||
/>
|
||||
</svg>
|
||||
<span class="notif-dot" *ngIf="unreadCount > 0 && !isLoading && !isLoadingMore"></span>
|
||||
<span class="notif-icon-spinner" *ngIf="isLoading || isLoadingMore"></span>
|
||||
</button>
|
||||
|
||||
<!-- Notification dropdown -->
|
||||
<div class="notif-dropdown" *ngIf="showNotifications">
|
||||
<div class="notif-dropdown__head">
|
||||
<div class="notif-dropdown__title-wrap">
|
||||
<span class="notif-dropdown__title">Notifications</span>
|
||||
<span class="notif-count-badge" *ngIf="unreadCount > 0">{{ unreadCount }} new</span>
|
||||
</div>
|
||||
<button class="link-btn" *ngIf="unreadCount > 0" (click)="markAllAsRead()">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
<div class="notif-dropdown__list" (scroll)="onDropdownScroll($event)">
|
||||
<!-- Initial Loading Spinner -->
|
||||
<div class="notif-spinner" *ngIf="isLoading && notifications.length === 0">
|
||||
<svg class="animate-spin" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="notif-empty" *ngIf="!isLoading && notifications.length === 0">
|
||||
<div class="notif-empty__icon-wrap">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="notif-empty__title">All caught up!</span>
|
||||
<span class="notif-empty__subtitle">You have no new notifications.</span>
|
||||
</div>
|
||||
|
||||
<!-- Notifications list -->
|
||||
<ng-container *ngIf="notifications.length > 0">
|
||||
<div
|
||||
class="notif-item"
|
||||
*ngFor="let n of notifications"
|
||||
(click)="onNotificationClick(n)"
|
||||
[class.notif-item--unread]="n.unread"
|
||||
>
|
||||
<div class="notif-item__left">
|
||||
<div class="notif-item__icon" [attr.data-type]="n.type">
|
||||
<svg
|
||||
*ngIf="n.type === 'risk'"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
*ngIf="n.type === 'task'"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg
|
||||
*ngIf="n.type === 'incident'"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
*ngIf="n.type === 'indicator'"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path
|
||||
d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-item__body">
|
||||
<div class="notif-item__title">{{ n.title }}</div>
|
||||
<div class="notif-item__msg">{{ n.message }}</div>
|
||||
<div class="notif-item__meta">
|
||||
<span class="notif-item__time">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px; display: inline-block; vertical-align: middle;">
|
||||
<path d="M12 6v6l4 2M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ n.timestamp }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-item__right" *ngIf="n.unread">
|
||||
<span class="notif-item__unread-indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Loading More Spinner at Bottom -->
|
||||
<div class="notif-spinner" *ngIf="isLoadingMore">
|
||||
<svg class="animate-spin" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<app-user-menu></app-user-menu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- PAGE ALERTS (ABP) -->
|
||||
<abp-page-alert-container></abp-page-alert-container>
|
||||
|
||||
<!-- CONTENT -->
|
||||
<main class="content">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SEARCH DIALOG ════════════════════════════════════════════════ -->
|
||||
<ng-container *abpPermission="'DelightErm.NavbarGroupPermission.Navbar.GlobalSearch'">
|
||||
<div class="dialog-overlay" *ngIf="showSearchDialog" (click)="closeSearchOnBackdrop($event)">
|
||||
<div class="search-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="search-dialog__head">
|
||||
<h3>Search</h3>
|
||||
<p>Search across risks, incidents, and tasks</p>
|
||||
</div>
|
||||
<div class="search-dialog__input-wrap">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
#searchInputRef
|
||||
type="text"
|
||||
placeholder="Search risks, incidents, tasks..."
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
/>
|
||||
<button *ngIf="searchQuery" (click)="searchQuery = ''; onSearchChange('')">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-dialog__results">
|
||||
<ng-container *ngIf="searchQuery && searchResults?.groups">
|
||||
<div class="search-section" *ngFor="let group of searchResults.groups">
|
||||
<div
|
||||
class="search-section__label search-section__label--clickable"
|
||||
[attr.data-group-key]="group.groupKey"
|
||||
(click)="toggleSearchGroup(group.groupKey)"
|
||||
title="Click to expand/collapse {{ group.label || group.groupKey }}"
|
||||
>
|
||||
<div class="search-section__head-left">
|
||||
<svg
|
||||
*ngIf="group.groupKey === 'Risk'"
|
||||
width="14"
|
||||
height="14"
|
||||
style="color: #f97316"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<svg
|
||||
*ngIf="group.groupKey === 'Incident'"
|
||||
width="14"
|
||||
height="14"
|
||||
style="color: #ef4444"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<svg
|
||||
*ngIf="group.groupKey === 'Task'"
|
||||
width="14"
|
||||
height="14"
|
||||
style="color: #3b82f6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg
|
||||
*ngIf="group.groupKey !== 'Risk' && group.groupKey !== 'Incident' && group.groupKey !== 'Task'"
|
||||
width="14"
|
||||
height="14"
|
||||
style="color: #64748b"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<span class="search-section__title">{{ group.label || group.groupKey }}</span>
|
||||
<span class="search-section__count">{{ group.items?.length || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
class="search-section__chevron"
|
||||
[class.search-section__chevron--collapsed]="isSearchGroupCollapsed(group.groupKey)"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="search-group-items" *ngIf="!isSearchGroupCollapsed(group.groupKey)">
|
||||
<div
|
||||
class="search-result"
|
||||
[attr.data-group]="group.groupKey"
|
||||
*ngFor="let item of group.items"
|
||||
(click)="onSearchResultClick(item)"
|
||||
>
|
||||
<div class="search-result__info">
|
||||
<div class="search-result__name">{{ item.title }}</div>
|
||||
<div class="search-result__sub">
|
||||
<span class="search-result__parent-tag" [attr.data-type]="group.groupKey">{{ group.groupKey }}</span>
|
||||
<span class="search-result__id">ID: {{ item.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="search-result__badge" [attr.data-type]="group.groupKey">{{ group.groupKey }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Results -->
|
||||
<div
|
||||
class="search-empty"
|
||||
*ngIf="!hasSearchResults()"
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<p>No results for "{{ searchQuery }}"</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="search-empty" *ngIf="!searchQuery">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<p>Start typing to search</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Create overlay component -->
|
||||
<app-create-overlay></app-create-overlay>
|
||||
+1195
File diff suppressed because it is too large.
Load diff
+639
@@ -0,0 +1,639 @@
|
||||
import { ABP, ConfigStateService, eLayoutType, EnvironmentService, LocalizationPipe, PermissionDirective, ReplaceableTemplateDirective, RoutesService, SubscriptionService, TreeNode } from '@abp/ng.core';
|
||||
import { collapseWithMargin, slideFromBottom } from '@abp/ng.theme.shared';
|
||||
import { AsyncPipe, CommonModule } from '@angular/common';
|
||||
import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, OnInit, TrackByFunction, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
import { Observable, Subject, Subscription } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { GlobalSearchService } from '../../../../../../src/app/proxy/erm-global-search/global-search.service';
|
||||
import { GlobalSearchResultDto, SearchItemDto } from '../../../../../../src/app/proxy/erm-global-search-contract/dto/models';
|
||||
import { environment } from '../../../../../../src/environments/environment';
|
||||
import { NotificationDto } from '../../../../../../src/app/proxy/notifications/dto/models';
|
||||
import { NotificationService } from '../../../../../../src/app/proxy/notifications/notification.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { ThemeService } from '../../services/theme.service';
|
||||
import { LogoComponent } from '../logo/logo.component';
|
||||
import { PageAlertContainerComponent } from '../page-alert-container/page-alert-container.component';
|
||||
import { CreateOverlayComponent } from "./create-overlay.component";
|
||||
import { CreateOverlayService } from './create-overlay.service';
|
||||
import { UserMenuComponent } from "./user-menu.component";
|
||||
import { PermissionService } from '@abp/ng.core';
|
||||
|
||||
/* ── Types (mirrors existing interfaces) ─────────────────────────── */
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
type: 'risk' | 'task' | 'incident' | 'indicator' | string;
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
unread: boolean;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
risks: { id: string; name: string; category: string; residualRisk: number }[];
|
||||
incidents: { id: string; title: string; priority?: string }[];
|
||||
tasks: { id: string; name: string; status: string; dueDate: string }[];
|
||||
}
|
||||
|
||||
/* ── Route icon map ──────────────────────────────────────────────── */
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
// ABP route names → SVG strings (stroke icons, 18×18)
|
||||
Dashboard: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/></svg>`,
|
||||
Incidents: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>`,
|
||||
Risks: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>`,
|
||||
Assessments: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>`,
|
||||
Tasks: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125v-9M10.125 2.25h.375a9 9 0 019 9v.375M10.125 2.25A3.375 3.375 0 0113.5 5.625v1.5c0 .621.504 1.125 1.125 1.125h1.5a3.375 3.375 0 013.375 3.375M9 15l2.25 2.25L15 12"/></svg>`,
|
||||
Indicators:`<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"/></svg>`,
|
||||
Reporting: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"/></svg>`,
|
||||
Admin: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`,
|
||||
DEFAULT: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>`,
|
||||
};
|
||||
@Component({
|
||||
selector: 'abp-layout-application',
|
||||
templateUrl: './application-layout.component.html',
|
||||
styleUrl: './application-layout.component.scss',
|
||||
animations: [slideFromBottom, collapseWithMargin],
|
||||
providers: [LayoutService, SubscriptionService,PermissionDirective],
|
||||
imports: [
|
||||
CommonModule,
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
RouterOutlet,
|
||||
LocalizationPipe,
|
||||
PermissionDirective,
|
||||
ReplaceableTemplateDirective,
|
||||
LogoComponent,
|
||||
PageAlertContainerComponent,
|
||||
UserMenuComponent,
|
||||
CreateOverlayComponent,
|
||||
],
|
||||
})
|
||||
|
||||
export class ApplicationLayoutComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
static type = eLayoutType.application;
|
||||
readonly service = inject(LayoutService);
|
||||
readonly themeService = inject(ThemeService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly config = inject(ConfigStateService);
|
||||
private readonly routesService = inject(RoutesService);
|
||||
private readonly createOverlay = inject(CreateOverlayService);
|
||||
private readonly environment = inject(EnvironmentService);
|
||||
private readonly notificationSvc = inject(NotificationService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly oauthService = inject(OAuthService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly globalSearchService = inject(GlobalSearchService);
|
||||
private readonly permissionService = inject(PermissionService);
|
||||
private isDestroyed = false;
|
||||
|
||||
private readonly SIDEBAR_KEY = 'delight_sidebar_collapsed';
|
||||
|
||||
get isDarkMode(): boolean {
|
||||
return this.themeService.isDarkMode;
|
||||
}
|
||||
|
||||
toggleTheme(): void {
|
||||
this.themeService.toggleTheme();
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────────── */
|
||||
private _sidebarCollapsed = false;
|
||||
isMobile = false;
|
||||
mobileOpen = false;
|
||||
platformGroupExpanded = false;
|
||||
expandedGroups: Record<string, boolean> = {};
|
||||
|
||||
constructor() {
|
||||
const stored = localStorage.getItem(this.SIDEBAR_KEY);
|
||||
this.routesService.remove(['AbpUiNavigation::Menu:Administration']);
|
||||
if (stored !== null) this._sidebarCollapsed = stored === 'true';
|
||||
|
||||
}
|
||||
|
||||
set sidebarCollapsed(v: boolean) {
|
||||
this._sidebarCollapsed = v;
|
||||
localStorage.setItem(this.SIDEBAR_KEY, String(v));
|
||||
}
|
||||
get sidebarCollapsed(): boolean { return this._sidebarCollapsed; }
|
||||
|
||||
/* ── ABP Routes observable ────────────────────────────────────── */
|
||||
visibleRoutes$: Observable<TreeNode<ABP.Route>[]> = this.routesService.visible$;
|
||||
|
||||
trackByFn: TrackByFunction<TreeNode<ABP.Route>> = (_, item) => item.name;
|
||||
|
||||
getRouteIcon(route: TreeNode<ABP.Route>): string {
|
||||
if (route.iconClass) {
|
||||
return `<i class="${route.iconClass}" aria-hidden="true"></i>`;
|
||||
}
|
||||
|
||||
const routeName = String(route.name ?? '');
|
||||
const normalizedName = routeName.startsWith('::Menu:')
|
||||
? routeName.replace('::Menu:', '')
|
||||
: routeName;
|
||||
|
||||
return ROUTE_ICONS[normalizedName] ?? ROUTE_ICONS['DEFAULT'];
|
||||
}
|
||||
|
||||
toggleGroup(name: string): void {
|
||||
if (this.expandedGroups[name] === undefined) {
|
||||
this.expandedGroups[name] = false;
|
||||
} else {
|
||||
this.expandedGroups[name] = !this.expandedGroups[name];
|
||||
}
|
||||
}
|
||||
|
||||
isGroupExpanded(route: any): boolean {
|
||||
if (this.expandedGroups[route.name] !== undefined) {
|
||||
return this.expandedGroups[route.name];
|
||||
}
|
||||
return this.isGroupRouteActive(route);
|
||||
}
|
||||
|
||||
/* ── Route helpers ────────────────────────────────────────────── */
|
||||
getDashboardRoute(): string {
|
||||
const user = this.config.getOne('currentUser') as any;
|
||||
const roles: string[] = user?.roles ?? [];
|
||||
if (roles.includes('Super Admin')) return '/platform-dashboard';
|
||||
if (roles.includes('Tenant Admin')) return '/tenant-dashboard';
|
||||
if (roles.includes('Department')) return '/department-dashboard';
|
||||
return '/';
|
||||
}
|
||||
|
||||
navigateToDashboard(): void {
|
||||
this.router.navigateByUrl(this.getDashboardRoute());
|
||||
}
|
||||
|
||||
get isAdminRoute(): boolean {
|
||||
const url = this.router.url;
|
||||
return url.startsWith('/admin') || url.startsWith('/config') || url.startsWith('/configuration');
|
||||
}
|
||||
|
||||
isRouteActive(routePath?: string): boolean {
|
||||
const url = this.router.url;
|
||||
const cleanUrl = url.split('?')[0].replace(/^\/|\/$/g, '');
|
||||
const cleanRoutePath = (routePath || '').replace(/^\/|\/$/g, '');
|
||||
|
||||
if (cleanUrl === cleanRoutePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cleanRoutePath === 'risk-register') {
|
||||
return (
|
||||
cleanUrl.startsWith('risk-details') ||
|
||||
cleanUrl.startsWith('new-risk-details')
|
||||
);
|
||||
}
|
||||
|
||||
if (cleanRoutePath === 'risk-assessment') {
|
||||
return (
|
||||
cleanUrl === 'risk-assessment' ||
|
||||
cleanUrl.startsWith('risk-assessment/') ||
|
||||
cleanUrl.startsWith('risk-assessment-details')
|
||||
);
|
||||
}
|
||||
|
||||
if (cleanRoutePath === 'incident-management') {
|
||||
return cleanUrl.startsWith('incident-details');
|
||||
}
|
||||
|
||||
if (cleanRoutePath === 'task-management') {
|
||||
return cleanUrl.startsWith('tasks/');
|
||||
}
|
||||
|
||||
if (cleanRoutePath === 'admin') {
|
||||
return (
|
||||
cleanUrl === 'admin' ||
|
||||
cleanUrl.startsWith('admin/') ||
|
||||
cleanUrl.startsWith('user-roles') ||
|
||||
cleanUrl.startsWith('approval-groups') ||
|
||||
cleanUrl.startsWith('user-profile-details') ||
|
||||
cleanUrl.startsWith('departments') ||
|
||||
cleanUrl.startsWith('location') ||
|
||||
cleanUrl.startsWith('email-template') ||
|
||||
cleanUrl.startsWith('assets-types') ||
|
||||
cleanUrl.startsWith('incident-priority') ||
|
||||
cleanUrl.startsWith('config/') ||
|
||||
cleanUrl.startsWith('risk-matrix') ||
|
||||
cleanUrl.startsWith('threshold')
|
||||
);
|
||||
}
|
||||
|
||||
if (cleanRoutePath && cleanUrl.startsWith(cleanRoutePath + '/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isGroupRouteActive(route: any): boolean {
|
||||
if (!route.children) return false;
|
||||
return route.children.some((child: any) => this.isRouteActive(child.path));
|
||||
}
|
||||
|
||||
get showPlatformMenu(): boolean {
|
||||
const user = this.config.getOne('currentUser') as any;
|
||||
const roles: string[] = user?.roles ?? [];
|
||||
return roles.includes('Super Admin');
|
||||
}
|
||||
|
||||
/* ── Mobile ───────────────────────────────────────────────────── */
|
||||
@HostListener('window:resize')
|
||||
onResize(): void { this.checkMobile(); }
|
||||
|
||||
private checkMobile(): void {
|
||||
this.isMobile = window.innerWidth < 1024;
|
||||
if (!this.isMobile) this.mobileOpen = false;
|
||||
}
|
||||
|
||||
/* ── Keyboard shortcuts ───────────────────────────────────────── */
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
|
||||
if (event.key === '/' && !isInput && !this.showSearchDialog) {
|
||||
if (this.permissionService.getGrantedPolicy('DelightErm.NavbarGroupPermission.Navbar.GlobalSearch')) {
|
||||
event.preventDefault();
|
||||
this.showSearchDialog = true;
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
this.showSearchDialog = false;
|
||||
this.showNotifications = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Notifications ────────────────────────────────────────────── */
|
||||
@ViewChild('notificationRef') notificationRef!: ElementRef<HTMLDivElement>;
|
||||
showNotifications = false;
|
||||
|
||||
private hubConnection!: HubConnection;
|
||||
notifications: AppNotification[] = [];
|
||||
isLoading = false;
|
||||
isLoadingMore = false;
|
||||
hasMore = true;
|
||||
|
||||
get unreadCount(): number { return this.notifications.filter(n => n.unread).length; }
|
||||
toggleNotifications(): void { this.showNotifications = !this.showNotifications; }
|
||||
|
||||
loadNotifications(isInitial: boolean = false): void {
|
||||
if (isInitial) {
|
||||
this.isLoading = true;
|
||||
this.notifications = [];
|
||||
this.hasMore = true;
|
||||
} else {
|
||||
if (this.isLoading || this.isLoadingMore || !this.hasMore) return;
|
||||
this.isLoadingMore = true;
|
||||
}
|
||||
|
||||
const skipCount = isInitial ? 0 : this.notifications.length;
|
||||
this.notificationSvc.getList(skipCount, 10).subscribe({
|
||||
next: (dtos: NotificationDto[]) => {
|
||||
const mapped = (dtos || []).map(d => this.mapNotification(d));
|
||||
if (isInitial) {
|
||||
this.notifications = mapped;
|
||||
this.isLoading = false;
|
||||
} else {
|
||||
this.notifications = [...this.notifications, ...mapped];
|
||||
this.isLoadingMore = false;
|
||||
}
|
||||
if ((dtos || []).length < 10) {
|
||||
this.hasMore = false;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading notifications:', err);
|
||||
this.isLoading = false;
|
||||
this.isLoadingMore = false;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private mapNotification(dto: NotificationDto): AppNotification {
|
||||
const type = this.getNotificationType(dto.actionTitle || dto.action || '');
|
||||
return {
|
||||
id: dto.id || '',
|
||||
type,
|
||||
title: dto.actionTitle || 'Notification',
|
||||
message: dto.action || '',
|
||||
timestamp: (dto as any).creationTime ? this.formatTimeAgo((dto as any).creationTime) : 'Recent',
|
||||
unread: !dto.isRead,
|
||||
link: this.getNotificationLink(type)
|
||||
};
|
||||
}
|
||||
|
||||
private getNotificationType(text: string): string {
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('risk')) return 'risk';
|
||||
if (lower.includes('task')) return 'task';
|
||||
if (lower.includes('incident')) return 'incident';
|
||||
if (lower.includes('indicator') || lower.includes('kri')) return 'indicator';
|
||||
return 'task'; // fallback type
|
||||
}
|
||||
|
||||
private getNotificationLink(type: string): string {
|
||||
if (type === 'risk') return '/risks';
|
||||
if (type === 'task') return '/tasks';
|
||||
if (type === 'incident') return '/incidents';
|
||||
return '/';
|
||||
}
|
||||
|
||||
private formatTimeAgo(dateInput: any): string {
|
||||
if (!dateInput) return 'Recent';
|
||||
try {
|
||||
const date = new Date(dateInput);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return 'Just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'Yesterday';
|
||||
return `${days} days ago`;
|
||||
} catch {
|
||||
return 'Recent';
|
||||
}
|
||||
}
|
||||
|
||||
initSignalR(): void {
|
||||
const tenantId = (this.config.getOne('currentTenant')?.id || '00000000-0000-0000-0000-000000000000').toLowerCase();
|
||||
const rawUrl = environment.apis?.default?.url || '';
|
||||
const apiBaseUrl = rawUrl.replace('{0}.', '');
|
||||
const hubUrl = `${apiBaseUrl.replace(/\/$/, '')}/erm-notification-hub/${tenantId}`;
|
||||
|
||||
console.log('🚀 initSignalR called');
|
||||
console.log('🔑 TenantId:', tenantId);
|
||||
console.log('🌐 Hub URL:', hubUrl);
|
||||
console.log('🎫 Token:', this.oauthService.getAccessToken() ? 'Present ✅' : 'Missing ❌');
|
||||
|
||||
this.hubConnection = new HubConnectionBuilder()
|
||||
.withUrl(hubUrl, {
|
||||
accessTokenFactory: () => {
|
||||
const token = this.oauthService.getAccessToken() || '';
|
||||
console.log('🎫 accessTokenFactory called, token length:', token.length);
|
||||
return token;
|
||||
}
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
console.log('🔧 HubConnection built, registering handlers...');
|
||||
|
||||
this.hubConnection.on('NotificationEvent', (data: any) => {
|
||||
console.log('🔔 ===== NotificationEvent FIRED =====');
|
||||
console.log('🔔 Raw data:', data);
|
||||
console.log('🔔 JSON:', JSON.stringify(data, null, 2));
|
||||
|
||||
if (data) {
|
||||
console.log('✅ Data exists, entering ngZone.run...');
|
||||
this.ngZone.run(() => {
|
||||
console.log('✅ Inside ngZone.run');
|
||||
const mapped = this.mapNotification(data);
|
||||
console.log('✅ Mapped notification:', mapped);
|
||||
this.notifications = [mapped, ...this.notifications];
|
||||
console.log('✅ notifications length now:', this.notifications.length);
|
||||
this.cdr.markForCheck();
|
||||
console.log('✅ markForCheck called');
|
||||
});
|
||||
} else {
|
||||
console.warn('⚠️ data is null or undefined');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🔧 NotificationEvent handler registered');
|
||||
|
||||
this.hubConnection.onreconnecting((error) => {
|
||||
console.warn('🔄 SignalR reconnecting:', error);
|
||||
});
|
||||
|
||||
this.hubConnection.onreconnected((connectionId) => {
|
||||
console.log('🔄 SignalR reconnected. ID:', connectionId);
|
||||
});
|
||||
|
||||
this.hubConnection.onclose((error) => {
|
||||
console.error('❌ SignalR connection closed:', error);
|
||||
if (!this.isDestroyed) {
|
||||
console.log('🔄 Attempting restart...');
|
||||
this.startHubConnection();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🔧 All handlers registered, starting connection...');
|
||||
this.startHubConnection();
|
||||
}
|
||||
|
||||
private startHubConnection(): void {
|
||||
if (this.isDestroyed) return;
|
||||
|
||||
console.log('🚀 startHubConnection called, current state:', this.hubConnection.state);
|
||||
|
||||
this.hubConnection.start()
|
||||
.then(() => {
|
||||
console.log('✅ SignalR connected successfully');
|
||||
console.log('✅ Connection ID:', this.hubConnection.connectionId);
|
||||
console.log('✅ Connection state:', this.hubConnection.state);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('❌ SignalR connection failed:', err);
|
||||
console.error('❌ Error details:', JSON.stringify(err));
|
||||
if (!this.isDestroyed) {
|
||||
console.log('🔄 Retrying in 5 seconds...');
|
||||
setTimeout(() => this.startHubConnection(), 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDropdownScroll(event: Event): void {
|
||||
const element = event.target as HTMLElement;
|
||||
if (element.scrollHeight - element.scrollTop <= element.clientHeight + 15) {
|
||||
this.loadNotifications(false);
|
||||
}
|
||||
}
|
||||
|
||||
markAllAsRead(): void {
|
||||
this.notificationSvc.markAsReadAllNotifications().subscribe({
|
||||
next: () => {
|
||||
this.notifications = this.notifications.map(n => ({ ...n, unread: false }));
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error marking notifications as read:', err);
|
||||
this.notifications = this.notifications.map(n => ({ ...n, unread: false }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onNotificationClick(n: AppNotification): void {
|
||||
n.unread = false;
|
||||
this.showNotifications = false;
|
||||
}
|
||||
|
||||
@HostListener('document:mousedown', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
if (this.showNotifications && this.notificationRef?.nativeElement &&
|
||||
!this.notificationRef.nativeElement.contains(event.target as Node)) {
|
||||
this.showNotifications = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────────────────────────── */
|
||||
private _showSearchDialog = false;
|
||||
get showSearchDialog(): boolean { return this._showSearchDialog; }
|
||||
set showSearchDialog(val: boolean) {
|
||||
this._showSearchDialog = val;
|
||||
if (val) {
|
||||
setTimeout(() => {
|
||||
this.searchInputRef?.nativeElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
searchQuery = '';
|
||||
searchResults: GlobalSearchResultDto | null = null;
|
||||
private readonly searchSubject = new Subject<string>();
|
||||
private searchSub?: Subscription;
|
||||
|
||||
@ViewChild('searchInputRef') searchInputRef!: ElementRef<HTMLInputElement>;
|
||||
|
||||
onSearchChange(q: string): void {
|
||||
this.searchSubject.next(q);
|
||||
}
|
||||
|
||||
onSearchResultClick(item: SearchItemDto): void {
|
||||
let path = item.routeHint || '';
|
||||
if (!path) return;
|
||||
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path;
|
||||
}
|
||||
|
||||
if (item.id && !path.includes(item.id)) {
|
||||
const qIndex = path.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
const basePath = path.substring(0, qIndex);
|
||||
const queryStr = path.substring(qIndex);
|
||||
const cleanBase = basePath.endsWith('/') ? basePath : basePath + '/';
|
||||
path = cleanBase + item.id + queryStr;
|
||||
} else {
|
||||
if (!path.endsWith('/')) {
|
||||
path += '/';
|
||||
}
|
||||
path += item.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Split into base route and query parameters
|
||||
let urlPath = path;
|
||||
let queryParams: Record<string, string> = {};
|
||||
|
||||
const firstQ = path.indexOf('?');
|
||||
if (firstQ !== -1) {
|
||||
urlPath = path.substring(0, firstQ);
|
||||
const rawQuery = path.substring(firstQ + 1).replace(/\?/g, '&');
|
||||
const pairs = rawQuery.split('&');
|
||||
pairs.forEach(p => {
|
||||
const [k, v] = p.split('=');
|
||||
if (k && k.trim()) {
|
||||
queryParams[k.trim()] = decodeURIComponent(v || '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
if (Object.keys(queryParams).length > 0) {
|
||||
this.router.navigate([urlPath], {
|
||||
queryParams,
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
} else {
|
||||
this.router.navigateByUrl(urlPath);
|
||||
}
|
||||
});
|
||||
|
||||
this.showSearchDialog = false;
|
||||
this.searchQuery = '';
|
||||
this.searchResults = null;
|
||||
}
|
||||
|
||||
closeSearchOnBackdrop(event: MouseEvent): void {
|
||||
if ((event.target as HTMLElement) === event.currentTarget) {
|
||||
this.showSearchDialog = false;
|
||||
this.searchQuery = '';
|
||||
this.searchResults = null;
|
||||
}
|
||||
}
|
||||
|
||||
collapsedSearchGroups: Record<string, boolean> = {};
|
||||
|
||||
toggleSearchGroup(groupKey: string): void {
|
||||
if (!groupKey) return;
|
||||
this.collapsedSearchGroups[groupKey] = !this.collapsedSearchGroups[groupKey];
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
isSearchGroupCollapsed(groupKey: string): boolean {
|
||||
if (!groupKey) return false;
|
||||
return !!this.collapsedSearchGroups[groupKey];
|
||||
}
|
||||
|
||||
hasSearchResults(): boolean {
|
||||
if (!this.searchResults || !this.searchResults.groups) return false;
|
||||
return this.searchResults.groups.some(g => g.items && g.items.length > 0);
|
||||
}
|
||||
|
||||
/* ── Create ───────────────────────────────────────────────────── */
|
||||
onCreateClick(): void { this.createOverlay.open(); }
|
||||
|
||||
/* ── Lifecycle ────────────────────────────────────────────────── */
|
||||
ngOnInit(): void {
|
||||
this.checkMobile();
|
||||
this.loadNotifications(true);
|
||||
this.initSignalR();
|
||||
|
||||
this.searchSub = this.searchSubject
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(q => {
|
||||
const keyword = (q || '').trim();
|
||||
if (!keyword) {
|
||||
this.searchResults = null;
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
this.globalSearchService.search({ keyword, maxResultsPerGroup: 5 }).subscribe({
|
||||
next: (res: GlobalSearchResultDto) => {
|
||||
this.searchResults = res;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Global search error:', err);
|
||||
this.searchResults = null;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ngAfterViewInit(): void { this.service.subscribeWindowSize(); }
|
||||
ngOnDestroy(): void {
|
||||
this.isDestroyed = true;
|
||||
if (this.searchSub) {
|
||||
this.searchSub.unsubscribe();
|
||||
}
|
||||
if (this.hubConnection) {
|
||||
this.hubConnection.stop()
|
||||
.then(() => console.log('SignalR connection stopped.'))
|
||||
.catch(err => console.error('Error stopping SignalR connection:', err));
|
||||
}
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
CREATE OVERLAY SELECTOR MODAL
|
||||
Uses inline styles for guaranteed responsiveness (no Tailwind dependency).
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<ng-container *ngIf="isOpen">
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
style="
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
"
|
||||
(click)="onBackdropClick($event)"
|
||||
>
|
||||
|
||||
<!-- Modal Container -->
|
||||
<div
|
||||
style="
|
||||
position: relative;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 860px;
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.18), 0 0 0 1px rgba(226,232,240,0.6);
|
||||
margin: 0 auto;
|
||||
"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
|
||||
<!-- Header Area -->
|
||||
<div style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 28px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
">
|
||||
<div>
|
||||
<h2 style="margin: 0; font-size: clamp(16px, 2.5vw, 20px); font-weight: 700; color: #1e293b; line-height: 1.3;">
|
||||
What would you like to create?
|
||||
</h2>
|
||||
<p style="margin: 4px 0 0; font-size: 13px; color: #94a3b8;">
|
||||
Select an entity type to get started
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="close()"
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
"
|
||||
onmouseenter="this.style.background='#f1f5f9'; this.style.color='#475569';"
|
||||
onmouseleave="this.style.background='transparent'; this.style.color='#94a3b8';"
|
||||
title="Close"
|
||||
>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content wrapper -->
|
||||
<div style="padding: 24px 28px;">
|
||||
|
||||
<!-- Options Grid — auto-reflowing: 3 cols → 2 cols → 1 col -->
|
||||
<div style="
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
">
|
||||
|
||||
<!-- Incident -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="navigateToCreate('Incident')"
|
||||
style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #f1f5f9;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
"
|
||||
onmouseenter="this.style.transform='translateY(-3px)'; this.style.borderColor='#fecdd3'; this.style.background='#fff1f2'; this.style.boxShadow='0 10px 24px -4px rgba(244,63,94,0.08)';"
|
||||
onmouseleave="this.style.transform='none'; this.style.borderColor='#f1f5f9'; this.style.background='#ffffff'; this.style.boxShadow='none';"
|
||||
>
|
||||
<!-- Red Icon -->
|
||||
<div style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #fff1f2;
|
||||
color: #f43f5e;
|
||||
flex-shrink: 0;
|
||||
">
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span style="display: block; font-size: 15px; font-weight: 700; color: #1e293b; margin-bottom: 6px;">Incident</span>
|
||||
<span style="display: block; font-size: 12px; color: #64748b; line-height: 1.5;">Report a new incident or event for investigation</span>
|
||||
</button>
|
||||
|
||||
<!-- Risk -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="navigateToCreate('Risk')"
|
||||
style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #f1f5f9;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
"
|
||||
onmouseenter="this.style.transform='translateY(-3px)'; this.style.borderColor='#bfdbfe'; this.style.background='#eff6ff'; this.style.boxShadow='0 10px 24px -4px rgba(59,130,246,0.08)';"
|
||||
onmouseleave="this.style.transform='none'; this.style.borderColor='#f1f5f9'; this.style.background='#ffffff'; this.style.boxShadow='none';"
|
||||
>
|
||||
<!-- Blue Icon -->
|
||||
<div style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
">
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span style="display: block; font-size: 15px; font-weight: 700; color: #1e293b; margin-bottom: 6px;">Risk</span>
|
||||
<span style="display: block; font-size: 12px; color: #64748b; line-height: 1.5;">Register a new risk through the governance lifecycle</span>
|
||||
</button>
|
||||
|
||||
<!-- Task -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="navigateToCreate('Task')"
|
||||
style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #f1f5f9;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
"
|
||||
onmouseenter="this.style.transform='translateY(-3px)'; this.style.borderColor='#a7f3d0'; this.style.background='#ecfdf5'; this.style.boxShadow='0 10px 24px -4px rgba(16,185,129,0.08)';"
|
||||
onmouseleave="this.style.transform='none'; this.style.borderColor='#f1f5f9'; this.style.background='#ffffff'; this.style.boxShadow='none';"
|
||||
>
|
||||
<!-- Green Icon -->
|
||||
<div style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #ecfdf5;
|
||||
color: #10b981;
|
||||
flex-shrink: 0;
|
||||
">
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span style="display: block; font-size: 15px; font-weight: 700; color: #1e293b; margin-bottom: 6px;">Task</span>
|
||||
<span style="display: block; font-size: 12px; color: #64748b; line-height: 1.5;">Create a new task linked to a risk or incident</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer Notice -->
|
||||
<div style="text-align: center; padding-top: 4px;">
|
||||
<span style="font-size: 12px; color: #94a3b8; font-weight: 500;">
|
||||
Assessments are embedded in the risk governance lifecycle.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
HostListener,
|
||||
inject,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { CreateOverlayService, CreateEntityType } from './create-overlay.service';
|
||||
|
||||
// ── Form interfaces ───────────────────────────────────────────────────────────
|
||||
|
||||
interface IncidentForm {
|
||||
title: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
dateOccurred: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
urgency: string;
|
||||
financialLoss: number | null;
|
||||
affectedSystems: string;
|
||||
immediateActions: string;
|
||||
}
|
||||
|
||||
interface TaskForm {
|
||||
name: string;
|
||||
linkedEntityType: string;
|
||||
linkedEntityId: string;
|
||||
owner: string;
|
||||
dueDate: string;
|
||||
priority: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface AssessmentForm {
|
||||
name: string;
|
||||
type: string;
|
||||
dueDate: string;
|
||||
assignedTo: string;
|
||||
priority: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
interface RiskForm {
|
||||
name: string;
|
||||
category: string;
|
||||
primaryOwner: string;
|
||||
description: string;
|
||||
likelihood: string;
|
||||
impact: string;
|
||||
}
|
||||
|
||||
// ── Risk step names (matches React RISK_STEPS array) ─────────────────────────
|
||||
const RISK_STEPS = [
|
||||
'Risk Identification',
|
||||
'Root Cause & Consequence',
|
||||
'Inherent Risk Assessment',
|
||||
'Risk Analysis & Voting',
|
||||
'Control Identification',
|
||||
'Residual Risk Calculation',
|
||||
'Target Risk Definition',
|
||||
'Mitigation Planning',
|
||||
'KRI Linking',
|
||||
'Reassessment',
|
||||
'Monitoring Phase',
|
||||
'Risk Closure',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-overlay',
|
||||
templateUrl: './create-overlay.component.html',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
})
|
||||
export class CreateOverlayComponent implements OnInit, OnDestroy {
|
||||
|
||||
private readonly overlayService = inject(CreateOverlayService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
// ── Expose constants to template ─────────────────────────────────
|
||||
readonly RISK_STEPS = RISK_STEPS;
|
||||
readonly RISK_TOTAL_STEPS = RISK_STEPS.length;
|
||||
|
||||
// ── Overlay state (reads from service signals) ────────────────────
|
||||
get isOpen(): boolean { return this.overlayService.isOpen(); }
|
||||
get preselected(): CreateEntityType { return this.overlayService.preselected(); }
|
||||
|
||||
// ── Navigation state ──────────────────────────────────────────────
|
||||
/** step 1 = type selector, step 2+ = form */
|
||||
step = 1;
|
||||
/** Which entity type is selected */
|
||||
selectedType: CreateEntityType = null;
|
||||
/** Sub-step within multi-step forms (Incident: 1-3, Risk: 1-12) */
|
||||
formStep = 1;
|
||||
/** Submission spinner */
|
||||
isSubmitting = false;
|
||||
|
||||
// ── Form models ───────────────────────────────────────────────────
|
||||
incidentForm: IncidentForm = this.freshIncident();
|
||||
taskForm: TaskForm = this.freshTask();
|
||||
assessmentForm: AssessmentForm = this.freshAssessment();
|
||||
riskForm: RiskForm = this.freshRisk();
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
ngOnInit(): void {
|
||||
// When the service opens with a pre-selected type, skip step 1
|
||||
// We use a poll/effect pattern since Angular signals don't auto-subscribe
|
||||
// in class fields — wire this manually if you use signal-based change detection
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Always restore scroll on destroy to avoid a stuck lock
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
|
||||
private lockBodyScroll(): void {
|
||||
// Lock every possible scroll host in this app's layout
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
const content = document.querySelector<HTMLElement>('.content');
|
||||
const mainArea = document.querySelector<HTMLElement>('.main-area');
|
||||
const appShell = document.querySelector<HTMLElement>('.app-shell');
|
||||
if (content) { content.dataset['prevOverflow'] = content.style.overflow; content.style.overflow = 'hidden'; }
|
||||
if (mainArea) { mainArea.dataset['prevOverflow'] = mainArea.style.overflow; mainArea.style.overflow = 'hidden'; }
|
||||
if (appShell) { appShell.dataset['prevOverflow'] = appShell.style.overflow; appShell.style.overflow = 'hidden'; }
|
||||
}
|
||||
|
||||
private unlockBodyScroll(): void {
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
const content = document.querySelector<HTMLElement>('.content');
|
||||
const mainArea = document.querySelector<HTMLElement>('.main-area');
|
||||
const appShell = document.querySelector<HTMLElement>('.app-shell');
|
||||
if (content) content.style.overflow = content.dataset['prevOverflow'] ?? '';
|
||||
if (mainArea) mainArea.style.overflow = mainArea.dataset['prevOverflow'] ?? '';
|
||||
if (appShell) appShell.style.overflow = appShell.dataset['prevOverflow'] ?? '';
|
||||
}
|
||||
|
||||
// ── Watch service state ───────────────────────────────────────────
|
||||
/**
|
||||
* Called by the parent layout whenever the overlay opens.
|
||||
* The parent template renders <app-create-overlay> and the service
|
||||
* signals drive isOpen automatically.
|
||||
* If a preselectedType is set, jump straight to step 2.
|
||||
*/
|
||||
private _wasOpen = false;
|
||||
|
||||
ngDoCheck(): void {
|
||||
const open = this.isOpen;
|
||||
|
||||
// Lock / unlock body scroll whenever open state toggles
|
||||
if (open && !this._wasOpen) {
|
||||
this.lockBodyScroll();
|
||||
} else if (!open && this._wasOpen) {
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
this._wasOpen = open;
|
||||
|
||||
if (open && this.preselected && this.step === 1) {
|
||||
this.selectedType = this.preselected;
|
||||
this.step = 2;
|
||||
this.formStep = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type selection ────────────────────────────────────────────────
|
||||
selectType(type: CreateEntityType): void {
|
||||
this.selectedType = type;
|
||||
}
|
||||
|
||||
navigateToCreate(type: 'Risk' | 'Incident' | 'Task'): void {
|
||||
this.close();
|
||||
if (type === 'Risk') {
|
||||
this.router.navigate(['/risk-register'], { queryParams: { create: 'true' } });
|
||||
} else if (type === 'Incident') {
|
||||
this.router.navigate(['/incident-management'], { queryParams: { create: 'true' } });
|
||||
} else if (type === 'Task') {
|
||||
this.router.navigate(['/task-management'], { queryParams: { create: 'true' } });
|
||||
}
|
||||
}
|
||||
|
||||
proceedFromTypeSelection(): void {
|
||||
if (!this.selectedType) return;
|
||||
this.step = 2;
|
||||
this.formStep = 1;
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────
|
||||
goBack(): void {
|
||||
if (this.formStep > 1) {
|
||||
this.formStep--;
|
||||
} else {
|
||||
// Back to type selector
|
||||
this.step = 1;
|
||||
this.selectedType = null;
|
||||
this.formStep = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix: use this.formStep
|
||||
// private get formStep(): number { return this._formStep; }
|
||||
// private set formStep(v: number) { this._formStep = v; }
|
||||
_formStep = 1;
|
||||
|
||||
nextStep(): void {
|
||||
if (!this.canProceed()) return;
|
||||
this._formStep++;
|
||||
}
|
||||
|
||||
prevStep(): void {
|
||||
if (this._formStep > 1) this._formStep--;
|
||||
}
|
||||
|
||||
isLastFormStep(): boolean {
|
||||
if (this.selectedType === 'Risk') return this._formStep === this.RISK_TOTAL_STEPS;
|
||||
if (this.selectedType === 'Incident') return this._formStep === 3;
|
||||
// Task and Assessment are single-step
|
||||
return this.selectedType === 'Task' || this.selectedType === 'Assessment';
|
||||
}
|
||||
|
||||
// ── Titles ────────────────────────────────────────────────────────
|
||||
getTitle(): string {
|
||||
if (this.step === 1) return 'What would you like to create?';
|
||||
if (this.selectedType === 'Task') return 'Create Task';
|
||||
if (this.selectedType === 'Incident') return 'Create Incident';
|
||||
if (this.selectedType === 'Assessment') return 'Create Assessment';
|
||||
if (this.selectedType === 'Risk')
|
||||
return `Create Risk — ${RISK_STEPS[this._formStep - 1] || 'Lifecycle'}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
getSubtitle(): string {
|
||||
if (this.step === 1) return 'Select an entity type to get started';
|
||||
if (this.selectedType === 'Task') return 'Create a new task linked to an entity';
|
||||
if (this.selectedType === 'Assessment') return 'Run a structured risk or compliance assessment';
|
||||
return '';
|
||||
}
|
||||
|
||||
getRiskStepName(): string {
|
||||
return RISK_STEPS[this._formStep - 1] ?? '';
|
||||
}
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────
|
||||
canProceed(): boolean {
|
||||
if (this.selectedType === 'Incident') {
|
||||
if (this._formStep === 1) return !!this.incidentForm.title && !!this.incidentForm.category && !!this.incidentForm.priority;
|
||||
if (this._formStep === 2) return !!this.incidentForm.impact;
|
||||
return true;
|
||||
}
|
||||
if (this.selectedType === 'Task') {
|
||||
return !!this.taskForm.name && !!this.taskForm.owner && !!this.taskForm.dueDate;
|
||||
}
|
||||
if (this.selectedType === 'Assessment') {
|
||||
return !!this.assessmentForm.name && !!this.assessmentForm.type;
|
||||
}
|
||||
if (this.selectedType === 'Risk') {
|
||||
if (this._formStep === 1) return !!this.riskForm.name && !!this.riskForm.category;
|
||||
return true; // placeholder steps pass through
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────
|
||||
handleSubmit(): void {
|
||||
if (!this.canProceed() || this.isSubmitting) return;
|
||||
this.isSubmitting = true;
|
||||
|
||||
// Simulate async save — replace with your real API service call
|
||||
setTimeout(() => {
|
||||
this.isSubmitting = false;
|
||||
console.log('Submitted:', this.selectedType, this.getFormData());
|
||||
this.close();
|
||||
// Optionally navigate: this.router.navigateByUrl('/risks');
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
private getFormData(): unknown {
|
||||
switch (this.selectedType) {
|
||||
case 'Incident': return this.incidentForm;
|
||||
case 'Task': return this.taskForm;
|
||||
case 'Assessment': return this.assessmentForm;
|
||||
case 'Risk': return this.riskForm;
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close / reset ─────────────────────────────────────────────────
|
||||
close(): void {
|
||||
this.overlayService.close();
|
||||
// Reset after animation frame so closing feels instant
|
||||
setTimeout(() => {
|
||||
this.step = 1;
|
||||
this.selectedType = null;
|
||||
this._formStep = 1;
|
||||
this.isSubmitting = false;
|
||||
this.incidentForm = this.freshIncident();
|
||||
this.taskForm = this.freshTask();
|
||||
this.assessmentForm = this.freshAssessment();
|
||||
this.riskForm = this.freshRisk();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
// Only close if the backdrop itself was clicked (not the panel)
|
||||
if ((event.target as HTMLElement) === event.currentTarget) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape key closes overlay */
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (this.isOpen) this.close();
|
||||
}
|
||||
|
||||
// ── Fresh form factories ──────────────────────────────────────────
|
||||
private freshIncident(): IncidentForm {
|
||||
return { title:'', category:'', priority:'', status:'Open', dateOccurred:'',
|
||||
description:'', impact:'', urgency:'', financialLoss:null,
|
||||
affectedSystems:'', immediateActions:'' };
|
||||
}
|
||||
|
||||
private freshTask(): TaskForm {
|
||||
return { name:'', linkedEntityType:'', linkedEntityId:'',
|
||||
owner:'', dueDate:'', priority:'', description:'' };
|
||||
}
|
||||
|
||||
private freshAssessment(): AssessmentForm {
|
||||
return { name:'', type:'', dueDate:'', assignedTo:'', priority:'', scope:'' };
|
||||
}
|
||||
|
||||
private freshRisk(): RiskForm {
|
||||
return { name:'', category:'', primaryOwner:'', description:'',
|
||||
likelihood:'', impact:'' };
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
export type CreateEntityType = 'Risk' | 'Incident' | 'Task' | 'Assessment' | null;
|
||||
|
||||
export interface LinkedEntity {
|
||||
type: 'Risk' | 'Incident' | 'Task';
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateOverlayState {
|
||||
isOpen: boolean;
|
||||
preselectedType: CreateEntityType;
|
||||
linkedEntity?: LinkedEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular equivalent of React's CreateOverlayContext.
|
||||
* Inject this wherever you need to open the Create overlay.
|
||||
*
|
||||
* Usage:
|
||||
* constructor(private createOverlay: CreateOverlayService) {}
|
||||
* this.createOverlay.open({ type: 'Risk' });
|
||||
* this.createOverlay.open(); // shows type selector
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CreateOverlayService {
|
||||
|
||||
private _state = signal<CreateOverlayState>({
|
||||
isOpen: false,
|
||||
preselectedType: null,
|
||||
});
|
||||
|
||||
/** Read-only state signal */
|
||||
readonly state = this._state.asReadonly();
|
||||
|
||||
/** Convenience computed signals */
|
||||
readonly isOpen = computed(() => this._state().isOpen);
|
||||
readonly preselected = computed(() => this._state().preselectedType);
|
||||
readonly linkedEntity = computed(() => this._state().linkedEntity);
|
||||
|
||||
/**
|
||||
* Open the overlay. Optionally pre-select a type and/or linked entity.
|
||||
* Matches React: openCreate({ type?, linkedEntity? })
|
||||
*/
|
||||
open(opts?: { type?: CreateEntityType; linkedEntity?: LinkedEntity }): void {
|
||||
this._state.set({
|
||||
isOpen: true,
|
||||
preselectedType: opts?.type ?? null,
|
||||
linkedEntity: opts?.linkedEntity,
|
||||
});
|
||||
}
|
||||
|
||||
/** Close and reset — matches React closeCreate() */
|
||||
close(): void {
|
||||
this._state.set({
|
||||
isOpen: false,
|
||||
preselectedType: null,
|
||||
linkedEntity: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<!-- User Menu Wrapper -->
|
||||
<div class="relative" #menuRef>
|
||||
|
||||
<!-- ── Avatar Button ── -->
|
||||
<button type="button" (click)="toggleMenu()"
|
||||
class="flex items-center gap-2.5 px-2.5 py-1.5 rounded-xl transition-all duration-150 border-none bg-transparent cursor-pointer hover:bg-[var(--clr-bg-hover)]">
|
||||
<!-- Avatar circle with initials -->
|
||||
<div class="w-8 h-8 rounded-xl flex items-center justify-center text-white text-[12px] font-semibold flex-shrink-0"
|
||||
style="background: linear-gradient(135deg, #4F46E5, #4338CA); box-shadow: 0 1px 3px rgba(79,70,229,0.3);">
|
||||
{{ initials }}
|
||||
</div>
|
||||
|
||||
<!-- Name + role (hidden on small screens, visible on sm+) -->
|
||||
<div class="text-left hidden sm:block">
|
||||
<div class="text-[13px] font-medium text-[var(--clr-text)]">{{ displayName }}</div>
|
||||
<div class="text-[11px] text-[var(--clr-text-subtle)]">{{ displayRole }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<svg class="w-3.5 h-3.5 hidden sm:block transition-transform duration-150 text-[var(--clr-text-subtle)]"
|
||||
[style.transform]="isOpen ? 'rotate(180deg)' : 'rotate(0deg)'" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- ── Dropdown ── -->
|
||||
<div *ngIf="isOpen" class="absolute right-0 mt-2 w-64 bg-[var(--clr-bg-card)] rounded-2xl py-2 z-50 border border-[var(--clr-border)]"
|
||||
style="box-shadow: var(--shadow-xl);">
|
||||
|
||||
<!-- User info header -->
|
||||
<div class="px-4 py-3 border-b border-[var(--clr-border)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center text-white font-semibold flex-shrink-0"
|
||||
style="background: linear-gradient(135deg, #4F46E5, #4338CA); box-shadow: 0 1px 3px rgba(79,70,229,0.3);">
|
||||
{{ initials }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[13px] truncate text-[var(--clr-text)]">{{ displayName }}</div>
|
||||
<div class="text-[12px] truncate text-[var(--clr-text-subtle)]">{{ displayEmail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Role badge -->
|
||||
<div class="mt-2" *ngIf="displayRole">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-[var(--clr-primary-lt)] text-[var(--clr-primary)] border border-[var(--clr-border-focus)]">{{ displayRole }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu items -->
|
||||
<div class="py-1 px-1">
|
||||
<button type="button" (click)="navigate('/account-details')"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded-xl transition-all duration-150 border-none bg-transparent cursor-pointer text-left text-[var(--clr-text-muted)] hover:bg-[var(--clr-bg-hover)] hover:text-[var(--clr-text)]">
|
||||
<!-- User icon -->
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-[var(--clr-text-subtle)]" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
<span>My Account</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="px-1 pt-1 mt-1 border-t border-[var(--clr-border)]">
|
||||
<button *ngIf="displayName !== 'Guest User'" type="button" (click)="handleLogout()"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded-xl transition-all duration-150 border-none bg-transparent cursor-pointer text-left text-red-500 hover:bg-red-500/10">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
<span>Log Out</span>
|
||||
</button>
|
||||
|
||||
<button *ngIf="displayName === 'Guest User'" type="button" (click)="navigate('/login')"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded-xl transition-all duration-150 border-none bg-transparent cursor-pointer text-left text-[var(--clr-primary)] hover:bg-[var(--clr-primary-lt)]">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m-6 0l3-3m0 0l-3-3m3 3H3" />
|
||||
</svg>
|
||||
<span>Log In</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
HostListener,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { ConfigStateService, AuthService } from '@abp/ng.core';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-menu',
|
||||
templateUrl: './user-menu.component.html',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
})
|
||||
|
||||
export class UserMenuComponent implements OnInit, OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly config = inject(ConfigStateService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
@ViewChild('menuRef') menuRef!: ElementRef<HTMLDivElement>;
|
||||
|
||||
isOpen = false;
|
||||
|
||||
// ── Derived display values ────────────────────────────────────────
|
||||
displayName = 'Guest User';
|
||||
displayEmail = 'guest@company.com';
|
||||
displayRole = 'Department';
|
||||
initials = 'GU';
|
||||
|
||||
ngOnInit(): void {
|
||||
// this.loadUser();
|
||||
this.config.getAll$()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.loadUser());
|
||||
}
|
||||
|
||||
private loadUser(): void {
|
||||
const currentUser = this.config.getOne('currentUser') as any;
|
||||
|
||||
// Reset to guest defaults when logged out
|
||||
if (!currentUser?.isAuthenticated) {
|
||||
this.displayName = 'Guest User';
|
||||
this.displayEmail = '';
|
||||
this.displayRole = '';
|
||||
this.initials = 'GU';
|
||||
return;
|
||||
}
|
||||
|
||||
const name: string = currentUser.name
|
||||
|| currentUser.userName
|
||||
|| currentUser.email
|
||||
|| 'Guest User';
|
||||
|
||||
this.displayName = name;
|
||||
this.displayEmail = currentUser.email ?? '';
|
||||
|
||||
const roles: string[] = currentUser.roles ?? [];
|
||||
this.displayRole = roles[0] ?? 'User';
|
||||
|
||||
this.initials = this.getInitials(name);
|
||||
}
|
||||
|
||||
private getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// ── Toggle ────────────────────────────────────────────────────────
|
||||
toggleMenu(): void {
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
// ── Close on outside click — mirrors React useEffect ─────────────
|
||||
@HostListener('document:mousedown', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
if (
|
||||
this.isOpen &&
|
||||
this.menuRef?.nativeElement &&
|
||||
!this.menuRef.nativeElement.contains(event.target as Node)
|
||||
) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────
|
||||
navigate(path: string): void {
|
||||
this.router.navigateByUrl(path);
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
// ── Logout — mirrors React handleLogout ──────────────────────────
|
||||
handleLogout(): void {
|
||||
this.isOpen = false;
|
||||
this.auth.logout().subscribe({
|
||||
next: () => { /* ABP handles redirect via post_logout_redirect_uri */ },
|
||||
error: () => this.router.navigateByUrl('/'),
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// HostListener cleanup is automatic
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { eLayoutType } from '@abp/ng.core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-layout-empty',
|
||||
template: ` <router-outlet></router-outlet> `,
|
||||
imports: [RouterOutlet],
|
||||
})
|
||||
export class EmptyLayoutComponent {
|
||||
static type = eLayoutType.empty;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './account-layout/account-layout.component';
|
||||
export * from './account-layout/auth-wrapper/auth-wrapper.component';
|
||||
export * from './account-layout/tenant-box/tenant-box.component';
|
||||
export * from './application-layout/application-layout.component';
|
||||
export * from './empty-layout/empty-layout.component';
|
||||
export * from './logo/logo.component';
|
||||
export * from './nav-items/current-user.component';
|
||||
export * from './nav-items/languages.component';
|
||||
export * from './nav-items/nav-items.component';
|
||||
export * from './page-alert-container/page-alert-container.component';
|
||||
export * from './routes/routes.component';
|
||||
export * from './validation-error/validation-error.component';
|
||||
@@ -0,0 +1,148 @@
|
||||
import { EnvironmentService } from '@abp/ng.core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { LOGO_APP_NAME_TOKEN, LOGO_URL_TOKEN } from '@abp/ng.theme.shared';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-logo',
|
||||
template: `
|
||||
<a class="logo-link" routerLink="/">
|
||||
<!-- ── If a logoUrl is configured in environment.ts, use it ── -->
|
||||
<ng-container *ngIf="logoUrl; else defaultLogo">
|
||||
<img [src]="logoUrl" [alt]="appName" class="logo-img" />
|
||||
</ng-container>
|
||||
|
||||
<!-- ── Default: branded wordmark ── -->
|
||||
<ng-template #defaultLogo>
|
||||
<div class="logo-wordmark">
|
||||
<!-- Icon mark -->
|
||||
<div class="logo-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Shield base -->
|
||||
<path
|
||||
d="M16 2L4 7.5V16.5C4 22.85 9.33 28.74 16 30C22.67 28.74 28 22.85 28 16.5V7.5L16 2Z"
|
||||
fill="url(#shield-grad)"
|
||||
/>
|
||||
<!-- Inner check / chart line — ERM motif -->
|
||||
<polyline
|
||||
points="9,16 13,20 23,11"
|
||||
stroke="white"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="shield-grad"
|
||||
x1="4"
|
||||
y1="2"
|
||||
x2="28"
|
||||
y2="30"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stop-color="var(--clr-primary)" />
|
||||
<stop offset="100%" stop-color="var(--clr-primary-dk)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="logo-text">
|
||||
<span class="logo-text__brand">Delight</span><span class="logo-text__suffix">ERM</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</a>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Link reset ───────────────────────────────────────── */
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── External image logo ──────────────────────────────── */
|
||||
.logo-img {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Wordmark layout ──────────────────────────────────── */
|
||||
.logo-wordmark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
/* ── Icon mark ────────────────────────────────────────── */
|
||||
.logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 2px 4px rgba(37, 99, 235, 0.25));
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Text ─────────────────────────────────────────────── */
|
||||
.logo-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logo-text__brand {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--clr-text);
|
||||
font-family: var(--font-base);
|
||||
}
|
||||
|
||||
.logo-text__suffix {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--clr-primary);
|
||||
font-family: var(--font-base);
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
imports: [RouterLink, CommonModule],
|
||||
})
|
||||
export class LogoComponent {
|
||||
private readonly environment = inject(EnvironmentService);
|
||||
private readonly providedLogoUrl = inject(LOGO_URL_TOKEN, { optional: true });
|
||||
private readonly providedAppName = inject(LOGO_APP_NAME_TOKEN, { optional: true });
|
||||
|
||||
get logoUrl(): string {
|
||||
return this.providedLogoUrl ?? this.environment.getEnvironment().application?.logoUrl ?? '';
|
||||
}
|
||||
|
||||
get appName(): string {
|
||||
return this.providedAppName ?? this.environment.getEnvironment().application?.name ?? 'DelightERM';
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
@if ((currentUser$ | async)?.isAuthenticated) {
|
||||
<div
|
||||
ngbDropdown
|
||||
class="dropdown"
|
||||
#currentUserDropdown="ngbDropdown"
|
||||
display="static"
|
||||
>
|
||||
<a
|
||||
ngbDropdownToggle
|
||||
class="nav-link"
|
||||
href="javascript:void(0)"
|
||||
role="button"
|
||||
id="dropdownMenuLink"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
@if ((selectedTenant$ | async)?.name; as tenantName) {
|
||||
<small
|
||||
><i>{{ tenantName }}</i
|
||||
>\</small
|
||||
>
|
||||
}
|
||||
<strong>{{ (currentUser$ | async)?.userName }}</strong>
|
||||
</a>
|
||||
<div
|
||||
class="dropdown-menu dropdown-menu-end border-0 shadow-sm"
|
||||
aria-labelledby="dropdownMenuLink"
|
||||
[class.d-block]="smallScreen && currentUserDropdown.isOpen()"
|
||||
>
|
||||
@for (item of userMenu.items$ | async; track $index) {
|
||||
<ng-container *abpVisible="!item.visible || item.visible(item)">
|
||||
<li class="nav-item d-flex align-items-center" *abpPermission="item.requiredPolicy || ''">
|
||||
@if (item.component) {
|
||||
<ng-container
|
||||
[ngComponentOutlet]="item.component"
|
||||
[ngComponentOutletInjector]="item | toInjector"
|
||||
></ng-container>
|
||||
} @else {
|
||||
@if (item.html) {
|
||||
<div [innerHTML]="item.html" (click)="item.action ? item.action() : null"></div>
|
||||
} @else {
|
||||
@if (item.textTemplate) {
|
||||
<a (click)="item.action ? item.action() : null" class="dropdown-item pointer">
|
||||
@if (item.textTemplate.icon) {
|
||||
<i class="me-1 {{ item.textTemplate.icon }}"></i>
|
||||
}
|
||||
{{ item.textTemplate.text | abpLocalization }}</a
|
||||
>
|
||||
}
|
||||
}
|
||||
}
|
||||
</li>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<a role="button" class="nav-link pointer" (click)="navigateToLogin()">
|
||||
{{ 'AbpAccount::Login' | abpLocalization }}
|
||||
</a>
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AuthService,
|
||||
ConfigStateService,
|
||||
CurrentUserDto,
|
||||
LocalizationPipe,
|
||||
NAVIGATE_TO_MANAGE_PROFILE,
|
||||
PermissionDirective,
|
||||
SessionStateService,
|
||||
ToInjectorPipe,
|
||||
} from '@abp/ng.core';
|
||||
import { AbpVisibleDirective, UserMenu, UserMenuService } from '@abp/ng.theme.shared';
|
||||
import { Component, TrackByFunction, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { NgComponentOutlet, AsyncPipe, DOCUMENT } from '@angular/common';
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-current-user',
|
||||
templateUrl: './current-user.component.html',
|
||||
imports: [
|
||||
NgComponentOutlet,
|
||||
AsyncPipe,
|
||||
NgbDropdownModule,
|
||||
AbpVisibleDirective,
|
||||
PermissionDirective,
|
||||
ToInjectorPipe,
|
||||
LocalizationPipe,
|
||||
],
|
||||
})
|
||||
export class CurrentUserComponent {
|
||||
readonly navigateToManageProfile = inject(NAVIGATE_TO_MANAGE_PROFILE);
|
||||
readonly userMenu = inject(UserMenuService);
|
||||
private authService = inject(AuthService);
|
||||
private configState = inject(ConfigStateService);
|
||||
private sessionState = inject(SessionStateService);
|
||||
private document = inject(DOCUMENT);
|
||||
|
||||
currentUser$: Observable<CurrentUserDto> = this.configState.getOne$('currentUser');
|
||||
selectedTenant$ = this.sessionState.getTenant$();
|
||||
trackByFn: TrackByFunction<UserMenu> = (_, element) => element.id;
|
||||
|
||||
get smallScreen(): boolean {
|
||||
return (this.document.defaultView?.innerWidth ?? 0) < 992;
|
||||
}
|
||||
|
||||
navigateToLogin() {
|
||||
this.authService.navigateToLogin();
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.authService.logout().subscribe();
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { ConfigStateService, LanguageInfo, SessionStateService } from '@abp/ng.core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { DOCUMENT, AsyncPipe } from '@angular/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-languages',
|
||||
template: `
|
||||
@if (((dropdownLanguages$ | async)?.length || 0) > 0) {
|
||||
<div class="dropdown" ngbDropdown #languageDropdown="ngbDropdown" display="static">
|
||||
<a
|
||||
ngbDropdownToggle
|
||||
class="nav-link"
|
||||
href="javascript:void(0)"
|
||||
role="button"
|
||||
id="dropdownMenuLink"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{{ defaultLanguage$ | async }}
|
||||
</a>
|
||||
<div
|
||||
class="dropdown-menu dropdown-menu-end border-0 shadow-sm"
|
||||
aria-labelledby="dropdownMenuLink"
|
||||
[class.d-block]="smallScreen && languageDropdown.isOpen()"
|
||||
>
|
||||
@for (lang of dropdownLanguages$ | async; track $index) {
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="dropdown-item"
|
||||
(click)="onChangeLang(lang.cultureName || '')"
|
||||
>{{ lang?.displayName }}</a
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
imports: [AsyncPipe, NgbDropdownModule],
|
||||
})
|
||||
export class LanguagesComponent {
|
||||
private sessionState = inject(SessionStateService);
|
||||
private configState = inject(ConfigStateService);
|
||||
document = inject(DOCUMENT);
|
||||
|
||||
get smallScreen(): boolean {
|
||||
return (this.document.defaultView?.innerWidth ?? 0) < 992;
|
||||
}
|
||||
|
||||
languages$: Observable<LanguageInfo[]> = this.configState.getDeep$('localization.languages');
|
||||
|
||||
get defaultLanguage$(): Observable<string> {
|
||||
return this.languages$.pipe(
|
||||
map(
|
||||
languages =>
|
||||
languages?.find(lang => lang.cultureName === this.selectedLangCulture)?.displayName || '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get dropdownLanguages$(): Observable<LanguageInfo[]> {
|
||||
return this.languages$.pipe(
|
||||
map(
|
||||
languages => languages?.filter(lang => lang.cultureName !== this.selectedLangCulture) || [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get selectedLangCulture(): string {
|
||||
return this.sessionState.getLanguage();
|
||||
}
|
||||
|
||||
onChangeLang(cultureName: string) {
|
||||
this.sessionState.setLanguage(cultureName);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<ul class="navbar-nav">
|
||||
@if (isBrowser) {
|
||||
@for (item of navItems.items$ | async; track $index) {
|
||||
<ng-container *abpVisible="!item.visible || item.visible(item)">
|
||||
<li class="nav-item d-flex align-items-center" *abpPermission="item.requiredPolicy || ''">
|
||||
@if (item.component) {
|
||||
<ng-container
|
||||
[ngComponentOutlet]="item.component"
|
||||
[ngComponentOutletInjector]="item | toInjector"
|
||||
></ng-container>
|
||||
} @else {
|
||||
<div [innerHTML]="item.html" (click)="item.action ? item.action() : null"></div>
|
||||
}
|
||||
</li>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { AbpVisibleDirective, NavItem, NavItemsService } from '@abp/ng.theme.shared';
|
||||
import { Component, TrackByFunction, inject, PLATFORM_ID } from '@angular/core';
|
||||
import { NgComponentOutlet, AsyncPipe, isPlatformBrowser } from '@angular/common';
|
||||
import { PermissionDirective, ToInjectorPipe } from '@abp/ng.core';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-nav-items',
|
||||
templateUrl: 'nav-items.component.html',
|
||||
imports: [NgComponentOutlet, AsyncPipe, AbpVisibleDirective, PermissionDirective, ToInjectorPipe],
|
||||
})
|
||||
export class NavItemsComponent {
|
||||
readonly navItems = inject(NavItemsService);
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
readonly isBrowser = isPlatformBrowser(this.platformId);
|
||||
|
||||
trackByFn: TrackByFunction<NavItem> = (_, element) => element.id;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
@for (alert of service.alerts$ | async; track i; let i = $index) {
|
||||
<div
|
||||
class="alert alert-{{ alert.type }} fade show"
|
||||
[ngClass]="{ 'alert-dismissible fade show': alert.dismissible }"
|
||||
role="alert"
|
||||
>
|
||||
@if (alert.title) {
|
||||
<h4
|
||||
class="alert-heading"
|
||||
[innerHTML]="alert.title | abpSafeHtml | abpLocalization: alert.titleLocalizationParams"
|
||||
></h4>
|
||||
}
|
||||
<span
|
||||
[innerHTML]="alert.message | abpSafeHtml | abpLocalization: alert.messageLocalizationParams"
|
||||
></span>
|
||||
@if (alert.dismissible) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-dismiss="alert"
|
||||
aria-label="Close"
|
||||
(click)="service.remove(i)"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Component, ViewEncapsulation, inject } from '@angular/core';
|
||||
import { PageAlertService } from '@abp/ng.theme.shared';
|
||||
import { NgClass, AsyncPipe } from '@angular/common';
|
||||
import { LocalizationPipe, SafeHtmlPipe } from '@abp/ng.core';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-page-alert-container',
|
||||
templateUrl: './page-alert-container.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
imports: [NgClass, AsyncPipe, LocalizationPipe, SafeHtmlPipe],
|
||||
})
|
||||
export class PageAlertContainerComponent {
|
||||
service = inject(PageAlertService);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<ul class="navbar-nav">
|
||||
@for (route of routesService.visible$ | async; track $index) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="isDropdown(route) ? dropdownLink : defaultLink"
|
||||
[ngTemplateOutletContext]="{ $implicit: route }"
|
||||
/>
|
||||
}
|
||||
|
||||
<ng-template #defaultLink let-route>
|
||||
<li class="nav-item" *abpPermission="route.requiredPolicy">
|
||||
<a class="nav-link" [routerLink]="[route.path]">
|
||||
@if (route.iconClass) {
|
||||
<i [ngClass]="route.iconClass"></i>
|
||||
}
|
||||
{{ route.name | abpAsyncLocalization | async }}
|
||||
</a>
|
||||
</li>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #dropdownLink let-route>
|
||||
@if (route.children?.length) {
|
||||
<li
|
||||
class="nav-item dropdown"
|
||||
display="static"
|
||||
*abpPermission="route.requiredPolicy"
|
||||
(click)="
|
||||
rootDropdownExpand[route.name]
|
||||
? (rootDropdownExpand[route.name] = false)
|
||||
: (rootDropdownExpand[route.name] = true)
|
||||
"
|
||||
>
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
href="javascript:void(0)"
|
||||
>
|
||||
@if (route.iconClass) {
|
||||
<i [ngClass]="route.iconClass"></i>
|
||||
}
|
||||
{{ route.name | abpAsyncLocalization | async }}
|
||||
</a>
|
||||
<div
|
||||
#routeContainer
|
||||
class="dropdown-menu border-0 shadow-sm"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation()"
|
||||
[class.d-block]="smallScreen && rootDropdownExpand[route.name]"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="forTemplate; context: { $implicit: route }" />
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #forTemplate let-route>
|
||||
@for (child of route.children; track $index) {
|
||||
<ng-template
|
||||
[ngTemplateOutlet]="child.children?.length ? dropdownChild : defaultChild"
|
||||
[ngTemplateOutletContext]="{ $implicit: child }"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #defaultChild let-child>
|
||||
@if (child.path) {
|
||||
<div class="dropdown-submenu" *abpPermission="child.requiredPolicy">
|
||||
<a class="dropdown-item" [routerLink]="[child.path]" (click)="closeDropdown()">
|
||||
@if (child.iconClass) {
|
||||
<i [ngClass]="child.iconClass"></i>
|
||||
}
|
||||
{{ child.name | abpLocalization }}</a
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #dropdownChild let-child>
|
||||
<div
|
||||
class="dropdown-submenu"
|
||||
ngbDropdown
|
||||
#dropdownSubmenu="ngbDropdown"
|
||||
placement="right-top"
|
||||
[autoClose]="true"
|
||||
*abpPermission="child.requiredPolicy"
|
||||
>
|
||||
<div ngbDropdownToggle [class.dropdown-toggle]="false">
|
||||
<a
|
||||
abpEllipsis="210px"
|
||||
[abpEllipsisEnabled]="!smallScreen"
|
||||
role="button"
|
||||
class="btn d-block text-start dropdown-toggle"
|
||||
>
|
||||
@if (child.iconClass) {
|
||||
<i [ngClass]="child.iconClass"></i>
|
||||
}
|
||||
{{ child.name | abpLocalization }}
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
#childrenContainer
|
||||
class="dropdown-menu dropdown-menu-start border-0 shadow-sm"
|
||||
[class.d-block]="smallScreen && dropdownSubmenu.isOpen()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="forTemplate; context: { $implicit: child }" />
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ul>
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
ABP,
|
||||
AsyncLocalizationPipe,
|
||||
LocalizationPipe,
|
||||
PermissionDirective,
|
||||
RoutesService,
|
||||
TreeNode,
|
||||
} from '@abp/ng.core';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
QueryList,
|
||||
Renderer2,
|
||||
TrackByFunction,
|
||||
ViewChildren,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet, NgClass, AsyncPipe } from '@angular/common';
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { EllipsisDirective } from '@abp/ng.theme.shared';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-routes',
|
||||
templateUrl: 'routes.component.html',
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
NgClass,
|
||||
AsyncPipe,
|
||||
RouterLink,
|
||||
NgbDropdownModule,
|
||||
AsyncLocalizationPipe,
|
||||
PermissionDirective,
|
||||
EllipsisDirective,
|
||||
LocalizationPipe,
|
||||
],
|
||||
})
|
||||
export class RoutesComponent {
|
||||
public readonly routesService = inject(RoutesService);
|
||||
protected renderer = inject(Renderer2);
|
||||
|
||||
@Input() smallScreen?: boolean;
|
||||
|
||||
@ViewChildren('childrenContainer') childrenContainers!: QueryList<ElementRef<HTMLDivElement>>;
|
||||
|
||||
rootDropdownExpand = {} as { [key: string]: boolean };
|
||||
|
||||
trackByFn: TrackByFunction<TreeNode<ABP.Route>> = (_, item) => item.name;
|
||||
|
||||
isDropdown(node: TreeNode<ABP.Route>) {
|
||||
return !node?.isLeaf || this.routesService.hasChildren(node.name);
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
this.childrenContainers.forEach(({ nativeElement }) => {
|
||||
this.renderer.addClass(nativeElement, 'd-none');
|
||||
setTimeout(() => this.renderer.removeClass(nativeElement, 'd-none'), 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
|
||||
import { Validation, ValidationErrorComponent as ErrorComponent } from '@ngx-validate/core';
|
||||
import { LocalizationPipe } from '@abp/ng.core';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-validation-error',
|
||||
template: `
|
||||
@for (error of abpErrors; track $index) {
|
||||
<div class="invalid-feedback">
|
||||
{{ error.message | abpLocalization: error.interpoliteParams }}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
imports: [LocalizationPipe],
|
||||
})
|
||||
export class ValidationErrorComponent extends ErrorComponent {
|
||||
get abpErrors(): (Validation.Error & { interpoliteParams?: string[] })[] {
|
||||
if (!this.errors || !this.errors.length) return [];
|
||||
|
||||
return this.errors.map(error => {
|
||||
if (!error.message) return error;
|
||||
|
||||
const index = error.message.indexOf('[');
|
||||
|
||||
if (index > -1) {
|
||||
return {
|
||||
...error,
|
||||
message: error.message.slice(0, index),
|
||||
interpoliteParams: error.message.slice(index + 1, error.message.length - 1).split(','),
|
||||
};
|
||||
}
|
||||
|
||||
return error;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
export default `
|
||||
.content-header-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.entry-row {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
#main-navbar-tools a.dropdown-toggle {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
.navbar .dropdown-submenu {
|
||||
position: relative;
|
||||
}
|
||||
.navbar .dropdown-menu {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.navbar .dropdown-menu a {
|
||||
font-size: .9em;
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
min-width: 210px;
|
||||
text-align: left;
|
||||
border-radius: 0.25rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
[dir=rtl] .navbar .dropdown-menu a {
|
||||
text-align: right!important;
|
||||
}
|
||||
.navbar .dropdown-submenu a::after {
|
||||
transform: rotate(-90deg);
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 18px;
|
||||
}
|
||||
[dir=rtl] .navbar .dropdown-submenu a::after {
|
||||
transform: rotate(90deg);
|
||||
left: 16px;
|
||||
right: auto;
|
||||
top: 20px;
|
||||
}
|
||||
.navbar .dropdown-submenu .dropdown-menu {
|
||||
top: 0;
|
||||
left: 100%;
|
||||
}
|
||||
/* work around for rtl. Track https://github.com/ng-bootstrap/ng-bootstrap/issues/4100 issue */
|
||||
[dir=rtl] .navbar .dropdown-submenu .dropdown-menu {
|
||||
top: 0;
|
||||
right: 100%;
|
||||
}
|
||||
.card-header .btn {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.card-header h5 {
|
||||
margin: 0;
|
||||
}
|
||||
.container > .card {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;
|
||||
}
|
||||
@media screen and (min-width: 992px) {
|
||||
.navbar .dropdown:hover > .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar .dropdown-submenu:hover > .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.input-validation-error {
|
||||
border-color: #dc3545;
|
||||
}
|
||||
.field-validation-error {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.ui-table .ui-table-tbody > tr.empty-row > div.empty-row-content {
|
||||
border: 1px solid #c8c8c8;
|
||||
}
|
||||
.abp-loading {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.modal-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.confirmation .confirmation-backdrop {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
.confirmation .confirmation-dialog {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px -5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.confirmation .confirmation-dialog .icon-container .icon {
|
||||
stroke: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
.confirmation .confirmation-dialog .icon-container.info .icon {
|
||||
stroke: #2f96b4;
|
||||
color: #2f96b4;
|
||||
}
|
||||
.confirmation .confirmation-dialog .icon-container.success .icon {
|
||||
stroke: #51a351;
|
||||
color: #51a351;
|
||||
}
|
||||
.confirmation .confirmation-dialog .icon-container.warning .icon {
|
||||
stroke: #f89406;
|
||||
color: #f89406;
|
||||
}
|
||||
.confirmation .confirmation-dialog .icon-container.error .icon {
|
||||
stroke: #bd362f;
|
||||
color: #bd362f;
|
||||
}
|
||||
.confirmation .confirmation-dialog .content .title {
|
||||
color: #222;
|
||||
}
|
||||
.confirmation .confirmation-dialog .content .message {
|
||||
color: #777;
|
||||
}
|
||||
.confirmation .confirmation-dialog .footer {
|
||||
background: transparent;
|
||||
}
|
||||
.confirmation .confirmation-dialog .footer .confirmation-button {
|
||||
background-color: #eee;
|
||||
color: #777;
|
||||
}
|
||||
.confirmation .confirmation-dialog .footer .confirmation-button:hover, .confirmation .confirmation-dialog .footer .confirmation-button:focus, .confirmation .confirmation-dialog .footer .confirmation-button:active {
|
||||
background-color: #bbb;
|
||||
}
|
||||
.confirmation .confirmation-dialog .footer .confirmation-button--confirm {
|
||||
background-color: #2f96b4;
|
||||
color: #fff;
|
||||
}
|
||||
.confirmation .confirmation-dialog .footer .confirmation-button--confirm:hover {
|
||||
background-color: #2e819b;
|
||||
}
|
||||
.ui-table .pagination-wrapper {
|
||||
background-color: #f4f4f4;
|
||||
border: 1px solid #c8c8c8;
|
||||
}
|
||||
.bordered .datatable-body-row {
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: -1px;
|
||||
}
|
||||
.breadcrumb {
|
||||
background-color: transparent;
|
||||
padding: 0.27rem;
|
||||
}
|
||||
|
||||
.abp-md-form {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
abp-chat-icon,
|
||||
abp-back-to-impersonator-nav-item {
|
||||
margin-inline: 0.4rem;
|
||||
}
|
||||
|
||||
abp-chat-icon {
|
||||
.fa-comments:before {
|
||||
color: var(--bs-navbar-color);
|
||||
}
|
||||
}
|
||||
|
||||
abp-back-to-impersonator-nav-item {
|
||||
.fa-undo {
|
||||
color: var(--bs-navbar-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ngx-datatable.material:has(.datatable-body-row) .datatable-footer {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.ngx-datatable.material:not(:has(.datatable-body-row)) .datatable-footer {
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const enum eThemeBasicComponents {
|
||||
ApplicationLayout = 'Theme.ApplicationLayoutComponent',
|
||||
AccountLayout = 'Theme.AccountLayoutComponent',
|
||||
EmptyLayout = 'Theme.EmptyLayoutComponent',
|
||||
Logo = 'Theme.LogoComponent',
|
||||
Routes = 'Theme.RoutesComponent',
|
||||
NavItems = 'Theme.NavItemsComponent',
|
||||
CurrentUser = 'Theme.CurrentUserComponent',
|
||||
Languages = 'Theme.LanguagesComponent',
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './user-menu-items';
|
||||
@@ -0,0 +1,4 @@
|
||||
export const enum eUserMenuItems {
|
||||
MyAccount = 'UserMenu.MyAccount',
|
||||
Logout = 'UserMenu.Logout',
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lazy-style.handler';
|
||||
@@ -0,0 +1,104 @@
|
||||
import { LazyLoadService, LOADING_STRATEGY } from '@abp/ng.core';
|
||||
import { DocumentDirHandlerService, LocaleDirection } from '@abp/ng.theme.shared';
|
||||
import { Injectable, Injector, inject } from '@angular/core';
|
||||
import { LAZY_STYLES } from '../tokens/lazy-styles.token';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
export const BOOTSTRAP = 'bootstrap-{{dir}}.min.css';
|
||||
|
||||
@Injectable()
|
||||
export class LazyStyleHandler {
|
||||
private document = inject(DOCUMENT);
|
||||
private lazyLoad!: LazyLoadService;
|
||||
private styles!: string[];
|
||||
private _dir: LocaleDirection = 'ltr';
|
||||
|
||||
readonly loaded = new Map<string, HTMLLinkElement>();
|
||||
|
||||
set dir(dir: LocaleDirection) {
|
||||
if (dir === this._dir) return;
|
||||
|
||||
this.switchCSS(dir);
|
||||
this._dir = dir;
|
||||
}
|
||||
|
||||
get dir(): LocaleDirection {
|
||||
return this._dir;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const injector = inject(Injector);
|
||||
|
||||
this.setStyles(injector);
|
||||
this.setLazyLoad(injector);
|
||||
this.listenToDirectionChanges(injector);
|
||||
}
|
||||
|
||||
private getHrefFromLink(link: HTMLLinkElement | null | undefined): string {
|
||||
if (!link) return '';
|
||||
|
||||
const a = this.document.createElement('a');
|
||||
a.href = link.href;
|
||||
return a.pathname.replace(/^\//, '');
|
||||
}
|
||||
|
||||
private getLoadedBootstrap(): LoadedStyle {
|
||||
const href = createLazyStyleHref(BOOTSTRAP, this.dir);
|
||||
const selector = `[href*="${href.replace(/\.css$/, '')}"]`;
|
||||
const link = this.document.querySelector<HTMLLinkElement>(selector);
|
||||
return { href, link };
|
||||
}
|
||||
|
||||
private listenToDirectionChanges(injector: Injector) {
|
||||
const docDirHandler = injector.get(DocumentDirHandlerService);
|
||||
|
||||
// will always listen, no need to unsubscribe
|
||||
docDirHandler.dir$.subscribe(dir => {
|
||||
this.dir = dir;
|
||||
});
|
||||
}
|
||||
|
||||
private setLazyLoad(injector: Injector) {
|
||||
this.lazyLoad = injector.get(LazyLoadService);
|
||||
const { href, link } = this.getLoadedBootstrap();
|
||||
if (link) {
|
||||
this.lazyLoad.loaded.set(href, link);
|
||||
}
|
||||
}
|
||||
|
||||
private setStyles(injector: Injector) {
|
||||
this.styles = injector.get(LAZY_STYLES, [BOOTSTRAP]);
|
||||
}
|
||||
|
||||
private switchCSS(dir: LocaleDirection) {
|
||||
this.styles.forEach(style => {
|
||||
const oldHref = createLazyStyleHref(style, this.dir);
|
||||
const newHref = createLazyStyleHref(style, dir);
|
||||
const link = this.loaded.get(newHref);
|
||||
const href = this.getHrefFromLink(link) || newHref;
|
||||
|
||||
const strategy = LOADING_STRATEGY.PrependAnonymousStyleToHead(href);
|
||||
this.lazyLoad.load(strategy).subscribe(() => {
|
||||
const oldLink = this.lazyLoad.loaded.get(oldHref) as HTMLLinkElement;
|
||||
this.loaded.delete(newHref);
|
||||
this.loaded.set(oldHref, oldLink);
|
||||
const newLink = this.lazyLoad.loaded.get(href) as HTMLLinkElement;
|
||||
this.lazyLoad.loaded.delete(href);
|
||||
this.lazyLoad.loaded.set(newHref, newLink);
|
||||
this.lazyLoad.remove(oldHref);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createLazyStyleHref(style: string, dir: string): string {
|
||||
return style.replace(/{{\s*dir\s*}}/g, dir);
|
||||
}
|
||||
|
||||
export function initLazyStyleHandler() {
|
||||
return () => new LazyStyleHandler();
|
||||
}
|
||||
|
||||
interface LoadedStyle {
|
||||
href: string;
|
||||
link: HTMLLinkElement | null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './layout';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TemplateRef } from '@angular/core';
|
||||
|
||||
export namespace Layout {
|
||||
export interface State {
|
||||
navigationElements: NavigationElement[];
|
||||
}
|
||||
|
||||
export interface NavigationElement {
|
||||
name: string;
|
||||
element: TemplateRef<any>;
|
||||
order?: number;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './nav-item.provider';
|
||||
export * from './styles.provider';
|
||||
export * from './user-menu.provider';
|
||||
export * from './theme-basic-config.provider';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NavItemsService } from '@abp/ng.theme.shared';
|
||||
import { inject, provideAppInitializer } from '@angular/core';
|
||||
import { CurrentUserComponent } from '../components/nav-items/current-user.component';
|
||||
import { LanguagesComponent } from '../components/nav-items/languages.component';
|
||||
import { eThemeBasicComponents } from '../enums/components';
|
||||
|
||||
export const BASIC_THEME_NAV_ITEM_PROVIDERS = [
|
||||
provideAppInitializer(() => {
|
||||
configureNavItems();
|
||||
}),
|
||||
];
|
||||
|
||||
export function configureNavItems() {
|
||||
const navItems = inject(NavItemsService);
|
||||
navItems.addItems([
|
||||
{
|
||||
id: eThemeBasicComponents.Languages,
|
||||
order: 100,
|
||||
component: LanguagesComponent,
|
||||
},
|
||||
{
|
||||
id: eThemeBasicComponents.CurrentUser,
|
||||
order: 100,
|
||||
component: CurrentUserComponent,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CONTENT_STRATEGY, DomInsertionService, ReplaceableComponentsService } from '@abp/ng.core';
|
||||
import { inject, provideAppInitializer } from '@angular/core';
|
||||
import { AccountLayoutComponent } from '../components/account-layout/account-layout.component';
|
||||
import { ApplicationLayoutComponent } from '../components/application-layout/application-layout.component';
|
||||
import { EmptyLayoutComponent } from '../components/empty-layout/empty-layout.component';
|
||||
import styles from '../constants/styles';
|
||||
import { eThemeBasicComponents } from '../enums/components';
|
||||
|
||||
export const BASIC_THEME_STYLES_PROVIDERS = [
|
||||
provideAppInitializer(() => {
|
||||
const initializerFn = configureStyles(
|
||||
inject(DomInsertionService),
|
||||
inject(ReplaceableComponentsService),
|
||||
);
|
||||
return initializerFn();
|
||||
}),
|
||||
];
|
||||
|
||||
export function configureStyles(
|
||||
domInsertion: DomInsertionService,
|
||||
replaceableComponents: ReplaceableComponentsService,
|
||||
) {
|
||||
return () => {
|
||||
domInsertion.insertContent(CONTENT_STRATEGY.AppendStyleToHead(styles));
|
||||
|
||||
initLayouts(replaceableComponents);
|
||||
};
|
||||
}
|
||||
|
||||
function initLayouts(replaceableComponents: ReplaceableComponentsService) {
|
||||
replaceableComponents.add({
|
||||
key: eThemeBasicComponents.ApplicationLayout,
|
||||
component: ApplicationLayoutComponent,
|
||||
});
|
||||
replaceableComponents.add({
|
||||
key: eThemeBasicComponents.AccountLayout,
|
||||
component: AccountLayoutComponent,
|
||||
});
|
||||
replaceableComponents.add({
|
||||
key: eThemeBasicComponents.EmptyLayout,
|
||||
component: EmptyLayoutComponent,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { makeEnvironmentProviders, inject, provideAppInitializer } from '@angular/core';
|
||||
|
||||
import {
|
||||
VALIDATION_ERROR_TEMPLATE,
|
||||
VALIDATION_TARGET_SELECTOR,
|
||||
VALIDATION_INVALID_CLASSES,
|
||||
} from '@ngx-validate/core';
|
||||
import { LazyStyleHandler } from '../handlers';
|
||||
import { BASIC_THEME_NAV_ITEM_PROVIDERS } from './nav-item.provider';
|
||||
import { BASIC_THEME_STYLES_PROVIDERS } from './styles.provider';
|
||||
import { BASIC_THEME_USER_MENU_PROVIDERS } from './user-menu.provider';
|
||||
import { ValidationErrorComponent } from '../components';
|
||||
|
||||
export function provideThemeBasicConfig() {
|
||||
return makeEnvironmentProviders([
|
||||
BASIC_THEME_NAV_ITEM_PROVIDERS,
|
||||
BASIC_THEME_USER_MENU_PROVIDERS,
|
||||
BASIC_THEME_STYLES_PROVIDERS,
|
||||
{
|
||||
provide: VALIDATION_ERROR_TEMPLATE,
|
||||
useValue: ValidationErrorComponent,
|
||||
},
|
||||
{
|
||||
provide: VALIDATION_TARGET_SELECTOR,
|
||||
useValue: '.form-group',
|
||||
},
|
||||
{
|
||||
provide: VALIDATION_INVALID_CLASSES,
|
||||
useValue: 'is-invalid',
|
||||
},
|
||||
LazyStyleHandler,
|
||||
provideAppInitializer(() => {
|
||||
inject(LazyStyleHandler);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AuthService, NAVIGATE_TO_MANAGE_PROFILE } from '@abp/ng.core';
|
||||
import { UserMenuService } from '@abp/ng.theme.shared';
|
||||
import { Injector, inject, provideAppInitializer } from '@angular/core';
|
||||
import { eUserMenuItems } from '../enums/user-menu-items';
|
||||
|
||||
export const BASIC_THEME_USER_MENU_PROVIDERS = [
|
||||
provideAppInitializer(() => {
|
||||
const initializerFn = configureUserMenu(inject(Injector));
|
||||
return initializerFn();
|
||||
}),
|
||||
];
|
||||
|
||||
export function configureUserMenu(injector: Injector) {
|
||||
const userMenu = injector.get(UserMenuService);
|
||||
const authService = injector.get(AuthService);
|
||||
const navigateToManageProfile = injector.get(NAVIGATE_TO_MANAGE_PROFILE);
|
||||
|
||||
return () => {
|
||||
userMenu.addItems([
|
||||
{
|
||||
id: eUserMenuItems.MyAccount,
|
||||
order: 100,
|
||||
textTemplate: {
|
||||
text: 'AbpAccount::MyAccount',
|
||||
icon: 'fa fa-cog',
|
||||
},
|
||||
action: () => navigateToManageProfile(),
|
||||
},
|
||||
{
|
||||
id: eUserMenuItems.Logout,
|
||||
order: 101,
|
||||
textTemplate: {
|
||||
text: 'AbpUi::Logout',
|
||||
icon: 'fa fa-power-off',
|
||||
},
|
||||
action: () => {
|
||||
authService.logout().subscribe();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './layout.service';
|
||||
export * from './theme.service';
|
||||
@@ -0,0 +1,54 @@
|
||||
import {RouterEvents, SubscriptionService} from '@abp/ng.core';
|
||||
import { ChangeDetectorRef, Injectable, inject } from '@angular/core';
|
||||
import { fromEvent } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { eThemeBasicComponents } from '../enums';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
|
||||
@Injectable()
|
||||
export class LayoutService {
|
||||
private subscription = inject(SubscriptionService);
|
||||
private cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
document = inject(DOCUMENT);
|
||||
isCollapsed = true;
|
||||
|
||||
smallScreen!: boolean; // do not set true or false
|
||||
|
||||
logoComponentKey = eThemeBasicComponents.Logo;
|
||||
|
||||
routesComponentKey = eThemeBasicComponents.Routes;
|
||||
|
||||
navItemsComponentKey = eThemeBasicComponents.NavItems;
|
||||
|
||||
constructor() {
|
||||
const subscription = this.subscription;
|
||||
const routerEvents = inject(RouterEvents);
|
||||
|
||||
subscription.addOne(routerEvents.getNavigationEvents("End"),() => {
|
||||
this.isCollapsed = true;
|
||||
})
|
||||
}
|
||||
|
||||
private checkWindowWidth() {
|
||||
if (!this.document.defaultView) return;
|
||||
const isSmallScreen = this.document.defaultView.innerWidth < 992;
|
||||
if (isSmallScreen && this.smallScreen === false) {
|
||||
this.isCollapsed = false;
|
||||
setTimeout(() => {
|
||||
this.isCollapsed = true;
|
||||
}, 100);
|
||||
}
|
||||
this.smallScreen = isSmallScreen;
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
subscribeWindowSize() {
|
||||
this.checkWindowWidth();
|
||||
|
||||
if (this.document.defaultView) {
|
||||
const resize$ = fromEvent(this.document.defaultView, 'resize').pipe(debounceTime(150));
|
||||
this.subscription.addOne(resize$, () => this.checkWindowWidth());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
export type AppTheme = 'light' | 'dark';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ThemeService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly STORAGE_KEY = 'delight_theme';
|
||||
|
||||
private readonly themeSubject = new BehaviorSubject<AppTheme>(this.getInitialTheme());
|
||||
readonly theme$: Observable<AppTheme> = this.themeSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.applyTheme(this.currentTheme);
|
||||
}
|
||||
|
||||
get currentTheme(): AppTheme {
|
||||
return this.themeSubject.value;
|
||||
}
|
||||
|
||||
get isDarkMode(): boolean {
|
||||
return this.currentTheme === 'dark';
|
||||
}
|
||||
|
||||
toggleTheme(): void {
|
||||
const nextTheme: AppTheme = this.isDarkMode ? 'light' : 'dark';
|
||||
this.setTheme(nextTheme);
|
||||
}
|
||||
|
||||
setTheme(theme: AppTheme): void {
|
||||
this.themeSubject.next(theme);
|
||||
this.saveTheme(theme);
|
||||
this.applyTheme(theme);
|
||||
}
|
||||
|
||||
private getInitialTheme(): AppTheme {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
const saved = localStorage.getItem(this.STORAGE_KEY) as AppTheme | null;
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
return saved;
|
||||
}
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback in case of restricted environment
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
private saveTheme(theme: AppTheme): void {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
localStorage.setItem(this.STORAGE_KEY, theme);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
private applyTheme(theme: AppTheme): void {
|
||||
if (!this.document || !this.document.documentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = this.document.documentElement;
|
||||
const body = this.document.body;
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
root.setAttribute('data-theme', 'dark');
|
||||
if (body) {
|
||||
body.classList.add('dark');
|
||||
body.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
root.setAttribute('data-theme', 'light');
|
||||
if (body) {
|
||||
body.classList.remove('dark');
|
||||
body.setAttribute('data-theme', 'light');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { LazyLoadService, LoadingStrategy, LocalizationService } from '@abp/ng.core';
|
||||
import { DocumentDirHandlerService } from '@abp/ng.theme.shared';
|
||||
import { BOOTSTRAP, createLazyStyleHref, LazyStyleHandler } from '../handlers';
|
||||
import { LAZY_STYLES } from '../tokens/lazy-styles.token';
|
||||
import { setupComponentResources } from './utils';
|
||||
|
||||
describe('LazyStyleHandler', () => {
|
||||
let spectator: SpectatorService<LazyStyleHandler>;
|
||||
let handler: LazyStyleHandler;
|
||||
let lazyLoad: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupComponentResources(
|
||||
'../components/breadcrumb',
|
||||
import.meta.url
|
||||
);
|
||||
});
|
||||
|
||||
const dir$ = new Subject<'ltr' | 'rtl'>();
|
||||
|
||||
const createService = createServiceFactory({
|
||||
service: LazyStyleHandler,
|
||||
providers: [
|
||||
{
|
||||
provide: DOCUMENT,
|
||||
useValue: document,
|
||||
},
|
||||
{
|
||||
provide: LAZY_STYLES,
|
||||
useValue: [BOOTSTRAP],
|
||||
},
|
||||
{
|
||||
provide: LazyLoadService,
|
||||
useValue: {
|
||||
loaded: new Map(),
|
||||
load: vi.fn(() => of(null)),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DocumentDirHandlerService,
|
||||
useValue: {
|
||||
dir$,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LocalizationService,
|
||||
useValue: {
|
||||
currentLang: 'en',
|
||||
currentLang$: of({ payload: 'en' }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spectator = createService();
|
||||
handler = spectator.service;
|
||||
lazyLoad = spectator.inject(LazyLoadService);
|
||||
});
|
||||
|
||||
describe('#dir', () => {
|
||||
it('should initially be "ltr"', () => {
|
||||
expect(handler.dir).toBe('ltr');
|
||||
});
|
||||
|
||||
it('should set bootstrap to rtl', () => {
|
||||
const oldHref = createLazyStyleHref(BOOTSTRAP, 'ltr');
|
||||
const newHref = createLazyStyleHref(BOOTSTRAP, 'rtl');
|
||||
|
||||
lazyLoad.loaded.set(newHref, null);
|
||||
|
||||
const loadSpy = vi.spyOn(lazyLoad, 'load');
|
||||
const removeSpy = vi.spyOn(lazyLoad, 'remove');
|
||||
|
||||
handler.dir = 'rtl';
|
||||
|
||||
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||
const [strategy] = loadSpy.mock.calls[0];
|
||||
expect((strategy as LoadingStrategy).path).toBe(newHref);
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith(oldHref);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './setup-component-resources';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Sets up component resource resolution for Angular component tests.
|
||||
* This is needed when components have external templates or stylesheets.
|
||||
*
|
||||
* @param componentDirPath - The path to the component directory relative to the test file.
|
||||
* For example: '../components/loader-bar' or './components/my-component'
|
||||
* @param testFileUrl - The import.meta.url from the test file. Defaults to the caller's location.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
*
|
||||
* import { setupComponentResources } from './utils';
|
||||
*
|
||||
* beforeAll(() => setupComponentResources('../components/loader-bar', import.meta.url));
|
||||
* ```
|
||||
*/
|
||||
export async function setupComponentResources(
|
||||
componentDirPath: string,
|
||||
testFileUrl: string = import.meta.url,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (typeof process !== 'undefined' && process.versions?.node) {
|
||||
const { ɵresolveComponentResources: resolveComponentResources } = await import('@angular/core');
|
||||
|
||||
// Get the test file directory path
|
||||
const testFileDir = dirname(fileURLToPath(testFileUrl));
|
||||
const componentDir = resolve(testFileDir, componentDirPath);
|
||||
|
||||
await resolveComponentResources((url: string) => {
|
||||
// For SCSS/SASS files, return empty CSS since jsdom can't parse SCSS
|
||||
if (url.endsWith('.scss') || url.endsWith('.sass')) {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
|
||||
// For other files (HTML, CSS, etc.), read the actual content
|
||||
try {
|
||||
// Resolve relative paths like './component.scss' or 'component.scss'
|
||||
const normalizedUrl = url.replace(/^\.\//, '');
|
||||
const filePath = resolve(componentDir, normalizedUrl);
|
||||
return Promise.resolve(readFileSync(filePath, 'utf-8'));
|
||||
} catch (error) {
|
||||
// If file not found, return empty string
|
||||
return Promise.resolve('');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to set up component resource resolver:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ModuleWithProviders, NgModule } from '@angular/core';
|
||||
import {
|
||||
AccountLayoutComponent,
|
||||
AuthWrapperComponent,
|
||||
TenantBoxComponent,
|
||||
ApplicationLayoutComponent,
|
||||
EmptyLayoutComponent,
|
||||
LogoComponent,
|
||||
CurrentUserComponent,
|
||||
LanguagesComponent,
|
||||
NavItemsComponent,
|
||||
PageAlertContainerComponent,
|
||||
RoutesComponent,
|
||||
ValidationErrorComponent,
|
||||
} from './components';
|
||||
import { provideThemeBasicConfig } from './providers';
|
||||
|
||||
export const LAYOUTS = [ApplicationLayoutComponent, AccountLayoutComponent, EmptyLayoutComponent];
|
||||
|
||||
export const THEME_BASIC_COMPONENTS = [
|
||||
...LAYOUTS,
|
||||
ValidationErrorComponent,
|
||||
LogoComponent,
|
||||
NavItemsComponent,
|
||||
RoutesComponent,
|
||||
CurrentUserComponent,
|
||||
LanguagesComponent,
|
||||
PageAlertContainerComponent,
|
||||
AuthWrapperComponent,
|
||||
TenantBoxComponent,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
exports: [...THEME_BASIC_COMPONENTS],
|
||||
imports: [...THEME_BASIC_COMPONENTS],
|
||||
})
|
||||
export class BaseThemeBasicModule {}
|
||||
|
||||
@NgModule({
|
||||
exports: [BaseThemeBasicModule],
|
||||
imports: [BaseThemeBasicModule],
|
||||
})
|
||||
export class ThemeBasicModule {
|
||||
/**
|
||||
* @deprecated forRoot method is deprecated, use `provideThemeBasicConfig` *function* for config settings.
|
||||
*/
|
||||
static forRoot(): ModuleWithProviders<ThemeBasicModule> {
|
||||
return {
|
||||
ngModule: ThemeBasicModule,
|
||||
providers: [provideThemeBasicConfig()],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lazy-styles.token';
|
||||
@@ -0,0 +1,3 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export const LAZY_STYLES = new InjectionToken<string[]>('LAZY_STYLES');
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Public API Surface of theme-basic
|
||||
*/
|
||||
|
||||
export * from './lib/components';
|
||||
export * from './lib/enums';
|
||||
export * from './lib/handlers';
|
||||
export * from './lib/models';
|
||||
export * from './lib/providers';
|
||||
export * from './lib/services';
|
||||
export * from './lib/theme-basic.module';
|
||||
export * from './lib/tokens';
|
||||
@@ -0,0 +1,20 @@
|
||||
import '@angular/compiler';
|
||||
import 'zone.js';
|
||||
import 'zone.js/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||
|
||||
// Initialize Angular testing environment
|
||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||
|
||||
// Mock window.location for test environment
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: 'http://localhost:4200',
|
||||
origin: 'http://localhost:4200',
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
BaseThemeBasicModule,
|
||||
BASIC_THEME_NAV_ITEM_PROVIDERS,
|
||||
BASIC_THEME_STYLES_PROVIDERS,
|
||||
ValidationErrorComponent,
|
||||
} from '@abp/ng.theme.basic';
|
||||
import { ModuleWithProviders, NgModule } from '@angular/core';
|
||||
import {
|
||||
VALIDATION_ERROR_TEMPLATE,
|
||||
VALIDATION_INVALID_CLASSES,
|
||||
VALIDATION_TARGET_SELECTOR,
|
||||
} from '@ngx-validate/core';
|
||||
|
||||
@NgModule({
|
||||
exports: [BaseThemeBasicModule],
|
||||
imports: [BaseThemeBasicModule],
|
||||
})
|
||||
export class ThemeBasicTestingModule {
|
||||
static withConfig(): ModuleWithProviders<ThemeBasicTestingModule> {
|
||||
return {
|
||||
ngModule: ThemeBasicTestingModule,
|
||||
providers: [
|
||||
BASIC_THEME_NAV_ITEM_PROVIDERS,
|
||||
BASIC_THEME_STYLES_PROVIDERS,
|
||||
{
|
||||
provide: VALIDATION_ERROR_TEMPLATE,
|
||||
useValue: ValidationErrorComponent,
|
||||
},
|
||||
{
|
||||
provide: VALIDATION_TARGET_SELECTOR,
|
||||
useValue: '.form-group',
|
||||
},
|
||||
{
|
||||
provide: VALIDATION_INVALID_CLASSES,
|
||||
useValue: 'is-invalid',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/theme-basic-testing.module';
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2020"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"target": "ES2022",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": [],
|
||||
"lib": ["dom", "es2020"],
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"exclude": [
|
||||
"src/test-setup.ts",
|
||||
"**/*.spec.ts",
|
||||
"jest.config.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/test-setup.ts"
|
||||
],
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false,
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/theme-basic',
|
||||
plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
test: {
|
||||
name: 'theme-basic',
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../coverage/packages/theme-basic',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user