This commit is contained in:
Chris Chen
2026-05-25 17:32:18 -07:00
parent 9b28fbcfb6
commit d5648315a0
262 changed files with 32074 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Page Templates and Building Blocks
This package is part of the [Telerik and Kendo UI Accelerator](https://www.telerik.com/page-templates-and-ui-blocks) add-on.
## License
This is commercial software. To use it, you need to agree to the [**End User License Agreement for Progress® Telerik and Kendo UI Accelerator**](https://www.telerik.com/purchase/license-agreement/ui-accelerator).
All available Progress® Telerik and Kendo UI Accelerator commercial licenses may be obtained at the [Progress® Telerik and Kendo UI Accelerator website](https://www.telerik.com/purchase.aspx?filter=ui-accelerator#individual-products).
*Copyright © 2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.*
+116
View File
@@ -0,0 +1,116 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"client-bridge": {
"projectType": "application",
"schematics": {
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:component": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js",
"@angular/localize/init"
],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/assets"
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "1mb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "client-bridge:build"
},
"configurations": {
"production": {
"buildTarget": "client-bridge:build:production"
},
"development": {
"buildTarget": "client-bridge:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing",
"@angular/localize/init"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
"src/assets"
],
"styles": [
"src/styles.scss"
]
}
}
}
}
}
}
+387
View File
@@ -0,0 +1,387 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Escrow Portal Access - RBJ Identity</title>
<style>
/* Email-safe CSS - compatible with most email clients */
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f4;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
}
/* Header Section */
.email-header {
background-color: #1e3a8a;
padding: 30px 20px;
text-align: center;
color: white;
}
.logo-image {
height: 50px;
width: auto;
margin-bottom: 15px;
}
.logo-text h1 {
font-size: 28px;
font-weight: bold;
margin: 0 0 5px 0;
}
.logo-text .tagline {
font-size: 14px;
opacity: 0.9;
margin: 0;
}
.email-title {
font-size: 24px;
font-weight: bold;
margin: 20px 0 0 0;
}
/* Content Section */
.email-content {
padding: 30px 20px;
}
.greeting {
font-size: 18px;
margin-bottom: 20px;
color: #1f2937;
}
.message {
font-size: 16px;
line-height: 1.6;
margin-bottom: 25px;
color: #4b5563;
}
/* Transaction Details */
.transaction-details {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
padding: 20px;
margin-bottom: 25px;
}
.details-header {
margin-bottom: 15px;
color: #495057;
font-weight: bold;
font-size: 16px;
}
.detail-item {
background-color: white;
border: 1px solid #e9ecef;
padding: 12px;
margin-bottom: 8px;
}
.detail-item:last-child {
margin-bottom: 0;
}
.detail-label {
font-weight: bold;
color: #495057;
font-size: 14px;
display: inline-block;
width: 120px;
}
.detail-value {
font-size: 14px;
color: #1a1a1a;
background-color: #f8f9fa;
padding: 4px 8px;
display: inline-block;
}
/* Access Button */
.access-section {
text-align: center;
margin-bottom: 25px;
}
.access-button {
display: inline-block;
background-color: #1e3a8a;
color: white;
text-decoration: none;
padding: 15px 30px;
font-size: 18px;
font-weight: bold;
border: none;
}
.access-button:hover {
background-color: #1e40af;
text-decoration: none;
color: white;
}
/* Security Notice */
.security-notice {
background-color: #fef3c7;
border: 1px solid #f59e0b;
padding: 15px;
margin-bottom: 25px;
}
.security-header {
margin-bottom: 8px;
color: #92400e;
font-weight: bold;
font-size: 16px;
}
.security-text {
color: #92400e;
font-size: 14px;
line-height: 1.5;
}
/* Footer */
.email-footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer-text {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.footer-contact {
font-size: 12px;
color: #9ca3af;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-container {
width: 100% !important;
}
.email-header {
padding: 20px 15px;
}
.logo-text h1 {
font-size: 24px;
}
.email-title {
font-size: 20px;
}
.email-content {
padding: 20px 15px;
}
.transaction-details {
padding: 15px;
}
.detail-label {
width: 100%;
display: block;
margin-bottom: 5px;
}
.detail-value {
display: block;
width: 100%;
}
.access-button {
padding: 12px 25px;
font-size: 16px;
}
}
</style>
</head>
<body>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f4f4f4;">
<tr>
<td align="center" style="padding: 20px 0;">
<table class="email-container" width="600" cellpadding="0" cellspacing="0" border="0"
style="background-color: #ffffff; max-width: 600px;">
<!-- Header -->
<tr>
<td class="email-header"
style="background-color: #1e3a8a; padding: 30px 20px; text-align: center; color: white;">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image"
style="height: 50px; width: auto; margin-bottom: 15px;">
<div class="logo-text">
<h1 style="font-size: 28px; font-weight: bold; margin: 0 0 5px 0;">RBJ Identity</h1>
<span class="tagline" style="font-size: 14px; opacity: 0.9;">Escrow Management
Portal</span>
</div>
<h2 class="email-title" style="font-size: 24px; font-weight: bold; margin: 20px 0 0 0;">
Secure Portal Access</h2>
</td>
</tr>
<!-- Content -->
<tr>
<td class="email-content" style="padding: 30px 20px;">
<div class="greeting" style="font-size: 18px; margin-bottom: 20px; color: #1f2937;">
Hello [Client Name],
</div>
<div class="message"
style="font-size: 16px; line-height: 1.6; margin-bottom: 25px; color: #4b5563;">
Your escrow transaction is ready for review. Please use the secure link below to access
your
personalized portal where you can view transaction details, upload required documents,
and communicate
with your escrow officer.
</div>
<!-- Transaction Details -->
<table class="transaction-details" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color: #f8f9fa; border: 1px solid #e9ecef; padding: 20px; margin-bottom: 25px;">
<tr>
<td>
<div class="details-header"
style="margin-bottom: 15px; color: #495057; font-weight: bold; font-size: 16px;">
Transaction Details
</div>
</td>
</tr>
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td
style="background-color: white; border: 1px solid #e9ecef; padding: 12px; margin-bottom: 8px;">
<span class="detail-label"
style="font-weight: bold; color: #495057; font-size: 14px; display: inline-block; width: 120px;">Company:</span>
<span class="detail-value"
style="font-size: 14px; color: #1a1a1a; background-color: #f8f9fa; padding: 4px 8px;">[Company
Name]</span>
</td>
</tr>
<tr>
<td
style="background-color: white; border: 1px solid #e9ecef; padding: 12px; margin-bottom: 8px;">
<span class="detail-label"
style="font-weight: bold; color: #495057; font-size: 14px; display: inline-block; width: 120px;">Escrow
Officer:</span>
<span class="detail-value"
style="font-size: 14px; color: #1a1a1a; background-color: #f8f9fa; padding: 4px 8px;">[Escrow
Officer Name]</span>
</td>
</tr>
<tr>
<td
style="background-color: white; border: 1px solid #e9ecef; padding: 12px; margin-bottom: 8px;">
<span class="detail-label"
style="font-weight: bold; color: #495057; font-size: 14px; display: inline-block; width: 120px;">Property:</span>
<span class="detail-value"
style="font-size: 14px; color: #1a1a1a; background-color: #f8f9fa; padding: 4px 8px;">[Property
Address]</span>
</td>
</tr>
<tr>
<td
style="background-color: white; border: 1px solid #e9ecef; padding: 12px;">
<span class="detail-label"
style="font-weight: bold; color: #495057; font-size: 14px; display: inline-block; width: 120px;">Transaction
ID:</span>
<span class="detail-value"
style="font-size: 14px; color: #1a1a1a; background-color: #f8f9fa; padding: 4px 8px;">[Transaction
ID]</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Access Button -->
<table class="access-section" width="100%" cellpadding="0" cellspacing="0" border="0"
style="text-align: center; margin-bottom: 25px;">
<tr>
<td align="center">
<a href="[SECRET_LINK]" class="access-button"
style="display: inline-block; background-color: #1e3a8a; color: white; text-decoration: none; padding: 15px 30px; font-size: 18px; font-weight: bold; border: none;">
Access Your Portal
</a>
</td>
</tr>
</table>
<!-- Security Notice -->
<table class="security-notice" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color: #fef3c7; border: 1px solid #f59e0b; padding: 15px; margin-bottom: 25px;">
<tr>
<td>
<div class="security-header"
style="margin-bottom: 8px; color: #92400e; font-weight: bold; font-size: 16px;">
Security Notice
</div>
<div class="security-text"
style="color: #92400e; font-size: 14px; line-height: 1.5;">
This link is secure and personalized for your transaction. Please do not
share this link with
others. If you did not request this access or have any concerns, please
contact your escrow officer
immediately.
</div>
</td>
</tr>
</table>
<div class="message"
style="font-size: 16px; line-height: 1.6; margin-bottom: 25px; color: #4b5563;">
If you have any questions or need assistance, please don't hesitate to contact your
escrow officer
directly. We're here to help make your transaction as smooth as possible.
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td class="email-footer"
style="background-color: #f8f9fa; padding: 20px; text-align: center; border-top: 1px solid #e9ecef;">
<div class="footer-text" style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
This email was sent by RBJ Identity Escrow Management Portal
</div>
<div class="footer-contact" style="font-size: 12px; color: #9ca3af;">
For technical support, contact: support@clientbridge.com | Phone: (555) 123-4567
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+11376
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
{
"name": "RBJ.Identity.App",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/animations": "^20.1.0",
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",
"@angular/core": "^20.1.0",
"@angular/forms": "^20.1.0",
"@angular/localize": "^20.1.6",
"@angular/platform-browser": "^20.1.0",
"@angular/router": "^20.1.0",
"@progress/kendo-angular-buttons": "^20.0.0",
"@progress/kendo-angular-charts": "^20.0.0",
"@progress/kendo-angular-common": "^20.0.0",
"@progress/kendo-angular-conversational-ui": "^20.0.0",
"@progress/kendo-angular-dateinputs": "^20.0.0",
"@progress/kendo-angular-dialog": "^20.0.0",
"@progress/kendo-angular-dropdowns": "^20.0.0",
"@progress/kendo-angular-editor": "^20.0.0",
"@progress/kendo-angular-excel-export": "^20.0.3",
"@progress/kendo-angular-gauges": "^20.0.0",
"@progress/kendo-angular-grid": "^20.0.0",
"@progress/kendo-angular-icons": "^20.0.0",
"@progress/kendo-angular-indicators": "^20.0.0",
"@progress/kendo-angular-inputs": "^20.0.0",
"@progress/kendo-angular-intl": "^20.0.0",
"@progress/kendo-angular-l10n": "^20.0.0",
"@progress/kendo-angular-label": "^20.0.0",
"@progress/kendo-angular-layout": "^20.0.0",
"@progress/kendo-angular-listview": "^20.0.0",
"@progress/kendo-angular-map": "^20.0.0",
"@progress/kendo-angular-menu": "^20.0.0",
"@progress/kendo-angular-navigation": "^20.0.0",
"@progress/kendo-angular-pager": "^20.0.0",
"@progress/kendo-angular-pdf-export": "^20.0.3",
"@progress/kendo-angular-popup": "^20.0.0",
"@progress/kendo-angular-progressbar": "^20.0.0",
"@progress/kendo-angular-scrollview": "^20.0.0",
"@progress/kendo-angular-toolbar": "^20.0.3",
"@progress/kendo-angular-tooltip": "^20.0.0",
"@progress/kendo-angular-treeview": "^20.0.0",
"@progress/kendo-angular-upload": "^20.0.0",
"@progress/kendo-angular-utils": "^20.0.0",
"@progress/kendo-data-query": "^1.7.1",
"@progress/kendo-drawing": "^1.22.0",
"@progress/kendo-licensing": "^1.7.0",
"@progress/kendo-svg-icons": "^4.5.0",
"@progress/kendo-theme-default": "^12.0.0",
"@progress/kendo-theme-utils": "^12.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.1.6",
"@angular/cli": "^20.1.6",
"@angular/compiler-cli": "^20.1.0",
"@angular/localize": "^20.2.1",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.8.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.8.2"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimations(),
provideHttpClient(withInterceptors([authInterceptor]))
]
};
+2
View File
@@ -0,0 +1,2 @@
<router-outlet></router-outlet>
<div kendoDialogContainer></div>
+27
View File
@@ -0,0 +1,27 @@
import { Routes } from '@angular/router';
import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashboard.component';
import { LoginPage } from './features/login-page/login-page';
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
import { AuthGuard } from './core/guards/auth.guard';
export const routes: Routes = [
// Public routes
{ path: 'login', component: LoginPage },
// Keep the startup surface intentionally small: login + guarded mock dashboard.
{
path: 'user-portal',
component: UserPortalComponent,
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent }
]
},
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'dashboard', redirectTo: 'user-portal/dashboard' },
// Catch all route - redirect to login
{ path: '**', redirectTo: 'login' }
];
+53
View File
@@ -0,0 +1,53 @@
/* Global layout styles */
/* Ensure AppBar sections are in one row */
kendo-appbar {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
width: 100% !important;
}
kendo-appbar-section {
display: flex !important;
align-items: center !important;
}
/* Make sure the drawer container takes full height */
kendo-drawer-container {
min-height: calc(100vh - 46px);
}
/* Global mobile optimizations */
@media (max-width: 767px) {
/* Improve touch targets for mobile */
button[kendoButton] {
min-height: 44px;
min-width: 44px;
}
/* Make drawer content full width on mobile */
kendo-drawer-content {
width: 100%;
}
/* Prevent horizontal scroll on mobile */
body {
overflow-x: hidden;
}
}
/* iOS specific fixes */
@supports (-webkit-touch-callout: none) {
/* Prevent iOS zoom on input focus */
input,
select,
textarea {
font-size: 16px !important;
}
/* Smooth scrolling for iOS - legacy support for older devices */
kendo-drawer-container {
overflow-y: auto;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Component, ViewEncapsulation, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { DialogModule } from '@progress/kendo-angular-dialog';
import { AuthService } from './shared/services/auth.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
DialogModule
],
templateUrl: './app.html',
styleUrls: ['./app.scss', '../styles.scss'],
encapsulation: ViewEncapsulation.None
})
export class App implements OnInit {
title = 'RBJ Identity';
constructor(private authService: AuthService) { }
ngOnInit(): void {
// Initialize authentication state from localStorage
this.authService.initializeAuth();
}
}
+120
View File
@@ -0,0 +1,120 @@
# Authentication Guard System
This implementation provides a complete authentication system that automatically detects if a user is logged in and redirects to the login page if not.
## Components
### AuthGuard (`auth.guard.ts`)
- **Purpose**: Protects routes that require authentication
- **Functionality**:
- Checks if user is authenticated using `AuthService.isAuthenticated()`
- Stores attempted URL for redirect after login
- Redirects to `/login` if not authenticated
- Allows access if authenticated
### LoginPage (`login-page.ts`)
- **Purpose**: Full-page login interface for routing
- **Features**:
- Beautiful landing page with company branding
- Integrated login dialog
- Demo credentials display
- Automatic redirect after successful login
- Prevents access if already logged in
## How It Works
### 1. Route Protection
All protected routes use the `AuthGuard`:
```typescript
{ path: 'dashboard', component: Dashboard, canActivate: [AuthGuard] }
```
### 2. Authentication Flow
1. User tries to access protected route
2. `AuthGuard` checks authentication status
3. If not authenticated:
- Stores attempted URL
- Redirects to `/login`
4. If authenticated:
- Allows access to requested route
### 3. Login Process
1. User lands on `/login` page
2. Clicks "Sign In" button
3. Login dialog opens with MFA support
4. After successful login:
- User data stored in localStorage
- Redirected to originally requested URL or dashboard
### 4. Logout Process
1. User clicks logout from header dropdown
2. `AuthService.logout()` clears user data
3. Redirected to `/login` page
## User State Management
### AuthService Features
- **Persistent Login**: User stays logged in across browser sessions
- **State Management**: Reactive user state with RxJS
- **Redirect URLs**: Remembers where user was trying to go
- **localStorage**: Automatic persistence and restoration
### Header Integration
- **Dynamic User Menu**: Shows different options based on auth state
- **User Information**: Displays current user name/email
- **Logout Button**: Easy access to logout functionality
## Route Structure
```
/login - Public login page
/dashboard - Protected (requires auth)
/schedule - Protected (requires auth)
/patients - Protected (requires auth)
... - All other routes protected
/** - Catch-all redirects to /login
```
## Usage Examples
### Adding New Protected Routes
```typescript
{ path: 'new-feature', component: NewFeatureComponent, canActivate: [AuthGuard] }
```
### Checking Auth State in Components
```typescript
constructor(private authService: AuthService) {}
ngOnInit() {
this.authService.currentUser$.subscribe(user => {
if (user) {
// User is logged in
} else {
// User is not logged in
}
});
}
```
### Manual Logout
```typescript
this.authService.logout();
this.router.navigate(['/login']);
```
## Security Features
- **Route Protection**: All sensitive routes are protected
- **Persistent Sessions**: Users stay logged in across browser sessions
- **Automatic Redirects**: Seamless user experience
- **State Validation**: Authentication state is checked on every route change
- **Clean Logout**: Complete session cleanup on logout
## Testing
Use these test credentials:
- **Direct Login**: `user@example.com` / `password123`
- **MFA Required**: `admin@example.com` / `password123` / `123456`
The system will automatically redirect unauthenticated users to the login page and remember where they were trying to go.
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { AuthService } from '../../shared/services/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) { }
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> | Promise<boolean> | boolean {
// Check if user is authenticated
if (this.authService.isAuthenticated()) {
return true;
}
// Store the attempted URL for redirecting after login
this.authService.setRedirectUrl(state.url);
// Redirect to login page
this.router.navigate(['/login']);
return false;
}
}
@@ -0,0 +1,60 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../../shared/services/auth.service';
import { Router } from '@angular/router';
import { ApiConfigService } from '../services/api-config.service';
export const authInterceptor: HttpInterceptorFn = (request, next) => {
const authService = inject(AuthService);
const apiConfigService = inject(ApiConfigService);
const router = inject(Router);
// Get the current user and token
const token = authService.getToken();
// Clone the request and add the Authorization header if token exists
if (token && shouldAddToken(apiConfigService, request)) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
// Handle the request and catch 401 errors
return next(request).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Token is invalid or expired, logout user
authService.logout();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
/**
* Determine if the token should be added to this request
* Skip adding token to login requests and other public endpoints
*/
function shouldAddToken(apiConfigService: ApiConfigService, request: any): boolean {
// Don't add token to outbound requests to other domains
if (!request.url.startsWith(apiConfigService.getBaseUrl())) {
return false;
}
// Don't add token to login requests
if (request.url.includes('/Auth/login') || request.url.includes('/Token/Create')) {
return false;
}
// Don't add token to public endpoints (you can customize this list)
const publicEndpoints = [
'/Auth/register',
'/Auth/forgot-password',
'/Auth/reset-password'
];
return !publicEndpoints.some(endpoint => request.url.includes(endpoint));
}
@@ -0,0 +1,58 @@
import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ApiConfigService {
private readonly baseUrl = environment.apiUrl;
constructor() { }
/**
* Get the full API URL for a specific endpoint
* @param endpoint - The API endpoint (e.g., 'Auth', 'Users', 'Transactions')
* @returns Full API URL
*/
getApiUrl(endpoint: string): string {
return `${this.baseUrl}/${endpoint}`;
}
/**
* Get the base API URL
* @returns Base API URL
*/
getBaseUrl(): string {
return this.baseUrl;
}
/**
* Get specific API endpoints
*/
get authUrl(): string {
return this.getApiUrl('Auth');
}
get tokenUrl(): string {
return this.getApiUrl('Token');
}
get usersUrl(): string {
return this.getApiUrl('Users');
}
get transactionsUrl(): string {
return this.getApiUrl('Transactions');
}
get dashboardUrl(): string {
return this.getApiUrl('Dashboard');
}
get ordersUrl(): string {
return this.getApiUrl('ClientBridgeOrder');
}
get orderDetailUrl(): string {
return this.getApiUrl('OrderDetail');
}
}
@@ -0,0 +1,168 @@
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ApiConfigService } from './api-config.service';
// Type definitions
type TextResponse = { message: string };
/**
* Base CRUD service that targets the provided controller path.
*
* It mirrors the endpoints of CrudBaseApiController<T>:
* GET /api/{controller}
* GET /api/{controller}/{id}
* POST /api/{controller} -> string
* POST /api/{controller}/batch -> string[]
* PUT /api/{controller}
* PUT /api/{controller}/batch -> number
* DELETE /api/{controller}/{id}
* DELETE /api/{controller}/batch -> text summary
* GET /api/{controller}/{id}/exists -> boolean
* GET /api/{controller}/count -> number
*/
@Injectable({ providedIn: 'root' })
export class CrudBaseApiService<T extends object> {
/**
* Example: baseUrl = 'https://your-api', controller = 'Customer' →
* endpoint = 'https://your-api/api/Customer'
*/
protected readonly endpoint: string;
/**
* @param http Angular HttpClient
* @param baseUrl API root without trailing slash (e.g., environment.apiBaseUrl)
* @param controllerName Controller name (e.g., 'Customer', 'Orders')
*/
constructor(
protected http: HttpClient,
protected apiConfig: ApiConfigService,
@Inject(String) private controllerName: string
) {
this.endpoint = apiConfig.getApiUrl(this.controllerName);
}
/** Optional default headers (JSON). Override in subclasses if needed. */
protected get jsonHeaders(): HttpHeaders {
return new HttpHeaders({ 'Content-Type': 'application/json' });
}
/** Shared error handler that surfaces useful messages. */
protected handleError(error: HttpErrorResponse): Observable<never> {
let msg = 'Unknown error';
if (error.error instanceof Blob) {
// In case backend returns text/plain; charset=utf-8 as Blob
return throwError(() => new Error('Server returned an error blob'));
}
if (typeof error.error === 'string') msg = error.error;
else if (error.error?.message) msg = error.error.message;
else if (error.message) msg = error.message;
return throwError(() => new Error(msg));
}
/** Prepare the response for the given entity. Override in subclasses if needed. */
protected prepareResponse(response: T): T {
// Do nothing by default
return response;
}
/** GET /api/{controller} */
getAll(): Observable<T[]> {
return this.http
.get<T[]>(this.endpoint)
.pipe(
map(response => {
for (let i = 0; i < response.length; i++) {
const element = response[i];
response[i] = this.prepareResponse(element);
}
return response;
}),
catchError(err => this.handleError(err)));
}
/** GET /api/{controller}/{id} */
getById(id: string): Observable<T> {
return this.http
.get<T>(`${this.endpoint}/${id}`)
.pipe(
map(response => this.prepareResponse(response)),
catchError(err => this.handleError(err)));
}
/** POST /api/{controller} -> string */
create(entity: T): Observable<string> {
return this.http
.post<string>(this.endpoint, entity, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** POST /api/{controller}/batch -> string[] */
createRange(entities: T[]): Observable<string[]> {
return this.http
.post<string[]>(`${this.endpoint}/batch`, entities, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** PUT /api/{controller} */
update(entity: T): Observable<void> {
return this.http
.put<void>(this.endpoint, entity, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** PUT /api/{controller}/batch -> number (updated count) */
updateRange(entities: T[]): Observable<number> {
return this.http
.put<number>(`${this.endpoint}/batch`, entities, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** DELETE /api/{controller}/{id} */
delete(id: string): Observable<void> {
return this.http
.delete<void>(`${this.endpoint}/${id}`)
.pipe(catchError(err => this.handleError(err)));
}
/** DELETE /api/{controller}/batch -> text summary */
deleteRange(ids: string[]): Observable<TextResponse> {
// API returns a plain text message; map it into a TextResponse for convenience
return this.http
.delete(`${this.endpoint}/batch`, {
body: ids,
headers: this.jsonHeaders
})
.pipe(
map((response: any) => ({ message: response || 'Batch delete completed' })),
catchError(err => this.handleError(err))
);
}
/** GET /api/{controller}/{id}/exists -> boolean */
exists(id: string): Observable<boolean> {
return this.http
.get<boolean>(`${this.endpoint}/${id}/exists`)
.pipe(catchError(err => this.handleError(err)));
}
/** GET /api/{controller}/count -> number */
count(): Observable<number> {
return this.http
.get<number>(`${this.endpoint}/count`)
.pipe(catchError(err => this.handleError(err)));
}
}
@@ -0,0 +1,294 @@
<main
class="k-px-2 k-px-sm-4.5 k-px-md-6 k-px-lg-4 k-px-xl-7.5 k-py-2 k-py-sm-4.5 k-py-md-6 k-py-lg-4 k-py-xl-7.5 k-pt-8 k-bg-light">
<h1 class="k-h1 k-color-primary-emphasis k-overflow-hidden k-text-ellipsis">Dashboard</h1>
<div class="k-d-grid k-grid-cols-12 k-gap-4 k-py-4">
<!-- Start of CMPCTCARD-1 -->
<div *ngFor="let card of compactCards; let i = index;"
class="{{cardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<kendo-svgicon [icon]="card.svgIcon" themeColor="primary" size="xxlarge"></kendo-svgicon>
<div class="k-d-flex k-flex-col">
<span
class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">{{card.title}}</span>
<span class="k-font-size-sm k-line-height-lg k-color-subtle">{{card.info}}</span>
</div>
</div>
<!-- End of CMPCTCARD-1 -->
<!-- Start of DASHBRDCARD-10 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<div class="k-d-flex k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Calendar</span>
</div>
<div class="k-flex-1 k-px-3 k-pb-3 k-d-flex k-justify-content-center">
<kendo-calendar [showOtherMonthDays]="false" type="classic" [(ngModel)]="date2">
</kendo-calendar>
</div>
</div>
<!-- End of DASHBRDCARD-10 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Bed
Occupancy</span>
<kendo-datepicker format="yyyy" [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart style="height: 257px;">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item
[categories]="['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100" [min]="0" [majorTicks]="{step: 10}">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="column" name="Occupied" [spacing]="0"
[legendItem]="{type: 'line' }" [data]="[67, 78, 47, 41, 38, 33]">
</kendo-chart-series-item>
<kendo-chart-series-item type="column" name="Free" [legendItem]="{type: 'line' }"
[data]="[21, 10, 44, 40, 48, 60]">
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom" orientation="horizontal" align="start"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-1 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Staff</span>
<kendo-dropdownlist [data]="ddlData" [value]="ddlValue" fillMode="flat" [style.width.px]="164"
[attr.aria-label]="'Select'"></kendo-dropdownlist>
</div>
<div class="k-flex-1 k-px-3">
<kendo-listview [data]="listItems" layout="flex" flexDirection="col" [bordered]="false">
<ng-template kendoListViewItemTemplate let-dataItem>
<div
class="k-d-flex k-border-b k-border-b-solid k-border-border k-gap-3 k-p-2 k-align-items-center">
<kendo-badge-container>
<kendo-avatar [imageSrc]="dataItem.imageSrc"></kendo-avatar>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlignBottomEnd"
themeColor="success"></kendo-badge>
</kendo-badge-container>
<div class="k-d-flex k-flex-col">
<div class="k-font-size-lg">{{dataItem.name}}</div>
<div class="k-font-size-sm k-color-subtle">{{dataItem.specialty}}</div>
</div>
</div>
</ng-template>
</kendo-listview>
</div>
<div class="k-p-3">
<button kendoButton fillMode="flat" themeColor="primary">View all</button>
</div>
</div>
<!-- End of DASHBRDCARD-1 -->
<!-- Start of DASHBRDCARD-4 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span
class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Appointments</span>
<kendo-datepicker [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164" [clearButton]="true"
[inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-d-grid k-grid-cols-12 k-p-4 k-gap-2">
<div *ngFor="let appointment of appointments; let last = last"
[ngClass]="{ 'k-d-none k-d-lg-block' : last }"
class=" k-col-span-12 k-col-span-lg-4 k-bg-light k-border k-border-solid k-border-border k-rounded-sm k-d-flex k-flex-col k-flex-1">
<div class="k-d-flex k-justify-content-between k-p-1.5 k-h-12">
<span class="k-font-medium">{{appointment.doctor}}</span>
<div class="k-flex-shrink-0">
<span class="k-badge k-badge-md k-badge-solid k-badge-solid-primary k-rounded-full">
{{appointment.start}}
</span>
</div>
</div>
<div class="k-d-flex k-flex-col k-flex-1 k-gap-1.5 k-px-1.5">
<div>Appointment with {{appointment.patient.name}}.</div>
<div class="k-font-size-sm">
<div class="k-color-subtle k-d-flex k-gap-1 k-align-items-center k-line-height-lg">
<kendo-svgicon [icon]="envelopeIcon"></kendo-svgicon>
<a class="k-color-inherit" href="#">{{appointment.patient.phone}}</a>
</div>
<div class="k-color-subtle k-d-flex k-gap-1 k-align-items-center k-line-height-lg">
<kendo-svgicon [icon]="envelopeIcon"></kendo-svgicon>
<a class="k-color-inherit" href="#">{{appointment.patient.email}}</a>
</div>
</div>
</div>
<div class="k-d-flex k-flex-shrink-0 k-p-1.5">
<button kendoButton fillMode="clear" themeColor="primary">Edit</button>
<button kendoButton fillMode="clear">Cancel</button>
</div>
</div>
</div>
<div class="k-p-3">
<button kendoButton fillMode="clear" themeColor="primary">View all appointments</button>
</div>
</div>
<!-- End of DASHBRDCARD-4 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Infection
Rate</span>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-x-axis>
<kendo-chart-x-axis-item [labels]="{rotation: -45}"></kendo-chart-x-axis-item>
</kendo-chart-x-axis>
<kendo-chart-series>
<kendo-chart-series-item
*ngFor="let dataSet of ['RSV', 'CDC', 'Measles', 'Influenza', 'Campylobacteriosis', 'Hepatitis']"
type="heatmap" [data]="heatmapData(dataSet)" xField="a" yField="b"
valueField="value"></kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Equipment
Availability</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-series>
<kendo-chart-series-item [autoFit]="true" type="donut" [holeSize]="50" [data]="donutData"
categoryField="kind" field="share">
<kendo-chart-series-item-labels position="outsideEnd" color="#000"
[content]="chartLabelContent"></kendo-chart-series-item-labels>
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend [visible]="false"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Average Length of
Stay</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="departments">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="14" [majorUnit]="1">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="bar" [data]="averageStay">
</kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Hospital
Visits</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="330">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="hours" baseUnit="hours"
[labels]="{rotation: 270, position: 'start', format: 'h:mm'}">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="line" [data]="hospitalVisits">
</kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Satisfaction
Score</span>
<kendo-dropdownlist [value]="'2023'" [fillMode]="'flat'" [style.width.px]="164"
[attr.aria-label]="'Select'"></kendo-dropdownlist>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="288">
<kendo-chart-series>
<kendo-chart-series-item type="pie" [legendItem]="{type: 'line' }" [data]="satisfaction"
categoryField="kind" field="share" [padding]="10" [border]="{width: 3, color: '#fff'}">
<kendo-chart-series-item-labels position="center">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Mortality
Rate</span>
<kendo-datepicker format="yyyy" [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="288">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="mortalityCauses">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100" [min]="0"
[majorTicks]="{step: 10}"></kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="bar" [legendItem]="{type: 'line' }" name="Male"
[data]="[25, 35, 36, 42, 85, 12, 4, 17, 19, 49, 28]">
</kendo-chart-series-item>
<kendo-chart-series-item type="bar" [legendItem]="{type: 'line' }" name="Female"
[data]="[23, 40, 38, 30, 81, 18, 3, 21, 22, 45, 24]">
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom" orientation="horizontal" align="start"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
</div>
</main>
@@ -0,0 +1,86 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChartsModule, SeriesLabelsContentArgs } from '@progress/kendo-angular-charts';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ListViewModule } from '@progress/kendo-angular-listview';
import { BadgeAlign, IndicatorsModule } from '@progress/kendo-angular-indicators';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IconsModule } from '@progress/kendo-angular-icons';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { SVGIcon, envelopeIcon } from '@progress/kendo-svg-icons';
import {
appointments,
averageStay,
compactCards,
departments,
donutData,
heatmapDataCDC,
heatmapDataCampylobacteriosis,
heatmapDataHepatitis,
heatmapDataInfluenza,
heatmapDataMeasles,
heatmapDataRSV,
hospitalVisits,
hours,
listItems,
mortalityCauses,
satisfaction
} from './models';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
CommonModule,
FormsModule,
ChartsModule,
DateInputsModule,
DropDownsModule,
ListViewModule,
IndicatorsModule,
ButtonsModule,
IconsModule,
LayoutModule
],
templateUrl: './dashboard.html',
styleUrl: './dashboard.css'
})
export class Dashboard {
public cardClasses = 'k-d-flex k-border k-border-solid k-border-border k-bg-surface-alt k-align-items-center k-overflow-x-auto k-p-3 k-gap-6 k-elevation-1 k-rounded-md';
public dashboardClasses = 'k-d-flex k-flex-col k-border k-border-solid k-border-border k-bg-surface-alt k-overflow-x-auto k-elevation-1 k-rounded-md';
public envelopeIcon: SVGIcon = envelopeIcon;
public badgeAlignBottomEnd: BadgeAlign = {
vertical: 'bottom',
horizontal: 'end'
};
public chartLabelContent(e: SeriesLabelsContentArgs): string {
return e.category;
}
public date = new Date(2023, 5, 14);
public date2 = new Date(2023, 5, 15);
public averageStay = averageStay;
public hours = hours
public hospitalVisits = hospitalVisits;
public departments = departments;
public mortalityCauses = mortalityCauses;
public satisfaction = satisfaction;
public donutData = donutData;
public heatmapDataRSV = heatmapDataRSV;
public heatmapDataCDC = heatmapDataCDC;
public heatmapDataMeasles = heatmapDataMeasles;
public heatmapDataInfluenza = heatmapDataInfluenza;
public heatmapDataHepatitis = heatmapDataHepatitis
public heatmapDataCampylobacteriosis = heatmapDataCampylobacteriosis;
public heatmapData = (dataset: string): any[] => (this as any)[`heatmapData${dataset}`];
public appointments = appointments;
public ddlData = ['All Departments'];
public ddlValue = 'All Departments'
public compactCards = compactCards;
public listItems: any[] = listItems;
}
+502
View File
@@ -0,0 +1,502 @@
import { accessibilityIcon, calendarDateIcon, calendarIcon, displayBlockIcon, dollarIcon, fileIcon, inboxIcon, myspaceIcon, pencilIcon, starOutlineIcon } from "@progress/kendo-svg-icons";
export const menuItems = [
"Settings",
"Support",
"Log out"
];
export const averageStay = [4, 3, 2, 14, 5, 7, 5, 6, 12, 1, 4];
export const hours = Array(48).fill({}).map((_, idx) => `${Math.floor(idx / 2)}:${idx % 2 ? '30': '00'}`);
export const hospitalVisits = [14, 20, 20, 26, 30, 26, 29, 32, 31, 29, 31, 35, 36, 40, 42, 45, 61, 63, 65, 66, 67, 67, 63, 64, 63, 62, 60, 45, 52, 55, 48, 44, 38, 35, 31, 35, 36, 40, 42, 55, 50, 41, 41, 39, 31, 32, 23, 27];
export const departments = [
'Pharmacology & Toxicology',
'Gastroenterology',
'Radiology',
'Orthopedics',
'Outpatient',
'Oncology',
'Neurology',
'ICU',
'Cardiology',
'Emergency',
'Delivery'
];
export const mortalityCauses = [
'Pharmacology & Toxicology',
'Oncological diseases',
'Circulatory diseases',
'Injury and poisoning',
'Respiratory diseases',
'Endocrine diseases',
'Digestive diseases',
'Nervous system diseases',
'Infectious diseases',
'Kidney diseases',
'Other causes'
];
export const satisfaction = [
{
kind: 'Very dissatisfied',
share: 60
},
{
kind: 'Dissatisfied',
share: 60
},
{
kind: 'Neutral',
share: 60
},
{
kind: 'Satisfied',
share: 60
},
{
kind: 'Very satisfied',
share: 60
},
{
kind: 'Didn\'t answer',
share: 60
}];
export const donutData = [
{
kind: 'Imaging Equipment',
share: 0.17,
},
{
kind: 'Surgical Instruments',
share: 0.17,
},
{
kind: 'Electromedical Equipment',
share: 0.17,
},
{
kind: 'Transport and Storage',
share: 0.17,
},
{
kind: 'Endoscopic Instruments',
share: 0.17,
},
{
kind: 'Others',
share: 0.17,
}];
export const heatmapDataRSV = [{
a: 'June 2023',
b: 'RSV',
value: 66
}, {
a: 'May 2023',
b: 'RSV',
value: 34
}, {
a: 'Apr 2023',
b: 'RSV',
value: 13
}, {
a: 'Mar 2023',
b: 'RSV',
value: 49
}, {
a: 'Feb 2023',
b: 'RSV',
value: 22
}, {
a: 'Jan 2023',
b: 'RSV',
value: 66
}, {
a: 'Dec 2022',
b: 'RSV',
value: 78
}, {
a: 'Nov 2022',
b: 'RSV',
value: 89
}, {
a: 'Oct 2022',
b: 'RSV',
value: 27
}, {
a: 'Sep 2022',
b: 'RSV',
value: 83
}];
export const heatmapDataCDC = [{
a: 'June 2023',
b: 'CDC',
value: 51
}, {
a: 'May 2023',
b: 'CDC',
value: 84
}, {
a: 'Apr 2023',
b: 'CDC',
value: 32
}, {
a: 'Mar 2023',
b: 'CDC',
value: 16
}, {
a: 'Feb 2023',
b: 'CDC',
value: 11
}, {
a: 'Jan 2023',
b: 'CDC',
value: 55
}, {
a: 'Dec 2022',
b: 'CDC',
value: 99
}, {
a: 'Nov 2022',
b: 'CDC',
value: 42
}, {
a: 'Oct 2022',
b: 'CDC',
value: 30
}, {
a: 'Sep 2022',
b: 'CDC',
value: 10
}];
export const heatmapDataMeasles = [{
a: 'June 2023',
b: 'Measles',
value: 80
}, {
a: 'May 2023',
b: 'Measles',
value: 56
}, {
a: 'Apr 2023',
b: 'Measles',
value: 78
}, {
a: 'Mar 2023',
b: 'Measles',
value: 63
}, {
a: 'Feb 2023',
b: 'Measles',
value: 24
}, {
a: 'Jan 2023',
b: 'Measles',
value: 33
}, {
a: 'Dec 2022',
b: 'Measles',
value: 38
}, {
a: 'Nov 2022',
b: 'Measles',
value: 17
}, {
a: 'Oct 2022',
b: 'Measles',
value: 62
}, {
a: 'Sep 2022',
b: 'Measles',
value: 82
}];
export const heatmapDataInfluenza = [{
a: 'June 2023',
b: 'Influenza',
value: 84
}, {
a: 'May 2023',
b: 'Influenza',
value: 25
}, {
a: 'Apr 2023',
b: 'Influenza',
value: 59
}, {
a: 'Mar 2023',
b: 'Influenza',
value: 74
}, {
a: 'Feb 2023',
b: 'Influenza',
value: 41
}, {
a: 'Jan 2023',
b: 'Influenza',
value: 69
}, {
a: 'Dec 2022',
b: 'Influenza',
value: 71
}, {
a: 'Nov 2022',
b: 'Influenza',
value: 11
}, {
a: 'Oct 2022',
b: 'Influenza',
value: 23
}, {
a: 'Sep 2022',
b: 'Influenza',
value: 43
}];
export const heatmapDataHepatitis = [{
a: 'June 2023',
b: 'Hepatitis',
value: 31
}, {
a: 'May 2023',
b: 'Hepatitis',
value: 27
}, {
a: 'Apr 2023',
b: 'Hepatitis',
value: 16
}, {
a: 'Mar 2023',
b: 'Hepatitis',
value: 74
}, {
a: 'Feb 2023',
b: 'Hepatitis',
value: 50
}, {
a: 'Jan 2023',
b: 'Hepatitis',
value: 6
}, {
a: 'Dec 2022',
b: 'Hepatitis',
value: 22
}, {
a: 'Nov 2022',
b: 'Hepatitis',
value: 65
}, {
a: 'Oct 2022',
b: 'Hepatitis',
value: 37
}, {
a: 'Sep 2022',
b: 'Hepatitis',
value: 13
}];
export const heatmapDataCampylobacteriosis = [{
a: 'June 2023',
b: 'Campylobacteriosis',
value: 66
}, {
a: 'May 2023',
b: 'Campylobacteriosis',
value: 21
}, {
a: 'Apr 2023',
b: 'Campylobacteriosis',
value: 52
}, {
a: 'Mar 2023',
b: 'Campylobacteriosis',
value: 43
}, {
a: 'Feb 2023',
b: 'Campylobacteriosis',
value: 97
}, {
a: 'Jan 2023',
b: 'Campylobacteriosis',
value: 81
}, {
a: 'Dec 2022',
b: 'Campylobacteriosis',
value: 28
}, {
a: 'Nov 2022',
b: 'Campylobacteriosis',
value: 34
}, {
a: 'Oct 2022',
b: 'Campylobacteriosis',
value: 45
}, {
a: 'Sep 2022',
b: 'Campylobacteriosis',
value: 18
}];
export const appointments = [{
doctor: 'Dr. Terrell Fashey',
start: '8:30 AM',
patient: {
name: 'Flora Strosin',
phone: '679-747-6105',
email: 'flora.strosin@email.com'
}
}, {
doctor: 'Dr. Clarence Gulgowski',
start: '9:10 AM',
patient: {
name: 'Michele Nicolas',
phone: '884-528-7089',
email: 'm.nicolas@email.com'
}
}, {
doctor: 'Dr. Jay Mohr',
start: '9:45 AM',
patient: {
name: 'Joseph Pacocha',
phone: '777-284-2912',
email: 'j.pacocha@email.com'
}
}];
export const compactCards = [{
svgIcon: calendarIcon,
title: 'Appointments',
info: '78 appointments today'
}, {
svgIcon: accessibilityIcon,
title: 'Patients',
info: '1234 active cases'
}, {
svgIcon: displayBlockIcon,
title: 'Beds',
info: '56 occupied beds'
}, {
svgIcon: myspaceIcon,
title: 'Staff',
info: '78 colleagues at work'
}];
export const listItems = [{
name: 'Dr. Teresa Conn',
specialty: 'Internal medicine',
imageSrc: 'assets/healthcare-dashboard/avatar_1.png'
}, {
name: 'Dr. Mitchell Robel',
specialty: 'Pediatrics',
imageSrc: 'assets/healthcare-dashboard/avatar_2.png'
}, {
name: 'Dr. Barry Jacobs',
specialty: 'Gastroenterology',
imageSrc: 'assets/healthcare-dashboard/avatar_3.png'
}, {
name: 'Dr. Nina Bosco',
specialty: 'Cardiology',
imageSrc: 'assets/healthcare-dashboard/avatar_4.png'
}];
export const drawerItems = [{
text: 'Dashboard',
svgIcon: inboxIcon,
selected: true,
id: 0,
}, {
text: 'Schedule',
svgIcon: calendarDateIcon,
id: 1
}, {
text: 'Patients',
svgIcon: accessibilityIcon,
id: 2,
}, {
text: 'Bed Management',
svgIcon: displayBlockIcon,
id: 3
}, {
text: 'Staff',
svgIcon: myspaceIcon,
id: 4,
}, {
text: 'Doctors',
svgIcon: accessibilityIcon,
id: 40,
parentId: 4
}, {
text: 'Nurses',
svgIcon: accessibilityIcon,
id: 41,
parentId: 4
}, {
text: 'Therapists',
svgIcon: accessibilityIcon,
id: 42,
parentId: 4
}, {
text: 'Technicians',
svgIcon: accessibilityIcon,
id: 43,
parentId: 4
}, {
text: 'Information technology',
svgIcon: accessibilityIcon,
id: 44,
parentId: 4
}, {
text: 'Food services',
svgIcon: accessibilityIcon,
id: 45,
parentId: 4
}, {
text: 'Environmental services',
svgIcon: accessibilityIcon,
id: 46,
parentId: 4
}, {
text: 'Pharmacy',
svgIcon: pencilIcon,
id: 5,
}, {
text: 'Reports',
svgIcon: fileIcon,
id: 6,
}, {
text: 'Report 1',
svgIcon: fileIcon,
id: 60,
parentId: 6
}, {
text: 'Departments',
svgIcon: calendarIcon,
id: 7,
}, {
text: 'Report 1',
svgIcon: calendarIcon,
id: 70,
parentId: 7
}, {
text: 'Payments',
svgIcon: dollarIcon,
id: 8,
}, {
text: 'Payments 1',
svgIcon: dollarIcon,
id: 80,
parentId: 8
}, {
separator: true
}, {
text: 'Support',
svgIcon: starOutlineIcon,
id: 9,
}];
@@ -0,0 +1,238 @@
<div class="login-page-container">
<!-- Background Elements -->
<div class="background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<!-- Main Content -->
<div class="login-content">
<!-- Left Side - Branding -->
<div class="branding-section">
<div class="branding-content">
<div class="logo-container">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image">
<div class="logo-text">
<h1>RBJ Identity</h1>
<span class="tagline">Escrow Management Portal</span>
</div>
</div>
<div class="welcome-text">
<h2>Welcome Back</h2>
<p>Access your escrow transactions, manage client communications, and track document workflows
securely.</p>
</div>
<div class="features-list">
<div class="feature-item">
<div class="feature-icon">🔒</div>
<span>Secure Escrow Management</span>
</div>
<div class="feature-item">
<div class="feature-icon">💬</div>
<span>Client Communication</span>
</div>
<div class="feature-item">
<div class="feature-icon">📄</div>
<span>Document Management</span>
</div>
<div class="feature-item">
<div class="feature-icon">📋</div>
<span>Task Tracking</span>
</div>
</div>
</div>
</div>
<!-- Right Side - Login Form -->
<div class="login-section">
<div class="login-card">
<!-- Initial State -->
<div *ngIf="!showLoginForm" class="initial-state">
<div class="login-header">
<h3>Access Your Account</h3>
<p>Sign in to manage your escrow transactions and client communications</p>
</div>
<div class="login-actions">
<button kendoButton themeColor="primary" size="large" (click)="showLoginFormView()"
class="signin-button">
<span class="button-content">
<svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
Sign In
</span>
</button>
</div>
</div>
<!-- Login Form State -->
<div *ngIf="showLoginForm" class="login-form-state">
<div class="login-header">
<button class="back-button" (click)="goBackToInitialState()" title="Go back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15,18 9,12 15,6"></polyline>
</svg>
</button>
<h3>Sign In</h3>
<p>Enter your credentials to access your account</p>
</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="login-form">
<!-- Error Message -->
<div *ngIf="showError" class="error-message">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
{{ errorMessage }}
</div>
<!-- Email Field -->
<div class="form-field">
<kendo-label for="email">Email Address</kendo-label>
<kendo-textbox id="email" formControlName="email" placeholder="Enter your email address"
[clearButton]="false">
</kendo-textbox>
<div *ngIf="emailControl?.invalid && emailControl?.touched" class="field-error">
<span *ngIf="emailControl?.errors?.['required']">Email is required</span>
<span *ngIf="emailControl?.errors?.['email']">Please enter a valid email address</span>
</div>
</div>
<!-- Password Field -->
<div class="form-field">
<kendo-label for="password">Password</kendo-label>
<kendo-textbox id="password" formControlName="password" placeholder="Enter your password"
type="password" [clearButton]="false">
</kendo-textbox>
<div *ngIf="passwordControl?.invalid && passwordControl?.touched" class="field-error">
<span *ngIf="passwordControl?.errors?.['required']">Password is required</span>
<span *ngIf="passwordControl?.errors?.['minlength']">Password must be at least 6
characters</span>
</div>
</div>
<!-- Remember Me -->
<div class="form-field checkbox-field">
<label class="checkbox-container">
<kendo-checkbox formControlName="rememberMe"></kendo-checkbox>
<span class="checkbox-label">Remember me</span>
</label>
</div>
<!-- Submit Button -->
<div class="form-actions">
<button kendoButton themeColor="primary" size="large" type="submit"
[disabled]="loginForm.invalid || isProcessing" class="submit-button">
<span class="button-content">
<kendo-loader *ngIf="isProcessing" size="small"></kendo-loader>
<svg *ngIf="!isProcessing" class="button-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
{{ isProcessing ? 'Signing In...' : 'Sign In' }}
</span>
</button>
</div>
</form>
</div>
<!-- Demo Credentials *ngIf="!showLoginForm"-->
<div class="demo-section" *ngIf="false">
<div class="demo-header">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
<span>Demo Access</span>
</div>
<div class="credential-tabs">
<button class="tab-button active" (click)="setActiveTab('user')"
[class.active]="activeTab === 'user'">
Client Access
</button>
<button class="tab-button" (click)="setActiveTab('admin')"
[class.active]="activeTab === 'admin'">
Admin Access
</button>
</div>
<div class="credential-content" *ngIf="activeTab === 'user'">
<div class="credential-item">
<span class="label">Client Email:</span>
<span class="value">client@example.com</span>
<button class="copy-btn" (click)="copyToClipboard('client@example.com')" title="Copy email">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Password:</span>
<span class="value">password123</span>
<button class="copy-btn" (click)="copyToClipboard('password123')" title="Copy password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
</div>
<div class="credential-content" *ngIf="activeTab === 'admin'">
<div class="credential-item">
<span class="label">Admin Email:</span>
<span class="value">admin@example.com</span>
<button class="copy-btn" (click)="copyToClipboard('admin@example.com')" title="Copy email">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Password:</span>
<span class="value">password123</span>
<button class="copy-btn" (click)="copyToClipboard('password123')" title="Copy password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Security Code:</span>
<span class="value">123456</span>
<button class="copy-btn" (click)="copyToClipboard('123456')" title="Copy security code">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MFA Dialog -->
<app-mfa-dialog #mfaDialog (mfaSuccess)="onMfaSuccess($event)" (mfaCancel)="onMfaCancel()">
</app-mfa-dialog>
</div>
@@ -0,0 +1,727 @@
.login-page-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
position: relative;
overflow: hidden;
padding: 1rem;
}
// Background Shapes
.background-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
&.shape-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 150px;
height: 150px;
top: 60%;
right: 15%;
animation-delay: 2s;
}
&.shape-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
// Main Content
.login-content {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1200px;
width: 100%;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
overflow: hidden;
position: relative;
z-index: 1;
}
// Branding Section
.branding-section {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
padding: 3rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="white" opacity="0.1"/><circle cx="75" cy="75" r="1" fill="white" opacity="0.1"/><circle cx="50" cy="10" r="0.5" fill="white" opacity="0.1"/><circle cx="10" cy="60" r="0.5" fill="white" opacity="0.1"/><circle cx="90" cy="40" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.3;
}
}
.branding-content {
position: relative;
z-index: 1;
text-align: center;
}
.logo-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
gap: 1rem;
.logo-image {
height: 60px;
width: auto;
filter: brightness(0) invert(1);
}
.logo-text {
h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.tagline {
font-size: 1rem;
opacity: 0.9;
font-weight: 300;
}
}
}
.welcome-text {
margin-bottom: 3rem;
h2 {
font-size: 2rem;
font-weight: 600;
margin: 0 0 1rem 0;
letter-spacing: -0.01em;
}
p {
font-size: 1.1rem;
opacity: 0.9;
line-height: 1.6;
margin: 0;
}
}
.features-list {
display: flex;
flex-direction: column;
gap: 1rem;
.feature-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateX(5px);
}
.feature-icon {
font-size: 1.5rem;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
}
span {
font-weight: 500;
font-size: 1rem;
}
}
}
// Login Section
.login-section {
padding: 3rem;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
h3 {
font-size: 2rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
letter-spacing: -0.01em;
}
p {
color: #666;
font-size: 1rem;
margin: 0;
}
}
.login-actions {
margin-bottom: 2rem;
}
.signin-button {
width: 100%;
height: 56px;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(30, 58, 138, 0.4);
}
&:active {
transform: translateY(0);
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 20px;
height: 20px;
}
}
// Demo Section
.demo-section {
background: #f8f9fa;
border-radius: 16px;
padding: 1.5rem;
border: 1px solid #e9ecef;
}
.demo-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #495057;
font-weight: 600;
font-size: 0.9rem;
.demo-icon {
width: 16px;
height: 16px;
}
}
.credential-tabs {
display: flex;
background: white;
border-radius: 8px;
padding: 4px;
margin-bottom: 1rem;
border: 1px solid #e9ecef;
}
.tab-button {
flex: 1;
padding: 0.75rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
color: #6c757d;
cursor: pointer;
transition: all 0.2s ease;
&.active {
background: #1e40af;
color: white;
box-shadow: 0 2px 4px rgba(30, 64, 175, 0.2);
}
&:hover:not(.active) {
background: #f8f9fa;
color: #495057;
}
}
.credential-content {
.credential-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: white;
border-radius: 8px;
margin-bottom: 0.5rem;
border: 1px solid #e9ecef;
transition: all 0.2s ease;
&:hover {
border-color: #1e40af;
box-shadow: 0 2px 8px rgba(30, 64, 175, 0.1);
}
.label {
font-weight: 600;
color: #495057;
min-width: 60px;
font-size: 0.9rem;
}
.value {
flex: 1;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9rem;
color: #1a1a1a;
background: #f8f9fa;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.copy-btn {
background: #1e40af;
border: none;
border-radius: 6px;
padding: 0.5rem;
color: white;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #1e3a8a;
transform: scale(1.05);
}
svg {
width: 14px;
height: 14px;
}
}
}
}
// Login Form Styles
.login-form-state {
.login-header {
position: relative;
text-align: center;
.back-button {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #666;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f8f9fa;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
h3 {
margin: 0 0 0.5rem 0;
}
p {
margin: 0 0 1.5rem 0;
font-size: 0.95rem;
}
}
}
.login-form {
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
background: #fee2e2;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid #fecaca;
margin-bottom: 1.5rem;
font-size: 0.9rem;
font-weight: 500;
.error-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.form-field {
margin-bottom: 1.5rem;
kendo-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
font-size: 0.9rem;
}
kendo-textbox {
width: 100%;
.k-textbox {
height: 48px;
border-radius: 8px;
border: 2px solid #e5e7eb;
font-size: 1rem;
transition: all 0.2s ease;
&:focus {
border-color: #1e40af;
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
}
&.k-invalid {
border-color: #dc2626;
}
}
}
.field-error {
margin-top: 0.25rem;
font-size: 0.8rem;
color: #dc2626;
font-weight: 500;
}
&.checkbox-field {
margin-bottom: 1rem;
.checkbox-container {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
kendo-checkbox {
margin: 0;
}
.checkbox-label {
font-size: 0.9rem;
color: #6b7280;
font-weight: 500;
margin: 0;
}
&:hover .checkbox-label {
color: #1e40af;
}
// Style when checkbox is checked
&:has(kendo-checkbox:checked) .checkbox-label {
color: #1e40af;
}
}
}
}
.form-actions {
margin-top: 2rem;
.submit-button {
width: 100%;
height: 48px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
box-shadow: 0 4px 12px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(30, 58, 138, 0.4);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 18px;
height: 18px;
}
}
}
}
// Mobile Responsive
@media (max-width: 768px) {
.login-page-container {
padding: 0.5rem;
}
.login-content {
grid-template-columns: 1fr;
border-radius: 16px;
}
.branding-section {
padding: 2rem 1.5rem;
order: 2;
}
.login-section {
padding: 2rem 1.5rem;
order: 1;
}
.logo-container {
flex-direction: column;
gap: 0.5rem;
.logo-text h1 {
font-size: 2rem;
}
}
.welcome-text {
margin-bottom: 2rem;
h2 {
font-size: 1.5rem;
}
p {
font-size: 1rem;
}
}
.features-list {
.feature-item {
padding: 0.5rem;
.feature-icon {
width: 35px;
height: 35px;
font-size: 1.25rem;
}
span {
font-size: 0.9rem;
}
}
}
.login-header h3 {
font-size: 1.75rem;
}
.signin-button {
height: 50px;
font-size: 1rem;
}
.demo-section {
padding: 1rem;
}
.credential-tabs {
.tab-button {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
}
.credential-content {
.credential-item {
padding: 0.5rem;
.label {
min-width: 50px;
font-size: 0.85rem;
}
.value {
font-size: 0.85rem;
}
}
}
// Login form mobile styles
.login-form-state {
.login-header {
.back-button {
padding: 0.4rem;
svg {
width: 18px;
height: 18px;
}
}
}
}
.login-form {
.form-field {
margin-bottom: 1.25rem;
kendo-textbox .k-textbox {
height: 44px;
font-size: 0.95rem;
}
}
.form-actions .submit-button {
height: 44px;
font-size: 0.95rem;
}
}
}
@media (max-width: 480px) {
.login-page-container {
padding: 0.25rem;
}
.branding-section,
.login-section {
padding: 1.5rem 1rem;
}
.logo-container .logo-text h1 {
font-size: 1.75rem;
}
.welcome-text h2 {
font-size: 1.25rem;
}
.features-list {
.feature-item {
.feature-icon {
width: 30px;
height: 30px;
font-size: 1rem;
}
span {
font-size: 0.85rem;
}
}
}
// Login form extra small mobile styles
.login-form {
.form-field {
margin-bottom: 1rem;
kendo-textbox .k-textbox {
height: 40px;
font-size: 0.9rem;
}
}
.form-actions .submit-button {
height: 40px;
font-size: 0.9rem;
}
}
}
@@ -0,0 +1,205 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DialogModule, DialogService } from '@progress/kendo-angular-dialog';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LabelModule } from '@progress/kendo-angular-label';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { MfaDialogComponent } from '../../shared/mfa-dialog/mfa-dialog.component';
import { AuthService, LoginCredentials, LoginResultType, TokenVerificationResult } from '../../shared/services/auth.service';
import { Router, ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-login-page',
standalone: true,
imports: [
CommonModule,
DialogModule,
ButtonsModule,
ReactiveFormsModule,
InputsModule,
LabelModule,
IndicatorsModule,
MfaDialogComponent
],
templateUrl: './login-page.component.html',
styleUrls: ['./login-page.component.scss']
})
export class LoginPage implements OnInit {
@ViewChild('mfaDialog') mfaDialog!: MfaDialogComponent;
activeTab: 'user' | 'admin' = 'user';
showLoginForm = false;
loginForm: FormGroup;
isProcessing = false;
showError = false;
errorMessage = '';
constructor(
private dialogService: DialogService,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute,
private fb: FormBuilder
) {
this.loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
rememberMe: [false]
});
}
ngOnInit(): void {
// Check if user is already logged in
if (this.authService.isAuthenticated()) {
this.redirectToDashboard();
return;
}
// Check for token in URL parameters
this.route.queryParams.subscribe(params => {
const token = params['token'];
if (token) {
this.verifySecretLinkToken(token);
}
});
}
setActiveTab(tab: 'user' | 'admin'): void {
this.activeTab = tab;
}
copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).then(() => {
// You could add a toast notification here
console.log('Copied to clipboard:', text);
}).catch(err => {
console.error('Failed to copy text: ', err);
});
}
showLoginFormView(): void {
this.showLoginForm = true;
// Focus on email input when form appears
setTimeout(() => {
const emailInput = document.querySelector('input[formControlName="email"]') as HTMLInputElement;
if (emailInput) {
emailInput.focus();
}
}, 100);
}
goBackToInitialState(): void {
this.showLoginForm = false;
this.loginForm.reset();
this.showError = false;
this.errorMessage = '';
}
onSubmit(): void {
if (this.loginForm.valid && !this.isProcessing) {
this.isProcessing = true;
this.showError = false;
const credentials: LoginCredentials = this.loginForm.value;
this.authService.login(credentials).subscribe({
next: (result) => {
this.isProcessing = false;
if (result.result === LoginResultType.Success) {
this.authService.setCurrentUser(result.responseData!);
this.redirectToDashboard();
} else if (result.result === LoginResultType.MfaRequired) {
this.showMfaDialog(credentials);
} else {
this.showError = true;
this.errorMessage = result.message || 'Invalid email or password';
}
},
error: (error) => {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'An error occurred during login. Please try again.';
console.error('Login error:', error);
}
});
}
}
get emailControl() {
return this.loginForm.get('email');
}
get passwordControl() {
return this.loginForm.get('password');
}
private showMfaDialog(credentials: LoginCredentials): void {
if (this.mfaDialog) {
// Set the login data for MFA dialog
(this.mfaDialog as any).loginData = credentials;
// Show MFA dialog
this.mfaDialog.show();
}
}
onMfaSuccess(userData: any): void {
this.authService.setCurrentUser(userData);
this.redirectToDashboard();
}
onMfaCancel(): void {
// Reset form and focus on email
this.loginForm.reset();
setTimeout(() => {
const emailInput = document.querySelector('input[formControlName="email"]') as HTMLInputElement;
if (emailInput) {
emailInput.focus();
}
}, 100);
}
private verifySecretLinkToken(token: string): void {
this.isProcessing = true;
this.showError = false;
// First check if token is expired locally
if (this.authService.isTokenExpired(token)) {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'This link has expired. Please request a new one.';
return;
}
this.authService.verifySecretLinkToken(token).subscribe({
next: (result: TokenVerificationResult) => {
this.isProcessing = false;
if (result.isValid && result.user) {
// Token is valid, set user and redirect
this.authService.setCurrentUser(result.user);
this.redirectToDashboard();
} else {
// Token verification failed
this.showError = true;
this.errorMessage = result.message || 'Invalid or expired link. Please request a new one.';
}
},
error: (error) => {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'An error occurred while verifying the link. Please try again.';
console.error('Token verification error:', error);
}
});
}
private redirectToDashboard(): void {
const redirectUrl = this.authService.getRedirectUrl();
this.router.navigate([redirectUrl || '/dashboard']);
}
}
@@ -0,0 +1,5 @@
<!-- Start of FTR-7 -->
<footer class="!k-bg-primary k-color-white k-bg-light k-py-6 k-px-2 k-px-sm-4.5 k-px-md-6 k-px-lg-4 k-px-xl-7.5">
<p class="!k-mb-0">Copyright © {{ currentYear }} RBJ Software, Inc. All rights reserved.</p>
</footer>
<!-- End of FTR-7 -->
@@ -0,0 +1,3 @@
footer {
margin-top: auto;
}
@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-footer',
standalone: true,
imports: [CommonModule],
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
})
export class FooterComponent {
public currentYear = new Date().getFullYear();
}
@@ -0,0 +1,42 @@
<!-- Start of TPNAV-1 -->
<header>
<kendo-appbar positionMode='sticky' themeColor="inherit" class='k-bg-surface-alt' [style.z-index]="10000">
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-gap-2">
<button kendoButton [svgIcon]="menuIcon" fillMode="clear" title="Menu" (click)="onMenuClick()"></button>
<a href="#" class="k-d-none k-d-sm-flex logo-link">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ RBJ Identity logo" />
<span class="logo-text">RBJ Identity Portal</span>
</a>
<a href="#" class="k-d-flex k-d-sm-none">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ RBJ Identity compact logo" />
</a>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-center">
<div class="k-d-flex k-d-md-none">
<button kendoButton [svgIcon]="searchIcon" fillMode="clear" title="Search"></button>
</div>
<div class="k-d-none k-d-md-flex search-box-wrapper">
<kendo-textbox class="search-box" placeholder="Input value" fillMode="flat">
<ng-template kendoTextBoxPrefixTemplate>
<kendo-svgicon [icon]="searchIcon"></kendo-svgicon>
<kendo-textbox-separator></kendo-textbox-separator>
</ng-template>
</kendo-textbox>
</div>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-end k-gap-1.5">
<kendo-badge-container>
<button kendoButton [svgIcon]="bellIcon" fillMode="clear" title="Notifications"></button>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlign" themeColor="error"></kendo-badge>
</kendo-badge-container>
<span class="k-appbar-separator k-color-border k-d-none k-d-sm-inline"></span>
<kendo-dropdownbutton [data]="userMenuItems" fillMode="clear" [svgIcon]="userIcon" [arrowIcon]="true"
(itemClick)="onUserMenuClick($event)">
<span class="k-d-none k-d-sm-inline">
{{ isAuthenticated ? (getDisplayName() || currentUser?.email || 'User') : 'Sign In' }}
</span>
</kendo-dropdownbutton>
</kendo-appbar-section>
</kendo-appbar>
</header>
<!-- End of TPNAV-1 -->
@@ -0,0 +1,48 @@
/* Logo styling */
.logo-link {
//display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.85;
}
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #0066cc 0%, #0052a3 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
white-space: nowrap;
// Fallback for browsers that don't support background-clip
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
color: #0066cc;
background: none;
-webkit-text-fill-color: initial;
}
}
/* Search box responsive styling */
.search-box-wrapper {
width: 100%;
max-width: 360px;
}
.search-box {
width: 100%;
}
/* Mobile optimizations */
@media (max-width: 767px) {
/* Optimize spacing on mobile */
kendo-appbar {
padding: 0 8px;
}
}
@@ -0,0 +1,128 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AppBarModule } from '@progress/kendo-angular-navigation';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, bellIcon, menuIcon, searchIcon, userIcon, logoutIcon } from '@progress/kendo-svg-icons';
import { LayoutService } from '../services/layout.service';
import { AuthService, User } from '../../shared/services/auth.service';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-header',
standalone: true,
imports: [
CommonModule,
AppBarModule,
ButtonsModule,
IndicatorsModule,
InputsModule,
IconsModule,
DropDownsModule
],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit, OnDestroy {
public menuIcon: SVGIcon = menuIcon;
public searchIcon: SVGIcon = searchIcon;
public bellIcon: SVGIcon = bellIcon;
public userIcon: SVGIcon = userIcon;
public logoutIcon: SVGIcon = logoutIcon;
public userMenuItems: any[] = [];
public currentUser: User | null = null;
public isAuthenticated = false;
public badgeAlign = {
vertical: 'top' as const,
horizontal: 'end' as const
};
private destroy$ = new Subject<void>();
constructor(
public layoutService: LayoutService,
private authService: AuthService,
private router: Router
) { }
ngOnInit(): void {
// Subscribe to authentication state changes
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
this.isAuthenticated = !!user;
this.updateUserMenu();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public onMenuClick(): void {
this.layoutService.toggleDrawer();
}
public onLogout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
public onUserMenuClick(item: any): void {
if (item.click) {
item.click();
}
}
public getDisplayName(): string {
if (this.currentUser) {
const fullName = `${this.currentUser.firstName} ${this.currentUser.lastName}`.trim();
return fullName || this.currentUser.email;
}
return '';
}
private updateUserMenu(): void {
if (this.isAuthenticated && this.currentUser) {
this.userMenuItems = [
{
text: `Welcome, ${this.getDisplayName() || this.currentUser.email}`,
disabled: true
},
{ separator: true },
{
text: 'Profile',
icon: 'user',
disabled: true
},
{
text: 'Settings',
icon: 'settings',
disabled: true
},
{ separator: true },
{
text: 'Logout',
icon: 'logout',
click: () => this.onLogout()
}
];
} else {
this.userMenuItems = [
{
text: 'Sign In',
click: () => this.router.navigate(['/login'])
}
];
}
}
}
@@ -0,0 +1,23 @@
<kendo-drawer-container>
<kendo-drawer class="!k-flex-none k-overflow-y-auto !k-pos-sticky" [items]="drawerItems"
[mode]="layoutService.drawerMode()" [mini]="true" [expanded]="layoutService.drawerExpanded()"
(select)="onSelect($event)" [autoCollapse]="layoutService.drawerAutoCollapse()"
[isItemExpanded]="isItemExpanded" [width]="248" [style.height]="'calc(100vh - 46px)'">
<ng-template kendoDrawerItemTemplate let-item let-hasChildren="hasChildren" let-isItemExpanded="isItemExpanded">
@if (item.svgIcon) {
<kendo-svgicon [icon]="item.svgIcon"></kendo-svgicon>
}
<span class="k-item-text">{{item.text}}</span>
@if (hasChildren) {
<span class="k-spacer"></span>
<span class="k-drawer-toggle">
<kendo-svgicon [icon]="isItemExpanded ? chevronUpIcon : chevronDownIcon"></kendo-svgicon>
</span>
}
</ng-template>
</kendo-drawer>
<kendo-drawer-content>
<router-outlet></router-outlet>
<app-footer></app-footer>
</kendo-drawer-content>
</kendo-drawer-container>
@@ -0,0 +1,20 @@
/* Drawer animation */
kendo-drawer {
transition: transform 0.3s ease-in-out;
}
/* Mobile optimizations */
@media (max-width: 767px) {
/* Ensure drawer overlay has proper z-index */
kendo-drawer.k-drawer-overlay {
z-index: 9999;
}
}
/* Tablet optimizations */
@media (min-width: 768px) and (max-width: 1023px) {
/* Adjust drawer width for tablets if needed */
kendo-drawer {
max-width: 280px;
}
}
@@ -0,0 +1,77 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterOutlet } from '@angular/router';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, chevronDownIcon, chevronUpIcon } from '@progress/kendo-svg-icons';
import { DrawerItemExpandedFn, DrawerSelectEvent } from '@progress/kendo-angular-layout';
import { LayoutService } from '../services/layout.service';
import { drawerItems } from '../../features/dashboard/models';
import { FooterComponent } from '../footer/footer.component';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
LayoutModule,
IconsModule,
FooterComponent
],
templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss']
})
export class NavbarComponent {
public chevronUpIcon: SVGIcon = chevronUpIcon;
public chevronDownIcon: SVGIcon = chevronDownIcon;
public drawerItems = drawerItems;
public selectedDrawerItem = 'Dashboard';
public expandedItems: Array<number> = [4];
constructor(
private router: Router,
public layoutService: LayoutService
) { }
public onSelect(ev: DrawerSelectEvent): void {
this.selectedDrawerItem = ev.item.text;
const current = ev.item.id;
if (this.expandedItems.indexOf(current) >= 0) {
this.expandedItems = this.expandedItems.filter((id) => id !== current);
} else {
this.expandedItems.push(current);
}
// Auto-collapse drawer on mobile after selection
if (this.layoutService.isMobile()) {
this.layoutService.closeDrawer();
}
// Navigate based on the selected item
const routeMap: { [key: string]: string } = {
'Dashboard': '/dashboard',
'Schedule': '/schedule',
'Patients': '/patients',
'Bed Management': '/bed-management',
'Staff': '/staff',
'Pharmacy': '/pharmacy',
'Reports': '/reports',
'Departments': '/departments',
'Payments': '/payments',
'Support': '/support'
};
const route = routeMap[ev.item.text];
if (route) {
this.router.navigate([route]);
}
}
public isItemExpanded: DrawerItemExpandedFn = (item): boolean => {
return this.expandedItems.indexOf(item.id) >= 0;
};
}
@@ -0,0 +1,84 @@
import { Injectable, signal, computed } from '@angular/core';
import { DrawerMode } from '@progress/kendo-angular-layout';
@Injectable({
providedIn: 'root'
})
export class LayoutService {
// Signals for reactive state management
private readonly windowWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 1024);
// Computed signals for responsive breakpoints
public readonly isMobile = computed(() => this.windowWidth() < 768);
public readonly isTablet = computed(() => this.windowWidth() >= 768 && this.windowWidth() < 1024);
public readonly isDesktop = computed(() => this.windowWidth() >= 1024);
// Drawer state
public readonly drawerExpanded = signal<boolean>(true);
public readonly drawerMode = computed<DrawerMode>(() =>
this.isMobile() ? 'overlay' : 'push'
);
public readonly drawerAutoCollapse = computed<boolean>(() => this.isMobile());
constructor() {
this.initializeResizeListener();
this.updateDrawerState();
}
/**
* Initialize window resize listener
*/
private initializeResizeListener(): void {
if (typeof window !== 'undefined') {
window.addEventListener('resize', () => this.handleResize());
}
}
/**
* Handle window resize events
*/
private handleResize(): void {
this.windowWidth.set(window.innerWidth);
this.updateDrawerState();
}
/**
* Update drawer state based on screen size
*/
private updateDrawerState(): void {
if (this.isMobile()) {
this.drawerExpanded.set(false);
} else {
this.drawerExpanded.set(true);
}
}
/**
* Toggle drawer open/closed state
*/
public toggleDrawer(): void {
this.drawerExpanded.update(expanded => !expanded);
}
/**
* Close drawer (useful for mobile after navigation)
*/
public closeDrawer(): void {
this.drawerExpanded.set(false);
}
/**
* Open drawer
*/
public openDrawer(): void {
this.drawerExpanded.set(true);
}
/**
* Get current window width
*/
public getWindowWidth(): number {
return this.windowWidth();
}
}
@@ -0,0 +1,40 @@
<header>
<kendo-appbar positionMode='sticky' themeColor="inherit" class='k-bg-surface-alt' [style.z-index]="10000">
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-gap-2">
<button kendoButton [svgIcon]="menuIcon" fillMode="clear" title="Menu" (click)="onMenuClick()"></button>
<a href="#" class="k-d-none k-d-sm-flex logo-link">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ RBJ Identity logo" />
<span class="logo-text">RBJ Identity Portal</span>
</a>
<a href="#" class="k-d-flex k-d-sm-none">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ RBJ Identity compact logo" />
</a>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-center">
<div class="k-d-flex k-d-md-none">
<button kendoButton [svgIcon]="searchIcon" fillMode="clear" title="Search"></button>
</div>
<div class="k-d-none k-d-md-flex search-box-wrapper">
<kendo-textbox class="search-box" placeholder="Search..." fillMode="flat">
<ng-template kendoTextBoxPrefixTemplate>
<kendo-svgicon [icon]="searchIcon"></kendo-svgicon>
<kendo-textbox-separator></kendo-textbox-separator>
</ng-template>
</kendo-textbox>
</div>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-end k-gap-1.5">
<kendo-badge-container>
<button kendoButton [svgIcon]="bellIcon" fillMode="clear" title="Notifications"></button>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlign" themeColor="error"></kendo-badge>
</kendo-badge-container>
<span class="k-appbar-separator k-color-border k-d-none k-d-sm-inline"></span>
<kendo-dropdownbutton [data]="userMenuItems" fillMode="clear" [svgIcon]="userIcon" [arrowIcon]="true"
(itemClick)="onUserMenuClick($event)">
<span class="k-d-none k-d-sm-inline">
{{ getDisplayName() || currentUser?.email || 'User' }}
</span>
</kendo-dropdownbutton>
</kendo-appbar-section>
</kendo-appbar>
</header>
@@ -0,0 +1,21 @@
.logo-link {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
.logo-text {
margin-left: 0.5rem;
font-weight: 600;
font-size: 1.1rem;
}
}
.search-box-wrapper {
width: 100%;
max-width: 400px;
.search-box {
width: 100%;
}
}
@@ -0,0 +1,118 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AppBarModule } from '@progress/kendo-angular-navigation';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, bellIcon, menuIcon, searchIcon, userIcon, logoutIcon } from '@progress/kendo-svg-icons';
import { AuthService, User } from '../../../../shared/services/auth.service';
import { LayoutService } from '../../../../layout/services/layout.service';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-user-header',
standalone: true,
imports: [
CommonModule,
AppBarModule,
ButtonsModule,
IndicatorsModule,
InputsModule,
IconsModule,
DropDownsModule
],
templateUrl: './user-header.component.html',
styleUrls: ['./user-header.component.scss']
})
export class UserHeaderComponent implements OnInit, OnDestroy {
public menuIcon: SVGIcon = menuIcon;
public searchIcon: SVGIcon = searchIcon;
public bellIcon: SVGIcon = bellIcon;
public userIcon: SVGIcon = userIcon;
public logoutIcon: SVGIcon = logoutIcon;
public userMenuItems: any[] = [];
public currentUser: User | null = null;
public badgeAlign = {
vertical: 'top' as const,
horizontal: 'end' as const
};
private destroy$ = new Subject<void>();
constructor(
private authService: AuthService,
private layoutService: LayoutService,
private router: Router
) { }
ngOnInit(): void {
// Subscribe to authentication state changes
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
this.updateUserMenu();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public onMenuClick(): void {
this.layoutService.toggleDrawer();
}
public onLogout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
public onUserMenuClick(item: any): void {
if (item.click) {
item.click();
}
}
public getDisplayName(): string {
if (this.currentUser) {
const fullName = `${this.currentUser.firstName} ${this.currentUser.lastName}`.trim();
return fullName || this.currentUser.email;
}
return '';
}
private updateUserMenu(): void {
if (this.currentUser) {
this.userMenuItems = [
{
text: `Welcome, ${this.getDisplayName() || this.currentUser.email}`,
disabled: true
},
{ separator: true },
{
text: 'Profile',
icon: 'user',
disabled: true
},
{
text: 'Settings',
icon: 'settings',
disabled: true
},
{ separator: true },
{
text: 'Logout',
icon: 'logout',
click: () => this.onLogout()
}
];
}
}
}
@@ -0,0 +1,42 @@
<kendo-drawer-container>
<kendo-drawer [mode]="'overlay'" [expanded]="layoutService.drawerExpanded()" [width]="280">
<kendo-drawer-content>
<div class="drawer-content">
<div class="drawer-header">
<h3>User Portal</h3>
<p>RBJ Identity Portal</p>
</div>
<nav class="drawer-nav">
<div class="nav-section">
<h4>Main</h4>
<button *ngFor="let item of mainNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
<div class="nav-section">
<h4>Management</h4>
<button *ngFor="let item of managementNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
<div class="nav-section">
<h4>Support</h4>
<button *ngFor="let item of supportNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
</nav>
</div>
</kendo-drawer-content>
</kendo-drawer>
</kendo-drawer-container>
@@ -0,0 +1,66 @@
.drawer-content {
height: 100%;
display: flex;
flex-direction: column;
}
.drawer-header {
padding: 1.5rem 1rem;
border-bottom: 1px solid #e0e0e0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
h3 {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
}
}
.drawer-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.nav-section {
margin-bottom: 1.5rem;
h4 {
margin: 0 0 0.5rem 0;
padding: 0 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #666;
}
}
.nav-button {
width: 100%;
justify-content: flex-start;
text-align: left;
padding: 0.75rem 1rem;
margin: 0.125rem 0;
border-radius: 0;
&:hover {
background-color: #f8f9fa;
}
&.k-button-solid {
background-color: #e3f2fd;
color: #1976d2;
&:hover {
background-color: #bbdefb;
}
}
}
@@ -0,0 +1,106 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd } from '@angular/router';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, homeIcon, calendarIcon, userIcon } from '@progress/kendo-svg-icons';
import { LayoutService } from '../../../../layout/services/layout.service';
import { Subject, takeUntil, filter } from 'rxjs';
interface NavItem {
text: string;
icon: SVGIcon;
path: string;
active?: boolean;
}
@Component({
selector: 'app-user-navbar',
standalone: true,
imports: [
CommonModule,
LayoutModule,
ButtonsModule,
IconsModule
],
templateUrl: './user-navbar.component.html',
styleUrls: ['./user-navbar.component.scss']
})
export class UserNavbarComponent implements OnInit, OnDestroy {
public homeIcon: SVGIcon = homeIcon;
public calendarIcon: SVGIcon = calendarIcon;
public peopleIcon: SVGIcon = userIcon; // Using userIcon as fallback
public bedIcon: SVGIcon = userIcon; // Using userIcon as fallback
public userIcon: SVGIcon = userIcon;
public pillIcon: SVGIcon = userIcon; // Using userIcon as fallback
public chartIcon: SVGIcon = userIcon; // Using userIcon as fallback
public buildingIcon: SVGIcon = userIcon; // Using userIcon as fallback
public creditCardIcon: SVGIcon = userIcon; // Using userIcon as fallback
public supportIcon: SVGIcon = userIcon; // Using userIcon as fallback
public mainNavItems: NavItem[] = [
{ text: 'Dashboard', icon: this.homeIcon, path: '/user-portal/dashboard' },
{ text: 'Schedule', icon: this.calendarIcon, path: '/user-portal/schedule' },
{ text: 'Patients', icon: this.peopleIcon, path: '/user-portal/patients' }
];
public managementNavItems: NavItem[] = [
{ text: 'Bed Management', icon: this.bedIcon, path: '/user-portal/bed-management' },
{ text: 'Staff', icon: this.userIcon, path: '/user-portal/staff' },
{ text: 'Pharmacy', icon: this.pillIcon, path: '/user-portal/pharmacy' },
{ text: 'Reports', icon: this.chartIcon, path: '/user-portal/reports' },
{ text: 'Departments', icon: this.buildingIcon, path: '/user-portal/departments' },
{ text: 'Payments', icon: this.creditCardIcon, path: '/user-portal/payments' }
];
public supportNavItems: NavItem[] = [
{ text: 'Support', icon: this.supportIcon, path: '/user-portal/support' }
];
private destroy$ = new Subject<void>();
constructor(
public layoutService: LayoutService,
private router: Router
) { }
ngOnInit(): void {
// Listen to route changes to update active states
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntil(this.destroy$)
)
.subscribe((event: NavigationEnd) => {
this.updateActiveStates(event.url);
});
// Set initial active state
this.updateActiveStates(this.router.url);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public navigateTo(path: string): void {
this.router.navigate([path]);
this.layoutService.closeDrawer();
}
private updateActiveStates(currentUrl: string): void {
// Reset all active states
[...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems]
.forEach(item => item.active = false);
// Set active state for current route
const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems]
.find(item => currentUrl.startsWith(item.path));
if (activeItem) {
activeItem.active = true;
}
}
}
@@ -0,0 +1,172 @@
<div class="dashboard-container">
<!-- Welcome Section -->
<div class="welcome-section">
<div class="welcome-content">
<h1>Welcome back, {{ getDisplayName() || 'User' }}!</h1>
<p>Here's a mock overview of the RBJ Identity escrow dashboard.</p>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ activeTransactions }}</div>
<div class="stat-label">Active Transactions</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon pending">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3 8-8"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ pendingTasks }}</div>
<div class="stat-label">Pending Tasks</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon completed">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22,4 12,14.01 9,11.01"></polyline>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ completedTransactions }}</div>
<div class="stat-label">Completed</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon total">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">${{ totalValue | number:'1.0-0' }}</div>
<div class="stat-label">Total Value</div>
</div>
</div>
</div>
<!-- Recent Transactions -->
<div class="section">
<div class="section-header">
<h2>Recent Transactions</h2>
</div>
<div class="transactions-list">
<!-- Transactions List -->
<div *ngIf="recentTransactions.length > 0">
<div *ngFor="let transaction of recentTransactions" class="transaction-card">
<div class="transaction-header">
<div class="transaction-title">{{ transaction.title }}</div>
<div class="transaction-status" [class]="transaction.status">
{{ transaction.statusLabel }}
</div>
</div>
<div class="transaction-details">
<div class="transaction-amount">${{ transaction.amount | number:'1.0-0' }}</div>
<div class="transaction-date">{{ transaction.date | date:'MMM d, y' }}</div>
</div>
<div class="transaction-progress">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="transaction.progress"></div>
</div>
<span class="progress-text">{{ transaction.progress }}% Complete</span>
</div>
</div>
</div>
<!-- Empty State -->
<div *ngIf="recentTransactions.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<h3>No Recent Transactions</h3>
<p>You don't have any recent transactions yet.</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
<div class="section-header">
<h2>Quick Actions</h2>
</div>
<div class="quick-actions-grid">
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<div class="action-content">
<div class="action-title">New Transaction</div>
<div class="action-description">Start a new escrow process</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3 8-8"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
</div>
<div class="action-content">
<div class="action-title">Manage Tasks</div>
<div class="action-description">View and update your tasks</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="action-content">
<div class="action-title">Contacts</div>
<div class="action-description">Manage your contacts</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<div class="action-content">
<div class="action-title">Messages</div>
<div class="action-description">Check your messages</div>
</div>
</button>
</div>
</div>
</div>
@@ -0,0 +1,536 @@
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
}
// Welcome Section
.welcome-section {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 200px;
height: 200px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transform: translate(50%, -50%);
}
.welcome-content {
position: relative;
z-index: 1;
h1 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.01em;
}
p {
margin: 0;
font-size: 1.1rem;
opacity: 0.9;
}
}
.welcome-actions {
position: relative;
z-index: 1;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
color: white;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
svg {
width: 18px;
height: 18px;
}
}
}
// Stats Grid
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.active {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.completed {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.total {
background: rgba(168, 85, 247, 0.1);
color: #9333ea;
}
svg {
width: 24px;
height: 24px;
}
}
.stat-content {
flex: 1;
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
font-weight: 500;
}
}
}
// Section Styles
.section {
margin-bottom: 2rem;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
}
.view-all-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: #1e40af;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1e3a8a;
}
svg {
width: 16px;
height: 16px;
}
}
}
}
// Transactions List
.transactions-list {
display: grid;
gap: 1rem;
}
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top: 3px solid #1e40af;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
.loading-text {
color: #6b7280;
font-size: 0.9rem;
margin: 0;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
.empty-icon {
width: 64px;
height: 64px;
background: rgba(30, 64, 175, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #1e40af;
margin-bottom: 1.5rem;
svg {
width: 32px;
height: 32px;
}
}
h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
p {
margin: 0 0 1.5rem 0;
color: #6b7280;
font-size: 0.9rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #1e40af;
border: none;
border-radius: 8px;
color: white;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: #1e3a8a;
transform: translateY(-2px);
}
}
}
.transaction-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 10px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #1e40af;
}
.transaction-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.transaction-title {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
}
.transaction-status {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
&.active {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.completed {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
}
}
.transaction-details {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.transaction-amount {
font-size: 1.25rem;
font-weight: 700;
color: #1f2937;
}
.transaction-date {
font-size: 0.9rem;
color: #6b7280;
}
}
.transaction-progress {
display: flex;
align-items: center;
gap: 1rem;
.progress-bar {
flex: 1;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 0.8rem;
color: #6b7280;
font-weight: 500;
min-width: 80px;
}
}
}
// Quick Actions
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.quick-action-btn {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 1rem;
text-align: left;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #1e40af;
}
.action-icon {
width: 48px;
height: 48px;
background: rgba(30, 64, 175, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #1e40af;
flex-shrink: 0;
svg {
width: 24px;
height: 24px;
}
}
.action-content {
flex: 1;
.action-title {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
}
.action-description {
font-size: 0.9rem;
color: #6b7280;
}
}
}
// Mobile Responsive
@media (max-width: 768px) {
.welcome-section {
flex-direction: column;
text-align: center;
gap: 1.5rem;
.welcome-content h1 {
font-size: 1.5rem;
}
.welcome-content p {
font-size: 1rem;
}
}
.stats-grid {
grid-template-columns: 1fr;
}
.quick-actions-grid {
grid-template-columns: 1fr;
}
.transaction-card {
padding: 1rem;
.transaction-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.transaction-details {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
}
@media (max-width: 480px) {
.welcome-section {
padding: 1.5rem;
.welcome-content h1 {
font-size: 1.25rem;
}
}
.stat-card {
padding: 1rem;
.stat-icon {
width: 40px;
height: 40px;
svg {
width: 20px;
height: 20px;
}
}
.stat-content .stat-value {
font-size: 1.5rem;
}
}
.quick-action-btn {
padding: 1rem;
.action-icon {
width: 40px;
height: 40px;
svg {
width: 20px;
height: 20px;
}
}
}
}
@@ -0,0 +1,75 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService, User } from '../../../../shared/services/auth.service';
interface Transaction {
id: string;
title: string;
amount: number;
status: 'active' | 'pending' | 'completed';
statusLabel: string;
date: Date;
progress: number;
}
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
currentUser: User | null = null;
activeTransactions = 5;
pendingTasks = 12;
completedTransactions = 23;
totalValue = 1250000;
recentTransactions: Transaction[] = [
{
id: 'RBJ-1001',
title: 'Maple Street Purchase Escrow',
amount: 425000,
status: 'active',
statusLabel: 'Open',
date: new Date('2026-04-24'),
progress: 68
},
{
id: 'RBJ-1002',
title: 'Oak Ridge Refinance',
amount: 310000,
status: 'pending',
statusLabel: 'Review',
date: new Date('2026-04-20'),
progress: 42
},
{
id: 'RBJ-1003',
title: 'Cedar Lane Closing',
amount: 515000,
status: 'completed',
statusLabel: 'Closed',
date: new Date('2026-04-12'),
progress: 100
}
];
constructor(private authService: AuthService) { }
ngOnInit(): void {
this.authService.currentUser$.subscribe(user => {
this.currentUser = user;
});
}
getDisplayName(): string {
if (this.currentUser) {
const fullName = `${this.currentUser.firstName} ${this.currentUser.lastName}`.trim();
return fullName || this.currentUser.email;
}
return '';
}
}
@@ -0,0 +1,122 @@
<div class="user-portal-container">
<!-- Background Elements -->
<div class="background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<!-- Main Portal Layout -->
<div class="portal-layout">
<!-- Sidebar Overlay for Mobile -->
<div *ngIf="isMobile && !sidebarCollapsed" class="sidebar-overlay" (click)="onSidebarOverlayClick()">
</div>
<!-- Sidebar Navigation -->
<aside class="sidebar" [class.collapsed]="sidebarCollapsed">
<div class="sidebar-header">
<div class="logo-section">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image">
<div class="logo-text" *ngIf="!sidebarCollapsed">
<h2>RBJ Identity</h2>
<span class="tagline">Escrow Portal</span>
</div>
</div>
<button class="sidebar-toggle" (click)="toggleSidebar()" title="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<h4 *ngIf="!sidebarCollapsed">Overview</h4>
<a routerLink="/user-portal/dashboard" routerLinkActive="active" class="nav-item"
(click)="onNavigationClick()">
<div class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</div>
<span *ngIf="!sidebarCollapsed">Dashboard</span>
</a>
</div>
</nav>
<div class="sidebar-footer" *ngIf="!sidebarCollapsed">
<div class="user-info">
<div class="user-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="user-details">
<div class="user-name">{{ getDisplayName() || 'User' }}
</div>
<div class="user-email">{{ currentUser?.email }}</div>
</div>
</div>
<button class="logout-btn" (click)="logout()" title="Logout" aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16,17 21,12 16,7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Logout
</button>
</div>
</aside>
<!-- Main Content Area -->
<main [class]="mainContentClass">
<!-- Top Header -->
<header class="top-header">
<div class="header-left">
<button class="mobile-menu-btn" (click)="toggleSidebar()" *ngIf="isMobile" title="Toggle menu"
aria-label="Toggle menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="breadcrumb">
<span class="breadcrumb-item">{{ currentPageTitle }}</span>
</div>
</div>
<div class="header-right">
<div class="header-actions">
<button class="action-btn" title="Notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<div class="notification-badge" *ngIf="unreadNotifications > 0">{{ unreadNotifications }}
</div>
</button>
<button class="action-btn" title="Search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
</button>
</div>
</div>
</header>
<!-- Page Content -->
<div class="page-content">
<router-outlet></router-outlet>
</div>
</main>
</div>
</div>
@@ -0,0 +1,554 @@
.user-portal-container {
min-height: 100vh;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
position: relative;
overflow: hidden;
}
// Background Shapes
.background-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
&.shape-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 150px;
height: 150px;
top: 60%;
right: 15%;
animation-delay: 2s;
}
&.shape-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
// Main Portal Layout
.portal-layout {
display: flex;
min-height: 100vh;
position: relative;
z-index: 1;
}
// Desktop layout - fixed sidebar with scrolling content
@media (min-width: 769px) {
.portal-layout {
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
}
.main-content {
margin-left: 290px; // Account for fixed sidebar width
flex: 1;
}
}
// When sidebar is collapsed on desktop
.sidebar.collapsed + .main-content {
margin-left: 70px;
}
}
// Sidebar Overlay for Mobile
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
// Sidebar Styles
.sidebar {
width: 280px;
height: 100vh;
background: rgba(255, 255, 255, 0.95);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
transition: all 0.3s ease;
box-shadow: 2px 0 20px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
&.collapsed {
width: 70px;
.logo-text,
.nav-section h4,
.nav-item span,
.user-details,
.logout-btn span {
opacity: 0;
visibility: hidden;
}
.sidebar-footer {
padding: 1rem 0.5rem;
}
.user-info {
justify-content: center;
}
}
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
}
.logo-section {
display: flex;
align-items: center;
gap: 0.75rem;
.logo-image {
height: 40px;
width: auto;
//filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg) brightness(104%)contrast(97%);
}
.logo-text {
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #1e3a8a;
letter-spacing: -0.01em;
}
.tagline {
font-size: 0.75rem;
color: #6b7280;
font-weight: 500;
}
}
}
.sidebar-toggle {
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
.sidebar-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
max-height: calc(100vh - 200px); // Account for header and footer
}
.nav-section {
margin-bottom: 2rem;
h4 {
margin: 0 0 1rem 1.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
}
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
color: #6b7280;
text-decoration: none;
transition: all 0.2s ease;
position: relative;
margin: 0.125rem 0;
&:hover {
background: rgba(30, 64, 175, 0.1);
color: #1e40af;
}
&.active {
background: rgba(30, 64, 175, 0.15);
color: #1e40af;
font-weight: 600;
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #1e40af;
}
}
.nav-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
span {
font-size: 0.9rem;
font-weight: 500;
}
.nav-badge {
background: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 10px;
margin-left: auto;
min-width: 20px;
text-align: center;
}
}
.sidebar-footer {
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
.user-avatar {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
svg {
width: 20px;
height: 20px;
}
}
.user-details {
flex: 1;
min-width: 0;
.user-name {
font-weight: 600;
color: #1f2937;
font-size: 0.9rem;
margin-bottom: 0.125rem;
}
.user-email {
font-size: 0.8rem;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.logout-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
font-weight: 500;
&:hover {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
svg {
width: 16px;
height: 16px;
}
}
// Main Content Area
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.95);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
margin: 1rem;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
height: calc(100vh - 2rem);
max-height: calc(100vh - 2rem);
}
.top-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.8);
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
.breadcrumb {
.breadcrumb-item {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn {
position: relative;
background: none;
border: none;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
.notification-badge {
position: absolute;
top: 0.25rem;
right: 0.25rem;
background: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
}
.page-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
height: 100%;
max-height: 100%;
}
// Mobile Responsive
@media (max-width: 768px) {
.portal-layout {
flex-direction: column;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s ease;
&:not(.collapsed) {
transform: translateX(0);
}
&.collapsed {
transform: translateX(-100%);
}
}
.main-content {
margin: 0;
border-radius: 0;
height: 100vh;
max-height: 100vh;
}
.mobile-menu-btn {
display: block;
}
.page-content {
padding: 1rem;
}
.top-header {
padding: 1rem;
}
}
@media (max-width: 480px) {
.page-content {
padding: 0.75rem;
}
.top-header {
padding: 0.75rem;
}
.breadcrumb .breadcrumb-item {
font-size: 1.1rem;
}
}
// Overlay for mobile sidebar
@media (max-width: 768px) {
.sidebar:not(.collapsed)::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
}
// Desktop sidebar collapsed state
@media (min-width: 769px) {
.sidebar.collapsed {
width: 70px;
}
.main-content.sidebar-collapsed {
margin-left: 70px;
}
}
@@ -0,0 +1,139 @@
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd, RouterModule, RouterLink, RouterLinkActive } from '@angular/router';
import { RouterOutlet } from '@angular/router';
import { AuthService, User } from '../../shared/services/auth.service';
import { Subject, takeUntil, filter } from 'rxjs';
@Component({
selector: 'app-user-portal',
standalone: true,
imports: [
CommonModule,
RouterModule,
RouterLink,
RouterLinkActive,
RouterOutlet
],
templateUrl: './user-portal.component.html',
styleUrls: ['./user-portal.component.scss']
})
export class UserPortalComponent implements OnInit, OnDestroy {
sidebarCollapsed = false;
isMobile = false;
currentUser: User | null = null;
currentPageTitle = 'Dashboard';
unreadMessages = 3;
unreadNotifications = 2;
private destroy$ = new Subject<void>();
constructor(
private authService: AuthService,
private router: Router
) { }
ngOnInit(): void {
this.checkScreenSize();
this.setupUserSubscription();
this.setupRouteSubscription();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
@HostListener('window:resize', ['$event'])
onResize(event: any): void {
this.checkScreenSize();
}
private checkScreenSize(): void {
this.isMobile = window.innerWidth <= 768;
if (this.isMobile) {
this.sidebarCollapsed = true;
} else {
// On desktop, start with sidebar expanded
this.sidebarCollapsed = false;
}
}
private setupUserSubscription(): void {
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
});
}
private setupRouteSubscription(): void {
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.updatePageTitle();
});
}
private updatePageTitle(): void {
const url = this.router.url;
const segments = url.split('/').filter(segment => segment);
if (segments.length >= 2) {
const page = segments[1];
this.currentPageTitle = this.getPageTitle(page);
} else {
this.currentPageTitle = 'Dashboard';
}
}
private getPageTitle(page: string): string {
const titles: { [key: string]: string } = {
'dashboard': 'Dashboard',
'transactions': 'Escrow Transactions',
'tasks': 'Tasks & Todos',
'contacts': 'Contacts',
'documents': 'Documents',
'messages': 'Messages',
'settings': 'Settings'
};
return titles[page] || 'Dashboard';
}
toggleSidebar(): void {
this.sidebarCollapsed = !this.sidebarCollapsed;
}
get mainContentClass(): string {
return this.sidebarCollapsed ? 'main-content sidebar-collapsed' : 'main-content';
}
onSidebarOverlayClick(): void {
if (this.isMobile && !this.sidebarCollapsed) {
this.sidebarCollapsed = true;
}
}
onNavigationClick(): void {
if (this.isMobile) {
this.sidebarCollapsed = true;
}
}
logout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
getDisplayName(): string {
if (this.currentUser) {
const fullName = `${this.currentUser.firstName} ${this.currentUser.lastName}`.trim();
return fullName || this.currentUser.email;
}
return '';
}
}
@@ -0,0 +1,88 @@
<div class="mfa-dialog-overlay" *ngIf="visible" (click)="close()">
<div class="mfa-dialog-container" (click)="$event.stopPropagation()">
<!-- Background Elements -->
<div class="dialog-background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
</div>
<!-- Dialog Content -->
<div class="mfa-dialog-content">
<!-- Header -->
<div class="mfa-header">
<button class="close-button" (click)="close()" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div class="header-content">
<div class="mfa-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<circle cx="12" cy="16" r="1"></circle>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3>Two-Factor Authentication</h3>
<p>Enter the 6-digit code sent to your device</p>
</div>
</div>
<!-- MFA Code Input -->
<div class="mfa-code-section">
<div class="code-inputs-container">
<ng-container *ngFor="let code of userInputCodes2; let i = index">
<input #codeInput type="number" min="0" max="9" maxlength="1" class="mfa-input"
[(ngModel)]="userInputCodes2[i]" name="n{{i+1}}" (keydown)="onKeyDown(i,$event)"
(paste)="pasteCode(i,$event)" [autofocus]="i==0"
[attr.aria-label]="'MFA code digit ' + (i+1)" title="Enter MFA code digit {{i+1}}">
</ng-container>
</div>
<!-- Error Message -->
<div *ngIf="isInvalidCode" class="error-message">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
Invalid code. Please try again.
</div>
</div>
<!-- Actions -->
<div class="mfa-actions">
<button kendoButton type="button" themeColor="secondary" size="medium" (click)="resendMFCode()"
[disabled]="processing || resendCountDown > 0" class="resend-button">
<span class="button-content">
<svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23,4 23,10 17,10"></polyline>
<polyline points="1,20 1,14 7,14"></polyline>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
{{ resendCodeText }}
</span>
</button>
<button kendoButton type="button" themeColor="primary" size="large" (click)="submitCode()"
[disabled]="processing || !allowSubmit" class="verify-button">
<span class="button-content">
<kendo-loader *ngIf="processing" size="small"></kendo-loader>
<svg *ngIf="!processing" class="button-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12"></polyline>
</svg>
{{ processing ? 'Verifying...' : 'Verify Code' }}
</span>
</button>
</div>
<!-- Help Text -->
<div class="help-text">
<p>Didn't receive the code? Check your spam folder or try resending.</p>
</div>
</div>
</div>
</div>
@@ -0,0 +1,418 @@
.mfa-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.mfa-dialog-container {
position: relative;
width: 100%;
max-width: 450px;
background: rgba(255, 255, 255, 0.98);
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
overflow: hidden;
animation: dialogSlideIn 0.3s ease-out;
}
@keyframes dialogSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Background Shapes
.dialog-background-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
border-radius: 50%;
background: rgba(30, 64, 175, 0.05);
animation: float 8s ease-in-out infinite;
&.shape-1 {
width: 80px;
height: 80px;
top: 15%;
right: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 60px;
height: 60px;
bottom: 20%;
left: 15%;
animation-delay: 3s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-15px) rotate(180deg);
}
}
// Dialog Content
.mfa-dialog-content {
position: relative;
z-index: 1;
padding: 2rem;
}
// Header
.mfa-header {
position: relative;
text-align: center;
margin-bottom: 2rem;
.close-button {
position: absolute;
top: -0.5rem;
right: -0.5rem;
background: #f8f9fa;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: #6b7280;
&:hover {
background: #e5e7eb;
color: #374151;
transform: scale(1.1);
}
svg {
width: 16px;
height: 16px;
}
}
.header-content {
.mfa-icon {
width: 60px;
height: 60px;
margin: 0 auto 1rem;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
svg {
width: 28px;
height: 28px;
}
}
h3 {
font-size: 1.75rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
letter-spacing: -0.01em;
}
p {
color: #6b7280;
font-size: 1rem;
margin: 0;
line-height: 1.5;
}
}
}
// MFA Code Section
.mfa-code-section {
margin-bottom: 2rem;
.code-inputs-container {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-bottom: 1rem;
.mfa-input {
width: 50px;
height: 50px;
border: 2px solid #e5e7eb;
border-radius: 12px;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
background: #ffffff;
transition: all 0.2s ease;
outline: none;
&:focus {
border-color: #1e40af;
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
transform: scale(1.05);
}
&:hover:not(:focus) {
border-color: #d1d5db;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type="number"] {
-moz-appearance: textfield;
}
}
}
.error-message {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: #fee2e2;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid #fecaca;
font-size: 0.9rem;
font-weight: 500;
animation: errorShake 0.5s ease-in-out;
.error-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
}
@keyframes errorShake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
// Actions
.mfa-actions {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
.resend-button {
flex: 1;
height: 48px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
background: #f8f9fa;
border: 2px solid #e5e7eb;
color: #6b7280;
transition: all 0.3s ease;
&:hover:not(:disabled) {
background: #e9ecef;
border-color: #d1d5db;
color: #495057;
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 16px;
height: 16px;
}
}
.verify-button {
flex: 2;
height: 48px;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
color: white;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(30, 58, 138, 0.4);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 18px;
height: 18px;
}
}
}
// Help Text
.help-text {
text-align: center;
p {
color: #9ca3af;
font-size: 0.85rem;
margin: 0;
line-height: 1.4;
}
}
// Mobile Responsive
@media (max-width: 480px) {
.mfa-dialog-overlay {
padding: 0.5rem;
}
.mfa-dialog-container {
max-width: 100%;
border-radius: 16px;
}
.mfa-dialog-content {
padding: 1.5rem;
}
.mfa-header {
margin-bottom: 1.5rem;
.header-content {
.mfa-icon {
width: 50px;
height: 50px;
margin-bottom: 0.75rem;
svg {
width: 24px;
height: 24px;
}
}
h3 {
font-size: 1.5rem;
}
p {
font-size: 0.9rem;
}
}
}
.mfa-code-section {
margin-bottom: 1.5rem;
.code-inputs-container {
gap: 0.5rem;
.mfa-input {
width: 45px;
height: 45px;
font-size: 1.25rem;
}
}
}
.mfa-actions {
flex-direction: column;
gap: 0.75rem;
.resend-button,
.verify-button {
flex: 1;
height: 44px;
}
.verify-button {
order: -1;
}
}
}
@media (max-width: 360px) {
.mfa-dialog-content {
padding: 1rem;
}
.code-inputs-container {
gap: 0.4rem;
.mfa-input {
width: 40px;
height: 40px;
font-size: 1.1rem;
}
}
}
@@ -0,0 +1,250 @@
import { Component, ElementRef, QueryList, ViewChildren, Output, EventEmitter, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { AuthService, LoginCredentials, LoginResultType } from '../services/auth.service';
import { take } from 'rxjs/operators';
const CODE_LENGTH = 6;
@Component({
selector: 'app-mfa-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ButtonsModule,
IndicatorsModule
],
templateUrl: './mfa-dialog.component.html',
styleUrls: ['./mfa-dialog.component.scss']
})
export class MfaDialogComponent {
@ViewChildren('codeInput') codeInputs!: QueryList<ElementRef>;
@Output() mfaSuccess = new EventEmitter<any>();
@Output() mfaCancel = new EventEmitter<void>();
@Input() visible = false;
token: string = '';
userInputCodes: (string | null)[] = [];
userInputCodes2: (string | null)[] = [];
loginData!: LoginCredentials;
processing = false;
allowSubmit = false;
isInvalidCode = false;
resendCountDown = 30;
constructor(private authService: AuthService) { }
ngOnInit() {
for (let i = 0; i < CODE_LENGTH; i++) {
this.userInputCodes.push(null);
this.userInputCodes2.push(null);
}
this.setReSendCountDown();
}
pasteCode(index: number, event: ClipboardEvent) {
event.preventDefault();
const data = event.clipboardData?.getData('text/plain') || '';
let pasteCode = data.replace(new RegExp("[^0-9]", 'g'), "");
for (let i = index; i < CODE_LENGTH; i++) {
if (pasteCode.length > i) {
const code = pasteCode[i];
let input = this.codeInputs.find((element, j) => j === i);
if (input) {
input.nativeElement.value = code;
this.userInputCodes[i] = code;
}
}
}
this.validate(5);
}
public onKeyDown(index: number, e: KeyboardEvent): void {
const el: HTMLInputElement = e.target as HTMLInputElement;
if (e.ctrlKey && e.key.toUpperCase() == 'V') {
return;
} else {
let nextFocusInput: ElementRef | undefined = undefined;
switch (e.key) {
case 'ArrowLeft':
nextFocusInput = this.getInputElements(index, -1);
if (nextFocusInput) nextFocusInput.nativeElement.focus();
break;
case 'ArrowRight':
nextFocusInput = this.getInputElements(index, 1);
if (nextFocusInput) nextFocusInput.nativeElement.focus();
break;
case 'Backspace':
if (el.value) {
el.value = '';
} else {
nextFocusInput = this.getInputElements(index, -1);
}
break;
case 'Delete':
el.value = '';
break;
default:
break;
}
if (nextFocusInput) {
nextFocusInput.nativeElement.focus();
return;
}
let isReplacing = el.selectionStart != el.selectionEnd;
if (this.isEditingKeyPress(e)) {
e.preventDefault();
if (new RegExp("[0-9]", 'g').test(e.key)) {
this.userInputCodes[index] = e.key;
el.value = e.key;
let nextInput = this.getInputElements(index, 1);
if (nextInput) nextInput.nativeElement.focus();
} else {
el.value = '';
this.userInputCodes[index] = null;
}
}
}
this.isInvalidCode = false;
this.validate(index);
}
private getInputElements(index: number, indexOffset: number): ElementRef | undefined {
let nextInput = this.codeInputs.find((element, i) => i === (index + indexOffset));
return nextInput;
}
private isEditingKeyPress(e: KeyboardEvent): boolean {
return e.key.length === 1 || e.key === 'Backspace' || e.key === 'Delete' || e.key === 'ArrowLeft' || e.key === 'ArrowRight';
}
validate(focusIndex: number) {
this.allowSubmit = !this.userInputCodes.some(n => n == null);
this.token = this.userInputCodes.map(n => n != null ? n : '').join('');
if (this.token && this.token.length == 6 && focusIndex == 5) {
this.submitCode();
}
}
close() {
this.visible = false;
this.mfaCancel.emit();
}
show() {
this.visible = true;
this.resetForm();
}
hide() {
this.visible = false;
}
resetForm() {
this.userInputCodes = [];
this.userInputCodes2 = [];
this.token = '';
this.isInvalidCode = false;
this.processing = false;
this.allowSubmit = false;
for (let i = 0; i < CODE_LENGTH; i++) {
this.userInputCodes.push(null);
this.userInputCodes2.push(null);
}
// Focus on first input
setTimeout(() => {
const firstInput = document.querySelector('.mfa-input:first-child') as HTMLInputElement;
if (firstInput) {
firstInput.focus();
}
}, 100);
}
submitCode() {
this.processing = true;
this.loginData.mfaCode = this.token;
// Check if this is token-based authentication
if ((this.loginData as any).tokenUser) {
// Handle token-based MFA verification
this.authService.verifyMfaForToken(this.token, (this.loginData as any).tokenUser).subscribe({
next: (result) => {
this.processing = false;
if (result.result === LoginResultType.Success) {
this.mfaSuccess.emit(result.responseData);
this.visible = false;
} else {
this.isInvalidCode = true;
}
},
error: (error) => {
this.processing = false;
this.isInvalidCode = true;
console.error('MFA verification error:', error);
}
});
} else {
// Handle regular login MFA verification
this.authService.login(this.loginData).subscribe({
next: (result) => {
this.processing = false;
if (result.result === LoginResultType.Success) {
this.mfaSuccess.emit(result.responseData);
this.visible = false;
} else {
this.isInvalidCode = true;
}
},
error: (error) => {
this.processing = false;
this.isInvalidCode = true;
console.error('MFA verification error:', error);
}
});
}
}
setReSendCountDown() {
if (this.resendCountDown > 0) {
setTimeout(() => {
this.resendCountDown--;
this.setReSendCountDown();
}, 1000);
}
}
resendMFCode() {
this.resendCountDown = 30;
this.loginData.mfaCode = '';
// Simulate resend MFA code - replace with actual service call
console.log('Resending MFA code to:', this.loginData.email);
// Check if this is token-based authentication
if ((this.loginData as any).tokenUser) {
// Handle token-based MFA verification
this.authService.verifyMfaForToken(this.token, (this.loginData as any).tokenUser).pipe(
take(1)
).subscribe(result => {
this.setReSendCountDown();
});
} else {
//TODO: Implement resend MFA code for regular login
}
this.setReSendCountDown();
}
public get resendCodeText(): string {
return 'Resend Code' + (this.resendCountDown > 0 ? ` (${this.resendCountDown})` : '');
}
}
+2
View File
@@ -0,0 +1,2 @@
// Export user models
export * from './user.model';
+400
View File
@@ -0,0 +1,400 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { ApiConfigService } from '../../core/services/api-config.service';
export interface User {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
createdAt: string;
branchIds: string[];
token: string;
mfaVerified?: boolean;
}
export interface LoginCredentials {
email: string;
password: string;
mfaCode?: string;
}
export interface LoginResponse {
isAuthenticated: boolean;
requiresMfa: boolean;
token?: string;
expires?: string;
user?: UserDto;
message?: string;
}
export interface TokenCreateResponse {
token?: string;
mfaToken?: string;
access?: AccessDto[];
isAuthenticated?: boolean;
isAuthorized?: boolean;
isChangePassword?: boolean;
message?: string;
username?: string;
email?: string;
concurrentTabs?: number;
mfaType?: number;
mfaHint?: string;
Token?: string;
MfaToken?: string;
Access?: AccessDto[];
IsAuthenticated?: boolean;
IsAuthorized?: boolean;
IsChangePassword?: boolean;
Message?: string;
Username?: string;
Email?: string;
ConcurrentTabs?: number;
MfaType?: number;
MfaHint?: string;
}
export interface AccessDto {
branchName?: string;
redxBranch?: string;
BranchName?: string;
RedxBranch?: string;
}
export interface UserDto {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
createdAt: string;
branchIds: string[];
}
export enum LoginResultType {
Success = 'Success',
MfaRequired = 'MfaRequired',
InvalidCredentials = 'InvalidCredentials',
Error = 'Error'
}
export interface LoginResult {
result: LoginResultType;
responseData?: User;
message?: string;
}
export interface TokenVerificationResult {
isValid: boolean;
user?: User;
message?: string;
expiresAt?: Date;
requiresMfa?: boolean;
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser$ = this.currentUserSubject.asObservable();
private redirectUrl: string = '/dashboard';
constructor(
private http: HttpClient,
private apiConfig: ApiConfigService
) { }
login(credentials: LoginCredentials): Observable<LoginResult> {
return this.authenticateUser(credentials);
}
private authenticateUser(credentials: LoginCredentials): Observable<LoginResult> {
const loginUrl = `${this.apiConfig.tokenUrl}/Create`;
return this.http.get<TokenCreateResponse>(loginUrl, {
headers: this.buildTokenCreateHeaders(credentials)
}).pipe(
map((response: TokenCreateResponse) => this.mapTokenCreateResponse(response, credentials)),
catchError((error) => {
console.error('Login error:', error);
return of({
result: LoginResultType.Error,
message: error.error?.message || 'An error occurred during login'
});
})
);
}
private buildTokenCreateHeaders(credentials: LoginCredentials): HttpHeaders {
let headers = new HttpHeaders({
Authorization: `Basic ${btoa(`${credentials.email}:${credentials.password}`)}`
});
if (credentials.mfaCode) {
headers = headers.set('mfaCode', credentials.mfaCode);
}
return headers;
}
private mapTokenCreateResponse(response: TokenCreateResponse, credentials: LoginCredentials): LoginResult {
const token = response.token || response.Token || '';
const message = response.message || response.Message || '';
const isAuthenticated = response.isAuthenticated ?? response.IsAuthenticated ?? false;
const isAuthorized = response.isAuthorized ?? response.IsAuthorized ?? false;
if (isAuthenticated && isAuthorized && token) {
return {
result: LoginResultType.Success,
responseData: this.mapUserFromTokenCreateResponse(response, credentials, token)
};
}
if (isAuthenticated && !token && this.isMfaRequired(message)) {
return {
result: LoginResultType.MfaRequired,
responseData: this.mapUserFromTokenCreateResponse(
response,
credentials,
response.mfaToken || response.MfaToken || ''
),
message
};
}
return {
result: isAuthenticated ? LoginResultType.Error : LoginResultType.InvalidCredentials,
message: message || 'Invalid email or password'
};
}
private mapUserFromTokenCreateResponse(
response: TokenCreateResponse,
credentials: LoginCredentials,
token: string
): User {
const username = response.username || response.Username || credentials.email;
const email = response.email || response.Email || credentials.email;
const access = response.access || response.Access || [];
return {
id: username || email,
username,
email,
firstName: '',
lastName: '',
createdAt: new Date().toISOString(),
branchIds: access
.map(item => item.redxBranch || item.RedxBranch || item.branchName || item.BranchName || '')
.filter(branchId => !!branchId),
token,
mfaVerified: !!credentials.mfaCode
};
}
private isMfaRequired(message: string): boolean {
return message.toLowerCase().includes('mfa verification required');
}
logout(): void {
this.currentUserSubject.next(null);
this.redirectUrl = '/dashboard';
// Clear any stored tokens, etc.
localStorage.removeItem('currentUser');
}
getCurrentUser(): User | null {
return this.currentUserSubject.value;
}
getToken(): string | null {
const user = this.currentUserSubject.value;
return user?.token || null;
}
isAuthenticated(): boolean {
return this.currentUserSubject.value !== null;
}
setCurrentUser(user: User): void {
this.currentUserSubject.next(user);
// Store user in localStorage for persistence
localStorage.setItem('currentUser', JSON.stringify(user));
}
setRedirectUrl(url: string): void {
this.redirectUrl = url;
}
getRedirectUrl(): string {
return this.redirectUrl;
}
// Initialize user from localStorage on app start
initializeAuth(): void {
const storedUser = localStorage.getItem('currentUser');
if (storedUser) {
try {
const user = JSON.parse(storedUser);
this.currentUserSubject.next(user);
} catch (error) {
console.error('Error parsing stored user:', error);
localStorage.removeItem('currentUser');
}
}
}
/**
* Verify JWT token from email link (local verification)
* @param token The JWT token to verify
* @returns Observable with verification result
*/
verifySecretLinkToken(token: string): Observable<TokenVerificationResult> {
try {
// Parse JWT token locally
const tokenData = this.parseJwtToken(token);
if (!tokenData) {
return of({
isValid: false,
message: 'Invalid token format'
});
}
// Check if token is expired
if (this.isTokenExpired(token)) {
return of({
isValid: false,
message: 'This link has expired. Please request a new one.'
});
}
console.log('tokenData', tokenData);
// Extract user data from token
const user: User = {
id: tokenData.userId || tokenData.sub || tokenData.id,
username: tokenData.username || tokenData.preferred_username || '',
email: tokenData.email || tokenData.email_address || '',
firstName: tokenData.firstName || tokenData.given_name || tokenData.first_name || '',
lastName: tokenData.lastName || tokenData.family_name || tokenData.last_name || '',
createdAt: tokenData.createdAt || tokenData.created_at || new Date().toISOString(),
branchIds: tokenData.branchIds || tokenData.branch_ids || [],
token: token,
mfaVerified: false // Token users still need MFA verification
};
return of({
isValid: true,
user: user,
message: 'Token verified successfully. MFA required.',
expiresAt: tokenData.exp ? new Date(tokenData.exp * 1000) : undefined,
requiresMfa: true
});
} catch (error) {
console.error('Token verification error:', error);
return of({
isValid: false,
message: 'Invalid or corrupted token'
});
}
}
/**
* Check if a token is expired locally (basic check)
* @param token JWT token to check
* @returns boolean indicating if token is expired
*/
isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
return payload.exp < currentTime;
} catch (error) {
console.error('Error parsing token:', error);
return true; // Consider invalid tokens as expired
}
}
/**
* Parse JWT token and extract payload
* @param token JWT token to parse
* @returns parsed token data or null if invalid
*/
private parseJwtToken(token: string): any | null {
try {
// Split token into parts
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
// Decode the payload (middle part)
const payload = parts[1];
const decodedPayload = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decodedPayload);
} catch (error) {
console.error('Error parsing JWT token:', error);
return null;
}
}
/**
* Verify MFA code for token-based authentication
* @param mfaCode MFA code entered by user
* @param user User data from token verification
* @returns Observable with MFA verification result
*/
verifyMfaForToken(mfaCode: string, user: User): Observable<LoginResult> {
// For token-based users, we can either:
// 1. Verify MFA locally (if MFA code is embedded in token)
// 2. Make a server call to verify MFA
// For now, we'll simulate MFA verification
// In a real implementation, you might want to verify against a server
//TODO: Implement MFA verification
const loginUrl = `${this.apiConfig.authUrl}/mfa/token-login`;
return this.http.post<LoginResponse>(loginUrl, {
mfaToken: user.token,
mfaCode: mfaCode
}).pipe(
map((response: LoginResponse) => {
return {
result: LoginResultType.Success,
responseData: {
...user,
mfaVerified: true
},
message: 'MFA verification successful'
};
}),
catchError((error) => {
console.error('MFA verification error:', error);
return of({
result: LoginResultType.Error,
message: error.error?.message || 'An error occurred during MFA verification'
});
})
);
}
/**
* Extract token from URL parameters
* @param url URL containing token parameter
* @returns token string or null if not found
*/
extractTokenFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
return urlObj.searchParams.get('token');
} catch (error) {
console.error('Error parsing URL:', error);
return null;
}
}
}
@@ -0,0 +1,182 @@
import { Injectable } from '@angular/core';
import { SVGIcon } from '@progress/kendo-angular-icons';
import { EscrowStatus, CbAssigneeRole } from '../models/enums.model';
import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class UiUtilsService {
public setPageTitleSubject = new Subject<string>();
public setPageTitle$ = this.setPageTitleSubject.asObservable();
// Icon properties - these should be injected or provided by a parent component
// For now, we'll make them optional and let the calling component provide them
checkCircleIcon?: SVGIcon;
clockIcon?: SVGIcon;
alertCircleIcon?: SVGIcon;
userIcon?: SVGIcon;
constructor() { }
/**
* Get CSS class for escrow status
*/
getStatusClass(status: EscrowStatus): string {
switch (status) {
case EscrowStatus.Open:
return 'status-active';
case EscrowStatus.Closed:
return 'status-completed';
case EscrowStatus.Cancelled:
return 'status-cancelled';
default:
return '';
}
}
/**
* Get display label for escrow status
*/
getEscrowStatusLabel(status: EscrowStatus): string {
switch (status) {
case EscrowStatus.Open:
return 'Open';
case EscrowStatus.Closed:
return 'Closed';
case EscrowStatus.Cancelled:
return 'Cancelled';
default:
return '';
}
}
/**
* Get CSS class for priority level
*/
getPriorityClass(priority: string): string {
switch (priority) {
case 'high':
return 'priority-high';
case 'medium':
return 'priority-medium';
case 'low':
return 'priority-low';
default:
return '';
}
}
/**
* Get icon for escrow status
*/
getStatusIcon(status: EscrowStatus): SVGIcon | undefined {
switch (status) {
case EscrowStatus.Open:
return this.checkCircleIcon;
case EscrowStatus.Closed:
return this.checkCircleIcon;
case EscrowStatus.Cancelled:
return this.alertCircleIcon;
default:
return this.clockIcon;
}
}
/**
* Get CSS class for assignee role
*/
getRoleClass(role: CbAssigneeRole): string {
switch (role) {
case CbAssigneeRole.Buyer:
return 'role-buyer';
case CbAssigneeRole.Seller:
return 'role-seller';
case CbAssigneeRole.BuyerRealEstateAgent:
case CbAssigneeRole.SellerRealEstateAgent:
return 'role-agent';
case CbAssigneeRole.EscrowOfficer:
case CbAssigneeRole.EscrowAssignee:
return 'role-escrow';
case CbAssigneeRole.LoanBroker:
case CbAssigneeRole.Lender:
return 'role-lender';
case CbAssigneeRole.SellerTransactionCoordinator:
case CbAssigneeRole.BuyerTransactionCoordinator:
return 'role-coordinator';
case CbAssigneeRole.None:
return 'role-default';
default:
return 'role-default';
}
}
/**
* Get icon for assignee role
*/
getRoleIcon(role: CbAssigneeRole): SVGIcon | undefined {
switch (role) {
case CbAssigneeRole.Buyer:
case CbAssigneeRole.Seller:
case CbAssigneeRole.BuyerRealEstateAgent:
case CbAssigneeRole.SellerRealEstateAgent:
case CbAssigneeRole.EscrowOfficer:
case CbAssigneeRole.EscrowAssignee:
case CbAssigneeRole.LoanBroker:
case CbAssigneeRole.Lender:
case CbAssigneeRole.SellerTransactionCoordinator:
case CbAssigneeRole.BuyerTransactionCoordinator:
return this.userIcon;
case CbAssigneeRole.None:
return this.userIcon;
default:
return this.userIcon;
}
}
/**
* Get display label for assignee role
*/
getRoleLabel(role: CbAssigneeRole): string {
switch (role) {
case CbAssigneeRole.Buyer:
return 'Buyer';
case CbAssigneeRole.Seller:
return 'Seller';
case CbAssigneeRole.BuyerRealEstateAgent:
return 'Buyer Agent';
case CbAssigneeRole.SellerRealEstateAgent:
return 'Seller Agent';
case CbAssigneeRole.EscrowOfficer:
return 'Escrow Officer';
case CbAssigneeRole.EscrowAssignee:
return 'Escrow Assignee';
case CbAssigneeRole.LoanBroker:
return 'Loan Broker';
case CbAssigneeRole.Lender:
return 'Lender';
case CbAssigneeRole.SellerTransactionCoordinator:
return 'Seller Coordinator';
case CbAssigneeRole.BuyerTransactionCoordinator:
return 'Buyer Coordinator';
case CbAssigneeRole.None:
return 'None';
default:
return 'Unknown';
}
}
/**
* Set icons for the service (should be called by components that use this service)
*/
setIcons(icons: {
checkCircleIcon?: SVGIcon;
clockIcon?: SVGIcon;
alertCircleIcon?: SVGIcon;
userIcon?: SVGIcon;
}): void {
this.checkCircleIcon = icons.checkCircleIcon;
this.clockIcon = icons.clockIcon;
this.alertCircleIcon = icons.alertCircleIcon;
this.userIcon = icons.userIcon;
}
}
+271
View File
@@ -0,0 +1,271 @@
// =============================================================================
// UI Utility Classes
// =============================================================================
// Centralized SCSS for utility classes used by UiUtilsService
// These classes provide consistent styling across all components
// =============================================================================
// Status Badge Classes
// =============================================================================
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
kendo-svgicon {
width: 12px;
height: 12px;
}
// Status variants
&.status-active {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.status-pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.status-completed {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.status-cancelled {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
}
// =============================================================================
// Priority Badge Classes
// =============================================================================
.priority-badge {
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
// Priority variants
&.priority-high {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
&.priority-medium {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.priority-low {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
}
// =============================================================================
// Role Badge Classes
// =============================================================================
.role-badge {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
kendo-svgicon {
width: 12px;
height: 12px;
}
// Role variants
&.role-buyer {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.role-seller {
background: rgba(168, 85, 247, 0.1);
color: #9333ea;
}
&.role-agent {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.role-escrow {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
}
&.role-lender {
background: rgba(139, 69, 19, 0.1);
color: #8b4513;
}
&.role-coordinator {
background: rgba(75, 85, 99, 0.1);
color: #4b5563;
}
&.role-default {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
}
// =============================================================================
// Task Status Classes (for transaction-detail component)
// =============================================================================
.task-status {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
&.task-completed {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.task-in-progress {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.task-pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.task-cancelled {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
}
// =============================================================================
// Utility Mixins (for consistent styling)
// =============================================================================
@mixin badge-base {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-weight: 600;
text-transform: uppercase;
}
@mixin status-colors($bg-color, $text-color) {
background: rgba($bg-color, 0.1);
color: $text-color;
}
// =============================================================================
// Responsive Design
// =============================================================================
@media (max-width: 768px) {
.status-badge,
.role-badge {
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
}
.priority-badge {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
}
}
// =============================================================================
// Dark Mode Support (if needed in the future)
// =============================================================================
@media (prefers-color-scheme: dark) {
.status-badge {
&.status-active {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
&.status-pending {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
&.status-completed {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
&.status-cancelled {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
}
.priority-badge {
&.priority-high {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
&.priority-medium {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
&.priority-low {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
}
.role-badge {
&.role-buyer {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
&.role-seller {
background: rgba(168, 85, 247, 0.2);
color: #a78bfa;
}
&.role-agent {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
&.role-escrow {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
}
&.role-lender {
background: rgba(139, 69, 19, 0.2);
color: #d97706;
}
&.role-coordinator {
background: rgba(75, 85, 99, 0.2);
color: #9ca3af;
}
&.role-default {
background: rgba(107, 114, 128, 0.2);
color: #9ca3af;
}
}
}
@@ -0,0 +1,77 @@
<div class="token-verification-container">
<div class="verification-card">
<div class="logo-section">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo">
</div>
<div class="verification-content">
<!-- Verifying State -->
<div *ngIf="isVerifying" class="verifying-state">
<kendo-loader size="large"></kendo-loader>
<h2>Verifying your access...</h2>
<p>Please wait while we verify your email link.</p>
</div>
<!-- Token Verified State -->
<div *ngIf="verificationResult?.isValid && !isVerifying && !verificationResult?.requiresMfa"
class="success-state">
<div class="success-icon">
<i class="k-icon k-i-check-circle"></i>
</div>
<h2>Access Granted!</h2>
<p>Welcome back, {{ verificationResult?.user?.firstName }}!</p>
<p class="redirect-message">Redirecting you to the dashboard...</p>
</div>
<!-- MFA Required State -->
<div *ngIf="verificationResult?.isValid && verificationResult?.requiresMfa && !isVerifying"
class="mfa-required-state">
<div class="success-icon">
<i class="k-icon k-i-check-circle"></i>
</div>
<h2>Token Verified!</h2>
<p>Welcome back, {{ verificationResult?.user?.firstName }}!</p>
<p class="mfa-message">Please complete MFA verification to continue...</p>
<!-- Debug button to manually trigger MFA dialog -->
<div class="debug-actions" style="margin-top: 20px;">
<button kendoButton themeColor="primary" (click)="showMfaDialog()" class="action-btn">
Show MFA Dialog (Debug)
</button>
</div>
</div>
<!-- Error State -->
<div *ngIf="errorMessage && !isVerifying" class="error-state">
<div class="error-icon">
<i class="k-icon k-i-warning"></i>
</div>
<h2>Link Verification Failed</h2>
<p class="error-message">{{ errorMessage }}</p>
<div class="help-text">
<p>If you're having trouble accessing your account:</p>
<ul>
<li>Make sure you clicked the complete link from your email</li>
<li>Check if the link has expired</li>
<li>Try requesting a new access link</li>
</ul>
</div>
<div class="action-buttons">
<button kendoButton themeColor="primary" (click)="requestNewLink()" class="action-btn">
Request New Link
</button>
<button kendoButton (click)="goToLogin()" class="action-btn secondary">
Go to Login
</button>
</div>
</div>
</div>
</div>
<!-- MFA Dialog -->
<app-mfa-dialog #mfaDialog (mfaSuccess)="onMfaSuccess($event)" (mfaCancel)="onMfaCancel()">
</app-mfa-dialog>
</div>
@@ -0,0 +1,164 @@
.token-verification-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.verification-card {
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
max-width: 500px;
width: 100%;
text-align: center;
}
.logo-section {
margin-bottom: 30px;
.logo {
height: 60px;
width: auto;
}
}
.verification-content {
h2 {
color: #333;
margin-bottom: 16px;
font-size: 24px;
font-weight: 600;
}
p {
color: #666;
margin-bottom: 12px;
line-height: 1.5;
}
}
.verifying-state {
.k-loader {
margin-bottom: 20px;
}
}
.success-state {
.success-icon {
margin-bottom: 20px;
.k-icon {
font-size: 48px;
color: #28a745;
}
}
.redirect-message {
color: #28a745;
font-weight: 500;
margin-top: 20px;
}
}
.mfa-required-state {
.success-icon {
margin-bottom: 20px;
.k-icon {
font-size: 48px;
color: #28a745;
}
}
.mfa-message {
color: #007bff;
font-weight: 500;
margin-top: 20px;
}
}
.error-state {
.error-icon {
margin-bottom: 20px;
.k-icon {
font-size: 48px;
color: #dc3545;
}
}
.error-message {
color: #dc3545;
font-weight: 500;
margin-bottom: 20px;
}
.help-text {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 16px;
margin-bottom: 30px;
text-align: left;
p {
margin-bottom: 8px;
font-weight: 500;
color: #495057;
}
ul {
margin: 0;
padding-left: 20px;
li {
margin-bottom: 4px;
color: #6c757d;
font-size: 14px;
}
}
}
.action-buttons {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
.action-btn {
min-width: 140px;
&.secondary {
background-color: #6c757d;
border-color: #6c757d;
&:hover {
background-color: #5a6268;
border-color: #545b62;
}
}
}
}
}
// Responsive design
@media (max-width: 768px) {
.verification-card {
padding: 30px 20px;
margin: 10px;
}
.action-buttons {
flex-direction: column;
align-items: center;
.action-btn {
width: 100%;
max-width: 200px;
}
}
}
@@ -0,0 +1,177 @@
import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogModule } from '@progress/kendo-angular-dialog';
import { AuthService, TokenVerificationResult, User } from '../services/auth.service';
import { MfaDialogComponent } from '../mfa-dialog/mfa-dialog.component';
import { take } from 'rxjs/operators';
@Component({
selector: 'app-token-verification',
standalone: true,
imports: [
CommonModule,
IndicatorsModule,
ButtonsModule,
DialogModule,
MfaDialogComponent
],
templateUrl: './token-verification.component.html',
styleUrls: ['./token-verification.component.scss']
})
export class TokenVerificationComponent implements OnInit, AfterViewInit {
@ViewChild('mfaDialog') mfaDialog!: MfaDialogComponent;
isVerifying = true;
verificationResult: TokenVerificationResult | null = null;
errorMessage = '';
tokenUser: User | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private authService: AuthService
) { }
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
const token = params['token'];
if (token) {
this.verifyToken(token);
} else {
this.handleError('No token provided in the link');
}
});
}
ngAfterViewInit(): void {
console.log('AfterViewInit - MFA dialog reference:', this.mfaDialog);
}
private verifyToken(token: string): void {
this.isVerifying = true;
this.errorMessage = '';
// Validate token format first
if (!this.isValidJwtFormat(token)) {
this.handleError('Invalid token format. Please check your email link.');
return;
}
this.authService.verifySecretLinkToken(token).subscribe({
next: (result: TokenVerificationResult) => {
this.isVerifying = false;
this.verificationResult = result;
if (result.isValid && result.user) {
// Token is valid, store user data and show MFA dialog
this.tokenUser = result.user;
this.verificationResult = result;
console.log('Token verification result:', result);
console.log('Requires MFA:', result.requiresMfa);
if (result.requiresMfa) {
// Show MFA dialog for token-based authentication
console.log('Showing MFA dialog...');
this.authService.verifyMfaForToken('', result.user).pipe(
take(1)
).subscribe(result => {
this.showMfaDialog();
});
} else {
// If MFA is not required, proceed directly
console.log('MFA not required, proceeding directly...');
this.authService.setCurrentUser(result.user);
setTimeout(() => {
this.redirectToDashboard();
}, 2000);
}
} else {
this.handleError(result.message || 'Invalid or expired link. Please request a new one.');
}
},
error: (error) => {
this.isVerifying = false;
this.handleError('An error occurred while verifying the link. Please try again.');
console.error('Token verification error:', error);
}
});
}
private isValidJwtFormat(token: string): boolean {
// Basic JWT format validation (3 parts separated by dots)
const parts = token.split('.');
return parts.length === 3 && parts.every(part => part.length > 0);
}
private handleError(message: string): void {
this.isVerifying = false;
this.errorMessage = message;
}
private redirectToDashboard(): void {
const redirectUrl = this.authService.getRedirectUrl();
this.router.navigate([redirectUrl || '/dashboard']);
}
goToLogin(): void {
this.router.navigate(['/login']);
}
requestNewLink(): void {
// This could redirect to a "request new link" page or contact form
// For now, redirect to login
this.router.navigate(['/login']);
}
showMfaDialog(): void {
console.log('showMfaDialog called, tokenUser:', this.tokenUser);
if (this.tokenUser) {
// Set the user data for MFA dialog first
const loginData = {
email: this.tokenUser.email,
password: '', // Not needed for token-based auth
tokenUser: this.tokenUser
};
// Use multiple approaches to ensure the dialog shows
const tryShowDialog = (attempt: number = 1) => {
console.log(`Attempt ${attempt} to show MFA dialog`);
if (this.mfaDialog) {
console.log('MFA dialog found, setting loginData and showing...');
(this.mfaDialog as any).loginData = loginData;
this.mfaDialog.show();
} else if (attempt < 5) {
// Try again after a short delay
setTimeout(() => tryShowDialog(attempt + 1), 100);
} else {
console.error('MFA dialog not found after multiple attempts');
}
};
// Start trying to show the dialog
tryShowDialog();
} else {
console.error('No token user available for MFA dialog');
}
}
onMfaSuccess(userData: any): void {
this.authService.setCurrentUser(userData);
this.redirectToDashboard();
}
onMfaCancel(): void {
// Reset to initial state
this.isVerifying = true;
this.verificationResult = null;
this.tokenUser = null;
this.errorMessage = '';
}
}
@@ -0,0 +1,90 @@
export class ArrayUtils {
public static Equals(array1: Array<any>, array2: Array<any>): boolean {
// if the other array is a falsy value, return
if (!array2)
return false;
// compare lengths - can save a lot of time
if (array1.length != array2.length)
return false;
for (var i = 0, l = array1.length; i < l; i++) {
// Check if we have nested arrays
if (array1[i] instanceof Array && array2[i] instanceof Array) {
// recurse into the nested arrays
if (!ArrayUtils.Equals(array1[i], array2[i]))
return false;
}
else if (array1[i] != array2[i]) {
// Warning - two different object instances will never be equal: {x:20} != {x:20}
return false;
}
}
return true;
}
// public static ToDropDownOptions(array: any[], keyProperty: string | number, valueProperty: string | number): DropDownOption[] {
// var result = [];
// for (let i = 0; i < array.length; i++) {
// const element = array[i];
// result.push(new DropDownOption(element[keyProperty], element[valueProperty]));
// }
// return result;
// }
public static GroupBy<T = any>(arr: Array<T>, groupKeyPrepareFunction: (obj: T) => any | any[]) {
let groups = arr.reduce(function (groupModel, obj) {
let keys = groupKeyPrepareFunction(obj);
const addToGroup = (key: any, obj: T) => {
let group = groupModel.find(g => g.key == key);
if (group == null) {
group = new GroupModel(key);
groupModel.push(group);
}
group.values.push(obj);
}
if (Array.isArray(keys)) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
addToGroup(key, obj);
}
} else {
addToGroup(keys, obj);
}
return groupModel;
}, [] as GroupModel<T>[]);
return groups;
}
public static RemoveDuplicate<T>(objArray: T[], duplicateDetection: (a: T, b: T) => boolean) {
return objArray.filter((value, index, self) =>
index === self.findIndex((t) => duplicateDetection(t, value))
);
}
public static insertAt = (arr: any[], index: number, newItems: any) => [
// part of the array before the specified index
...arr.slice(0, index),
// inserted items
newItems,
// part of the array after the specified index
...arr.slice(index)
];
}
export class GroupModel<T = any> {
constructor(key: any) {
this.key = key;
this.values = [];
}
key: any;
values: T[];
}
+190
View File
@@ -0,0 +1,190 @@
export class DateUtils {
// public static getIntervalDays(from: Date, to: Date, daysOfYear: DaysOfYear = DaysOfYear.ThirtyDaysPerMonth, countEndDate: boolean = false): number {
// let isNegative = false;
// if (from > to) {
// isNegative = true;
// let temp = new Date(to);
// to = from;
// from = temp;
// }
// var days = 0;
// if (from && to) {
// //Get date without time
// from = new Date(from.getFullYear(), from.getMonth(), from.getDate());
// to = new Date(to.getFullYear(), to.getMonth(), to.getDate());
// var differenceTime = to.getTime() - from.getTime();
// if (differenceTime > 0) {
// if (daysOfYear == DaysOfYear.ThirtyDaysPerMonth) {
// var fromYear = from.getFullYear();
// var toYear = to.getFullYear();
// var fromMonth = from.getMonth() + 1;
// var toMonth = to.getMonth() + 1;
// var fromDays = from.getDate() > 30 ? 30 : from.getDate();
// var toDays = to.getDate();
// days += 30 - fromDays;
// days += toDays;
// //calculate full 12 months years
// if (toYear > (fromYear + 1)) {
// days += (toYear - fromYear - 1) * 12 * 30;
// }
// //if it's two different years, calculate the interval months
// if (toYear > fromYear) {
// days += (12 - fromMonth) * 30;
// days += (toMonth - 1) * 30;
// }
// else {
// //same year
// days += (toMonth - fromMonth - 1) * 30
// }
// }
// else {
// // To calculate the no. of days between two dates
// days = Math.round(differenceTime / (1000 * 3600 * 24));
// }
// }
// }
// return (days + (countEndDate ? 1 : 0)) * (isNegative ? -1 : 1);
// }
public static addDays(date: Date, days: number): Date {
if (date) {
var result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
return date;
}
public static format(date: Date, format: string = 'MM/dd/yyyy hh:mm:ss', nullFormat: string = ''): string {
if (date) {
var z = {
M: date.getMonth() + 1,
d: date.getDate(),
H: date.getHours(),
h: (date.getHours() == 0 ? date.getHours() + 12 : date.getHours() > 12 ? date.getHours() - 12 : date.getHours()),
m: date.getMinutes(),
s: date.getSeconds(),
a: (date.getHours() > 11 ? 'PM' : 'AM')
};
format = format.replace(/(M+|d+|H+|h+|m+|s+|a+)/g, function (v) {
return ((v.length > 1 ? "0" : "") + z[v.slice(-1) as keyof typeof z]).slice(-2);
});
return format.replace(/(y+)/g, function (v) {
return date.getFullYear().toString().slice(-v.length)
});
}
return nullFormat;
}
public static isValidDate(d: Date): boolean {
return d instanceof Date && d.getTime() == d.getTime();
}
public static parse(value: string | Date | null | undefined, changeToLocalTime = false): Date | null {
if (value) {
if (typeof value === 'string' && value.includes('-')) {
value = this.parseLocalDate(value);
return value;
}
value = new Date(value);
if (changeToLocalTime) {
//todo: change to local time from UTC
}
} else {
return null;
}
return value
}
public static parseLocalDate(localDate: string): Date {
const [year, month, day] = localDate.split('-').map(Number);
return new Date(year, month, day);
}
public static toLocalDate(date: Date): string {
return this.format(date, 'yyyy-MM-dd');
}
public static getToday(endOfDay: boolean = false): Date {
let value = new Date();
if (!endOfDay) {
value.setHours(0, 0, 0, 0);
}
else {
value.setHours(23, 59, 59, 999);
}
return value
}
public static getBeginOfDate(value: Date): Date {
if (value) {
value = new Date(value);
value.setHours(0, 0, 0, 0);
}
return value
}
public static getEndOfDate(value: Date): Date {
if (value) {
value = new Date(value);
value.setHours(23, 59, 59, 999);
}
return value
}
public static getEndOfMonth(value: Date): Date {
if (value) {
return new Date(value.getFullYear(), value.getMonth() + 1, 0)
}
return value;
}
public static getFirstDayOfCurrentMonth = (): Date => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
};
public static getLastDayOfCurrentMonth = (): Date => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() + 1, 0);
};
public static isSameDate(date: Date, comparison: Date): boolean {
if (!date || !comparison) return (!date && !comparison);
date = this.parse(date, false) as Date;
comparison = this.parse(comparison, false) as Date;
return date.getFullYear() == comparison.getFullYear() && date.getMonth() == comparison.getMonth() && date.getDate() == comparison.getDate();
}
public static getTimeStamp() {
var now = new Date();
return ((now.getMonth() + 1) + '/' + (now.getDate()) + '/' + now.getFullYear() + " " + now.getHours() + ':'
+ ((now.getMinutes() < 10) ? ("0" + now.getMinutes()) : (now.getMinutes())) + ':' + ((now.getSeconds() < 10) ? ("0" + now
.getSeconds()) : (now.getSeconds())));
}
public static getDatesBetween(startDate: Date, endDate: Date): Date[] {
startDate = this.getBeginOfDate(startDate);
endDate = this.getBeginOfDate(endDate);
let result = [startDate];
if (startDate < endDate) {
let tempDate = new Date(startDate);
while (tempDate < endDate) {
tempDate = this.addDays(tempDate, 1);
result.push(tempDate);
}
}
return result;
}
/**
* Returns the stored time value in milliseconds since midnight, January 1, 1970 UTC., return 0 if date is null
*/
public static getTime(date?: Date): number {
return date != null ? date.getTime() : 0;
}
}
@@ -0,0 +1,18 @@
export class EnumUtils {
public static hasFlag(obj: number, enumValue: number): boolean {
if (obj) {
if (obj & enumValue) {
return true;
}
}
return false;
}
public static GetAllEnumValue(enumType: any): number[] {
return Object
.keys(enumType)
.filter((v) => !isNaN(Number(v)))
.map(v => Number.parseInt(v));
}
}
@@ -0,0 +1,26 @@
export class FileUtils {
public static formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} Bytes`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
}
public static getFileName(fileFullPath: string): string | null {
const lastSlashIndex = fileFullPath.lastIndexOf('\\');
if (lastSlashIndex === -1 || lastSlashIndex === 0 || lastSlashIndex === fileFullPath.length - 1) {
return fileFullPath; // No folder found
}
return fileFullPath.slice(lastSlashIndex + 1);
}
public static getFileExt(filename: string): string | null {
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1 || lastDotIndex === 0 || lastDotIndex === filename.length - 1) {
return null; // No extension found
}
return filename.slice(lastDotIndex);
}
}
@@ -0,0 +1,20 @@
import { Observable } from "rxjs";
export class HtmlElementUtils {
public static findChildByClassName<T = Element>(children: HTMLCollection, className: string): T {
let childrenNodeArr = Array.from(children);
for (let i = 0; i < childrenNodeArr.length; i++) {
const element = childrenNodeArr[i];
if (element.classList.contains(className)) {
return element as T;
} else if (element.children) {
let child = this.findChildByClassName(element.children, className)
if (child) return child as T;
}
}
return null as T;
}
}
@@ -0,0 +1,9 @@
export class LinqUtils {
public static GroupBy(xs: any[], key: any) {
return xs.reduce(function (rv, x) {
(rv[x[key]] = rv[x[key]] || []).push(x);
return rv;
}, {});
}
}
@@ -0,0 +1,84 @@
import { formatCurrency } from "@angular/common";
const PPI = 96;
export class NumberUtils {
public static Ordinal(value: number): string {
let suffix = '';
const last = value % 10;
const specialLast = value % 100;
if (!value || value < 1) {
return value.toString();
}
if (last === 1 && specialLast !== 11) {
suffix = 'st';
} else if (last === 2 && specialLast !== 12) {
suffix = 'nd';
} else if (last === 3 && specialLast !== 13) {
suffix = 'rd';
} else {
suffix = 'th';
}
return value + suffix;
}
public static Clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
public static SortFunction(a: number, b: number): number {
return a - b;
}
public static Sum(array: number[]): number {
return array.reduce((a, b) => (isNaN(a) ? 0 : a) + (isNaN(b) ? 0 : b), 0);
}
public static FormatCurrency(v: number, zeroExpression: string = '0'): string {
return ['', 0, null, undefined, NaN].includes(v) ? zeroExpression : formatCurrency(v, "en", "", "", `0.2`);
}
public static Round(num: number, precision: number) {
const factor = 10 ** precision;
return Math.round(num * factor) / factor;
}
public static RoundCurrency(num: number): number {
return this.Round(num, 2);
}
public static VersionDiff(v1: string, v2: string) {
let versionDefine = [
{ index: 0, name: 'major' },
{ index: 1, name: 'minor' },
{ index: 2, name: 'patch' },
{ index: 3, name: 'build' }
]
if (v1 && v2) {
let v1Versions = v1.split('.');
let v2Versions = v2.split('.');
for (let i = 0; i < v1Versions.length; i++) {
if (v2Versions.length == i) return versionDefine[i];
if (v1Versions[i] != v2Versions[i]) return versionDefine[i];
}
}
return null;
}
public static Average(numArr: number[]) {
return numArr.reduce((a, b) => a + b) / numArr.length;
}
public static PixelToInch(pixel: number) {
return this.Round(pixel / PPI, 2);
}
public static InchToPixel(inch: number) {
return Math.round(inch * PPI);
}
public static Mid(a: number, b: number) {
return a + Math.floor((b - a) / 2);
}
}
@@ -0,0 +1,217 @@
import { Observable } from "rxjs";
const dateAndTimeRegex = new RegExp(/^(?<Date>\d{4}-\d{2}-\d{2})T(?<HourMin>\d{2}:\d{2}):((?<SecondAndMillisecond>\d{2}\.\d{0,6})|(?<Second>\d{2}))$/);
export class ObjectUtils {
private static ReviveDateTime(key: any, value: any): any {
if (typeof value === 'string') {
if (dateAndTimeRegex.test(value)) {
let newDate = new Date(value);
return newDate;
}
}
return value;
}
public static HasAnyData(obj: any, excludes: string[] = []) {
var hasData = false;
for (const p in obj) {
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(obj, p)) {
const element = obj[p];
if (element) {
if (typeof element !== 'object' || this.HasAnyData(element, excludes)) {
hasData = true;
break;
}
}
}
}
return hasData;
}
public static Clone<T = any>(obj: T, avoidCirculateRef = false): T {
if (avoidCirculateRef) {
return JSON.parse(this.stringify(obj), ObjectUtils.ReviveDateTime);
}
return JSON.parse(JSON.stringify(obj), ObjectUtils.ReviveDateTime);
}
public static CopyValue(source: any, destination: any, excludes: string[] = ["id"], overwriting: boolean = true) {
for (const p in source) {
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(source, p)) {
const element = source[p];
if (element && Array.isArray(element)) {
if ([null, undefined].includes(destination[p])) {
destination[p] = [];
for (let i = 0; i < element.length; i++) {
const cloneItem = {};
this.CopyValue(element[i], cloneItem, excludes, true);
destination[p].push(cloneItem);
}
} else if (Array.isArray(destination[p])) {
for (let i = 0; i < element.length; i++) {
const item = element[i];
let destLength = destination[p].length;
if (i >= destLength) {
destination[p].push(this.Clone(source[p][i]));
}
this.CopyValue(item, destination[p][i], excludes, overwriting);
}
}
}
else if (element && typeof element.getMonth === 'function') {
//For angular will treat Date as object
try {
destination[p] = element;
} catch (error) {
console.log(`can\'t copy ${p}`, error);
}
}
else if (element && typeof element == 'object') {
let objectOverwriting = overwriting;
if ([null, undefined].includes(destination[p])) {
destination[p] = {};
objectOverwriting = true;
}
try {
this.CopyValue(element, destination[p], excludes, objectOverwriting);
} catch (error) {
console.log(`can\'t copy ${p}`, error);
}
} else if (overwriting || [null, '', undefined].includes(destination[p])) {
try {
destination[p] = element;
} catch (error) {
console.log(`can\'t copy ${p}`, error);
}
}
}
}
}
public static ClearPkAndFk(source: any, excludes: string[] = [], clearExtra: string[] = []) {
for (const p in source) {
const element = source[p];
if (element) {
if (clearExtra.includes(p)) {
source[p] = null;
} else if (typeof element == 'object') {
this.ClearPkAndFk(element, excludes, clearExtra);
} else if (
(typeof element == 'string' && (p == 'id' || p.indexOf('Id') > -1) &&
false == excludes.includes(p))
) {
source[p] = null;
}
}
}
}
public static ClearValue(source: any, excludes: string[] = ["id"]) {
for (const p in source) {
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(source, p)) {
const element = source[p];
if (element) {
if (typeof element == 'object') {
this.ClearValue(element, excludes);
} else if (typeof element == 'number') {
source[p] = 0;
} else {
source[p] = null;
}
}
}
}
}
/**
* return `true` if the comparison has any different value with source.
*/
public static CompareDiffValue(source: any, comparison: any, excludes: string[] = ["id"]) {
let isDifferent = false;
for (const p in source) {
if (false == excludes.includes(p) &&
Object.prototype.hasOwnProperty.call(source, p)) {
const element = source[p];
if (element && Array.isArray(element)) {
if (Array.isArray(comparison[p])) {
for (let i = 0; i < element.length; i++) {
const item = element[i];
let destLength = comparison[p].length;
if (i >= destLength) {
return true;
}
isDifferent = this.CompareDiffValue(item, comparison[p][i], excludes);
if (isDifferent) return true;
}
}
}
else if (element && typeof element.getMonth === 'function') {
//For angular will treat Date as object
//TODO:Compare Date
//comparison[p] = element;
}
else if (element && typeof element == 'object') {
isDifferent = this.CompareDiffValue(element, comparison[p], excludes);
} else {
isDifferent = comparison[p] != element;
}
if (isDifferent) return true;
}
}
return false;
}
/**
* return `true` if the comparison has any different value with source.
*/
public static CompareDiffArrayContent(source: any[], comparison: any[], excludes: string[] = ["id"]) {
let isDifferent = false;
if (source && comparison) {
if (source.length == comparison.length) {
for (let i = 0; i < source.length; i++) {
const sourceItem = source[i];
const comparisonItem = comparison[i];
isDifferent = this.CompareDiffValue(sourceItem, comparisonItem);
if (isDifferent) return true;
}
return false;
}
}
return true;
}
public static isNullOrUndefined(obj: any) {
return [null, undefined].includes(obj);
}
public static isObservable<T>(template: T | Observable<T>): template is Observable<T> {
return (template as Observable<T>).subscribe !== undefined;
}
public static stringify(obj: any): string {
let cache: any[] = [];
let str = JSON.stringify(obj, function (key, value) {
if (typeof value === "object" && value !== null) {
if (cache.indexOf(value) !== -1) {
// Circular reference found, discard key
return;
}
// Store value in our collection
cache.push(value);
}
return value;
});
cache = []; // reset the cache
return str;
}
}
@@ -0,0 +1,204 @@
import { AddressInfo } from "../models";
export class StringUtils {
static compare(aval: string, bval: string) {
return aval ? aval.localeCompare(bval) : -1;
}
// Sorting function for SemVer
// Returns 1 if a is greater, -1 if b is greater, 0 if equal
public static compareSemVer(a: string, b: string): number {
if (a === b) {
return 0;
}
var a_components = a.split(".");
var b_components = b.split(".");
var len = Math.min(a_components.length, b_components.length);
// loop while the components are equal
for (var i = 0; i < len; i++) {
// A bigger than B
if (parseInt(a_components[i]) > parseInt(b_components[i])) {
return 1;
}
// B bigger than A
if (parseInt(a_components[i]) < parseInt(b_components[i])) {
return -1;
}
}
// If one's a prefix of the other, the longer one is greater.
if (a_components.length > b_components.length) {
return 1;
}
if (a_components.length < b_components.length) {
return -1;
}
// Otherwise they are the same.
return 0;
}
public static isNullOrWhitespace(input: string): boolean {
return !input || !input.trim();
}
public static getTrimmedValue(input: string, emptyFormat: string = ''): string {
if (input) {
input = input.trim();
}
if (input) return input;
return emptyFormat;
}
public static removeNewLines(input: string): string {
if (input) {
input = input.replace(/[\r\n]+/g, '');
}
return input;
}
public static firstIsVowel(s: string): boolean {
if (s) {
return ['a', 'e', 'i', 'o', 'u'].indexOf(s[0].toLowerCase()) !== -1
}
return false;
}
public static makeCommaSeparatedString(arr: string[], useOxfordComma: boolean,
conjunctionWord: string = "and") {
arr = arr.filter(s => s);
const listStart = arr.slice(0, -1).join(', ');
const listEnd = arr.slice(-1);
const conjunction =
arr.length <= 1
? ""
: useOxfordComma && arr.length > 2
? `, ${conjunctionWord} `
: ` ${conjunctionWord} `;
return [listStart, listEnd].join(conjunction);
}
public static getFormattedTerm(input: string, emptyFormatter: string = 'Empty'): string {
if (false == this.isNullOrWhitespace(input)) {
return input.trim();
}
return emptyFormatter;
}
public static camelToTitle(camelCase: string): string {
// no side-effects
return camelCase
// inject space before the upper case letters
.replace(/([A-Z])/g, function (match) {
return " " + match;
})
// replace first char with upper case
.replace(/^./, function (match) {
return match.toUpperCase();
}).trim();
}
public static getCapitalLetters(str: string) {
return str.replace(/[^A-Z]+/g, "");
}
/**
* status could be `info` , `primary` , `danger` , `warning` , `success`
*/
public static getHtmlBadge(text: string, badgeStatus: string, mergingClass = 'mr-1') {
return `<label class="badge badge-${badgeStatus} ${mergingClass} my-0 py-1">${text}</label>`;
}
public static getHtmlInnerText(htmlText: string) {
return htmlText ? htmlText.replace(/<[^>]+>/g, '') : '';
}
/**
* Try to parse city sate zip string like `Monrovia, CA 91016` to AddressInfo, if failed will return `null` instead.
*/
public static tryParseCityStateZip(cityStateZip: string): AddressInfo {
let addressInfo = {} as AddressInfo;
var regex = /([\w\s]*),\s*([A-Z]{2})\s*(\d*-?\d*)/g;
var match = regex.exec(cityStateZip);
if (match) {
addressInfo = {
city: match[1],
state: match[2],
zip: match[3]
} as AddressInfo;
return addressInfo;
} else {
return {} as AddressInfo;
}
}
public static getCityStateZipString(addressInfo: AddressInfo): string {
let result = '';
if (false == this.isNullOrWhitespace(addressInfo.city)) {
result += `${addressInfo.city}, `;
}
if (false == this.isNullOrWhitespace(addressInfo.state)) {
result += `${addressInfo.state} `;
}
if (false == this.isNullOrWhitespace(addressInfo.zip)) {
result += addressInfo.zip;
}
return result;
}
public static getFullAddressString(addressInfo: AddressInfo): string {
if (addressInfo && StringUtils.isNullOrWhitespace(addressInfo.address)) {
return 'No Subject Property Entered';
}
else {
return `${addressInfo.address}, ${this.getCityStateZipString(addressInfo)}`
}
}
public static toUpperString(prop: any) {
if (prop) {
if (typeof prop === 'string') return prop.toUpperCase();
return prop.toString().toUpperCase();
} else {
return '';
}
}
public static toLowerString(prop: any) {
if (prop) {
if (typeof prop === 'string') return prop.toLowerCase();
return prop.toString().toLowerCase();
} else {
return '';
}
}
public static truncateString(str: string, maxLength: number) {
if (str && str.length > maxLength) {
return str.slice(0, maxLength);
} else {
return str;
}
}
public static insertAt(str: string, insertValue: string, index: number) {
return [str.slice(0, index), insertValue, str.slice(index)].join('');
}
public static replaceAll(str: string, find: string, replace: string) {
return str.replace(new RegExp(find, 'g'), replace);
}
public static safeLocaleCompare(a: string, b: string, sortDirection: string = 'asc') {
if (sortDirection == 'asc' && a)
return b ? a.localeCompare(b) : -1;
else if (b)
return a ? b.localeCompare(a) : 1;
else return 0;
}
}
@@ -0,0 +1,49 @@
export class TextareaUtils {
public static autoExpand(field: HTMLTextAreaElement) {
//
if (field) {
// Reset field height
field.style.height = '0px';
const computed = window.getComputedStyle(field);
// Calculate the height
var height = 0
+ parseInt(computed.getPropertyValue('border-top-width'), 10)
+ field.scrollHeight
+ parseInt(computed.getPropertyValue('border-bottom-width'), 10);
field.style.height = height + 'px';
}
}
public static isEditingKeyPress(e: KeyboardEvent) {
if ([46, 8, 9, 27, 13, 190].indexOf(e.keyCode) !== -1 ||
// Allow: Ctrl+A
(e.keyCode === 65 && (e.ctrlKey || e.metaKey)) ||
// Allow: Ctrl+C
(e.keyCode === 67 && (e.ctrlKey || e.metaKey)) ||
// Allow: Ctrl+V
(e.keyCode === 86 && (e.ctrlKey || e.metaKey)) ||
// Allow: Ctrl+X
(e.keyCode === 88 && (e.ctrlKey || e.metaKey)) ||
// Allow: page up, page down, home, end, left, right
(e.keyCode >= 33 && e.keyCode <= 39)) {
// let it happen, don't do anything
return false;
}
if (typeof e.which == "undefined") {
// This is IE, which only fires keypress events for printable keys
return true;
} else if (e.ctrlKey && e.key.toUpperCase() == 'V') {
return true;
}
else if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code != 'Tab' && e.key != 'Control') {
return true;
}
return false;
}
}
@@ -0,0 +1,50 @@
export class TimerUtils {
// private static debounceTimers: DebounceTimer[];
// private static addDebounceTimer(key: string, debounceTime: number, callback: Function) {
// if (!this.debounceTimers) {
// this.debounceTimers = [];
// }
// let timerProfile = this.debounceTimers.find(t => t.key == key);
// if (timerProfile) {
// clearTimeout(timerProfile.timer);
// } else {
// timerProfile = new DebounceTimer(){
// }
// }
// }
}
export class DebounceTimer {
constructor(
debounceTime: number,
callback: Function
) {
//this.key = key
this.debounceTime = debounceTime;
this.callback = callback;
//this.resetTimer();
}
debounceTime: number;
timer: any;
callback: Function;
resetTimer() {
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.callback();
this.timer = null;
}, this.debounceTime);
}
clearOut() {
if (this.timer) {
clearTimeout(this.timer);
}
}
}
@@ -0,0 +1,34 @@
const GUID_EMPTY = '00000000-0000-0000-0000-000000000000';
export class UuidUtils {
public static generate() {
var d = new Date().getTime();//Timestamp
var d2 = (performance && performance.now && (performance.now() * 1000)) || 0;//Time in microseconds since page-load or 0 if unsupported
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16;//random number between 0 and 16
if (d > 0) {//Use timestamp until depleted
r = (d + r) % 16 | 0;
d = Math.floor(d / 16);
} else {//Use microseconds since page-load if supported
r = (d2 + r) % 16 | 0;
d2 = Math.floor(d2 / 16);
}
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
public static stringToUuid = (str: string) => {
str = str.replace('-', '');
let index = -1;
return 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.replace(/[x]/g, function (c, p) {
index++;
return str[index];
});
}
public static empty() {
return GUID_EMPTY;
}
public static isNullOrEmpty(uuid: string) {
return !uuid || [GUID_EMPTY, undefined, null].includes(uuid);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,5 @@
<svg width="48" height="20" viewBox="0 0 48 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.9616 19.9991H12.9766V18.1129H15.3933V11.1574H4.75378V18.1129H7.17052V19.9991H0.185547V18.1129H2.60229V2.72828H0.185547V0.842041H7.17052V2.72828H4.75378V9.33011H15.3933V2.72828H12.9766V0.842041H19.9616V2.72828H17.5448V18.1129H19.9616V19.9991Z" fill="black"/>
<path d="M36.5842 2.72901H34.079V18.1136H36.4663V19.9999H29.6582V18.1136H31.8981V5.91204H31.7802L25.3552 19.0273H24.2058L17.6334 5.82362H17.5155V18.1136H19.8144V19.9999H13.0357V18.1136H15.4524V2.72901H12.9473V0.842773H17.3387L24.7952 15.8443H24.9131L32.1633 0.842773H36.5842V2.72901Z" fill="black"/>
<path d="M43.433 4.21094V9.02796H47.9977V11.1727H43.433V16.0126H41.0818V11.1727H36.54V9.02796H41.0818V4.21094H43.433Z" fill="#E71B0D"/>
</svg>

After

Width:  |  Height:  |  Size: 810 B

@@ -0,0 +1,20 @@
<svg width="162" height="16" viewBox="0 0 162 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M158.84 4.7998V7.7071H161.595V9.00154H158.84V11.9227H157.421V9.00154H154.68V7.7071H157.421V4.7998H158.84Z" fill="#E71B0D"/>
<path d="M11.7849 12.0834H7.62239V10.9594H9.06257V6.81449H2.72228V10.9594H4.16246V12.0834H0V10.9594H1.44018V1.79144H0V0.667399H4.16246V1.79144H2.72228V5.72558H9.06257V1.79144H7.62239V0.667399H11.7849V1.79144H10.3447V10.9594H11.7849V12.0834Z" fill="black"/>
<path d="M19.7488 9.83535C19.6434 10.5027 19.3097 11.0765 18.7477 11.5565C18.1856 12.0249 17.4246 12.2591 16.4645 12.2591C15.3638 12.2591 14.474 11.9137 13.7949 11.2228C13.1158 10.532 12.7762 9.55434 12.7762 8.28979C12.7762 7.07208 13.0982 6.07098 13.7422 5.2865C14.3862 4.50201 15.2585 4.10977 16.3591 4.10977C17.4363 4.10977 18.2559 4.44932 18.8179 5.12843C19.3799 5.80754 19.6668 6.60959 19.6785 7.53458C19.6785 7.839 19.6434 8.14929 19.5731 8.46542H14.0759C14.1461 10.292 14.9716 11.2053 16.5523 11.2053C17.1728 11.2053 17.6588 11.0589 18.01 10.7662C18.3613 10.4735 18.5955 10.0871 18.7125 9.60703L19.7488 9.83535ZM18.3788 7.55214C18.4374 6.9667 18.2969 6.41639 17.9573 5.90121C17.6295 5.38602 17.085 5.12843 16.324 5.12843C15.598 5.12843 15.0653 5.36846 14.7257 5.84852C14.3862 6.32858 14.1754 6.89645 14.0934 7.55214H18.3788Z" fill="black"/>
<path d="M21.7813 4.86498C22.1911 4.59568 22.6243 4.40249 23.081 4.2854C23.5376 4.16831 24.0235 4.10977 24.5387 4.10977C26.4004 4.10977 27.3312 4.98207 27.3312 6.72667V10.4501C27.3312 11.0121 27.5537 11.2931 27.9986 11.2931C28.1977 11.2931 28.3733 11.258 28.5255 11.1877L28.5431 11.9956C28.227 12.1712 27.8874 12.2591 27.5244 12.2591C26.5994 12.2591 26.1252 11.7322 26.1018 10.6784V10.6433C25.8559 11.0648 25.5047 11.4394 25.048 11.7673C24.6031 12.0951 24.0645 12.2591 23.4322 12.2591C22.917 12.2591 22.3901 12.101 21.8515 11.7849C21.3246 11.4687 21.0612 10.895 21.0612 10.0637C21.0612 9.08013 21.4476 8.43615 22.2204 8.13172C23.0049 7.81559 23.8362 7.65752 24.7143 7.65752C24.9485 7.65752 25.1827 7.66337 25.4169 7.67508C25.6627 7.68679 25.8911 7.70436 26.1018 7.72777V7.02525C26.1018 6.46323 25.9789 6.00659 25.733 5.65532C25.4988 5.30406 25.0305 5.12843 24.3279 5.12843C23.7542 5.12843 23.2507 5.23381 22.8175 5.44457L22.5189 6.79693L21.5003 6.67399L21.7813 4.86498ZM26.1018 9.15038V8.55324C25.8793 8.54153 25.6452 8.52397 25.3993 8.50055C25.1534 8.47713 24.9017 8.46542 24.6441 8.46542C24.0586 8.46542 23.5259 8.55909 23.0458 8.74643C22.5775 8.92206 22.3433 9.31431 22.3433 9.92316C22.3433 10.4266 22.5014 10.772 22.8175 10.9594C23.1336 11.1467 23.4615 11.2404 23.801 11.2404C24.3396 11.2404 24.8431 11.0531 25.3115 10.6784C25.7915 10.3037 26.055 9.79437 26.1018 9.15038Z" fill="black"/>
<path d="M33.1692 12.0834H29.1999V11.0823H30.5698V1.0011H29.1824V0H31.7993V11.0823H33.1692V12.0834Z" fill="black"/>
<path d="M39.942 9.97585C39.7312 11.498 38.9057 12.2591 37.4656 12.2591C36.6811 12.2591 36.0781 12.0366 35.6566 11.5917C35.2468 11.1467 35.0419 10.5437 35.0419 9.78266V5.25137H33.7773V4.26784H35.0419V1.75631H36.2713V4.26784H39.3273V5.25137H36.2713V9.62459C36.2713 10.0929 36.3708 10.4735 36.5698 10.7662C36.7806 11.0472 37.1202 11.1877 37.5885 11.1877C37.8929 11.1877 38.1739 11.094 38.4315 10.9067C38.7008 10.7194 38.894 10.3096 39.0111 9.67728L39.942 9.97585Z" fill="black"/>
<path d="M49.7087 12.0834H45.7394V11.0823H47.1094V7.44676C47.1094 6.60373 46.9337 6.01244 46.5825 5.67289C46.2429 5.32162 45.8038 5.14599 45.2652 5.14599C44.5861 5.14599 44.03 5.38017 43.5967 5.84852C43.1752 6.30516 42.9528 6.82035 42.9293 7.39407V11.0823H44.2817V12.0834H40.33V11.0823H41.6824V1.0011H40.33V0H42.9293V5.67289C43.5031 4.63081 44.4163 4.10977 45.6692 4.10977C46.4303 4.10977 47.0684 4.34394 47.5836 4.81229C48.0988 5.28064 48.3563 6.03586 48.3563 7.07794V11.0823H49.7087V12.0834Z" fill="black"/>
<path d="M57.3762 9.44896C57.2709 10.1281 56.9606 10.7662 56.4454 11.3633C55.9419 11.9605 55.1516 12.2591 54.0744 12.2591C52.9972 12.2591 52.1366 11.9137 51.4926 11.2228C50.8486 10.5203 50.5266 9.54263 50.5266 8.28979C50.5266 7.21259 50.831 6.24662 51.4399 5.39188C52.0488 4.53714 52.962 4.10977 54.1798 4.10977C54.6949 4.10977 55.1691 4.16831 55.6024 4.2854C56.0356 4.40249 56.4512 4.61325 56.8493 4.91767L57.1479 6.79693L56.1293 6.91987L55.8307 5.60263C55.3623 5.30992 54.8062 5.16356 54.1622 5.16356C53.3309 5.16356 52.7337 5.46213 52.3707 6.05928C52.0078 6.65642 51.8263 7.37651 51.8263 8.21954C51.8263 9.15624 52.0312 9.88804 52.441 10.4149C52.8508 10.9418 53.4421 11.2053 54.2149 11.2053C55.3857 11.2053 56.0883 10.5437 56.3225 9.22064L57.3762 9.44896Z" fill="black"/>
<path d="M59.3945 4.86498C59.8043 4.59568 60.2375 4.40249 60.6942 4.2854C61.1508 4.16831 61.6367 4.10977 62.1519 4.10977C64.0136 4.10977 64.9444 4.98207 64.9444 6.72667V10.4501C64.9444 11.0121 65.1669 11.2931 65.6118 11.2931C65.8109 11.2931 65.9865 11.258 66.1387 11.1877L66.1563 11.9956C65.8402 12.1712 65.5006 12.2591 65.1376 12.2591C64.2126 12.2591 63.7384 11.7322 63.715 10.6784V10.6433C63.4691 11.0648 63.1179 11.4394 62.6612 11.7673C62.2163 12.0951 61.6777 12.2591 61.0454 12.2591C60.5302 12.2591 60.0033 12.101 59.4647 11.7849C58.9378 11.4687 58.6744 10.895 58.6744 10.0637C58.6744 9.08013 59.0608 8.43615 59.8336 8.13172C60.618 7.81559 61.4494 7.65752 62.3275 7.65752C62.5617 7.65752 62.7959 7.66337 63.0301 7.67508C63.2759 7.68679 63.5043 7.70436 63.715 7.72777V7.02525C63.715 6.46323 63.5921 6.00659 63.3462 5.65532C63.112 5.30406 62.6437 5.12843 61.9411 5.12843C61.3674 5.12843 60.8639 5.23381 60.4307 5.44457L60.1321 6.79693L59.1135 6.67399L59.3945 4.86498ZM63.715 9.15038V8.55324C63.4925 8.54153 63.2584 8.52397 63.0125 8.50055C62.7666 8.47713 62.5149 8.46542 62.2573 8.46542C61.6718 8.46542 61.1391 8.55909 60.659 8.74643C60.1907 8.92206 59.9565 9.31431 59.9565 9.92316C59.9565 10.4266 60.1146 10.772 60.4307 10.9594C60.7468 11.1467 61.0747 11.2404 61.4142 11.2404C61.9528 11.2404 62.4563 11.0531 62.9247 10.6784C63.4047 10.3037 63.6682 9.79437 63.715 9.15038Z" fill="black"/>
<path d="M71.4498 4.12733C72.0118 4.12733 72.4567 4.26198 72.7846 4.53128L73.0832 6.49835L72.0821 6.6213L71.7835 5.2865C71.5844 5.21625 71.3854 5.18112 71.1863 5.18112C70.7297 5.18112 70.3492 5.37431 70.0447 5.7607C69.7403 6.13538 69.5881 6.62715 69.5881 7.23601V11.0823H71.3971V12.0834H66.9887V11.0823H68.3587V5.26894H66.9536V4.26784H69.5178V5.67289C69.6701 5.23966 69.9159 4.87669 70.2555 4.58397C70.6068 4.27955 71.0049 4.12733 71.4498 4.12733Z" fill="black"/>
<path d="M81.477 9.83535C81.3716 10.5027 81.0379 11.0765 80.4759 11.5565C79.9138 12.0249 79.1528 12.2591 78.1927 12.2591C77.092 12.2591 76.2022 11.9137 75.5231 11.2228C74.8439 10.532 74.5044 9.55434 74.5044 8.28979C74.5044 7.07208 74.8264 6.07098 75.4704 5.2865C76.1143 4.50201 76.9866 4.10977 78.0873 4.10977C79.1645 4.10977 79.9841 4.44932 80.5461 5.12843C81.1081 5.80754 81.395 6.60959 81.4067 7.53458C81.4067 7.839 81.3716 8.14929 81.3013 8.46542H75.8041C75.8743 10.292 76.6998 11.2053 78.2805 11.2053C78.901 11.2053 79.3869 11.0589 79.7382 10.7662C80.0895 10.4735 80.3236 10.0871 80.4407 9.60703L81.477 9.83535ZM80.107 7.55214C80.1656 6.9667 80.0251 6.41639 79.6855 5.90121C79.3577 5.38602 78.8132 5.12843 78.0521 5.12843C77.3262 5.12843 76.7935 5.36846 76.4539 5.84852C76.1143 6.32858 75.9036 6.89645 75.8216 7.55214H80.107Z" fill="black"/>
<path d="M100.854 1.79144H99.3613V10.9594H100.784V12.0834H96.7268V10.9594H98.0616V3.68825H97.9914L94.1626 11.5038H93.4776L89.5611 3.63557H89.4908V10.9594H90.8607V12.0834H86.8212V10.9594H88.2614V1.79144H86.7685V0.667399H89.3854L93.8289 9.60703H93.8992L98.2197 0.667399H100.854V1.79144Z" fill="black"/>
<path d="M102.805 4.86498C103.215 4.59568 103.648 4.40249 104.105 4.2854C104.561 4.16831 105.047 4.10977 105.562 4.10977C107.424 4.10977 108.355 4.98207 108.355 6.72667V10.4501C108.355 11.0121 108.577 11.2931 109.022 11.2931C109.221 11.2931 109.397 11.258 109.549 11.1877L109.567 11.9956C109.251 12.1712 108.911 12.2591 108.548 12.2591C107.623 12.2591 107.149 11.7322 107.125 10.6784V10.6433C106.88 11.0648 106.528 11.4394 106.072 11.7673C105.627 12.0951 105.088 12.2591 104.456 12.2591C103.941 12.2591 103.414 12.101 102.875 11.7849C102.348 11.4687 102.085 10.895 102.085 10.0637C102.085 9.08013 102.471 8.43615 103.244 8.13172C104.028 7.81559 104.86 7.65752 105.738 7.65752C105.972 7.65752 106.206 7.66337 106.44 7.67508C106.686 7.68679 106.915 7.70436 107.125 7.72777V7.02525C107.125 6.46323 107.002 6.00659 106.757 5.65532C106.522 5.30406 106.054 5.12843 105.352 5.12843C104.778 5.12843 104.274 5.23381 103.841 5.44457L103.543 6.79693L102.524 6.67399L102.805 4.86498ZM107.125 9.15038V8.55324C106.903 8.54153 106.669 8.52397 106.423 8.50055C106.177 8.47713 105.925 8.46542 105.668 8.46542C105.082 8.46542 104.549 8.55909 104.069 8.74643C103.601 8.92206 103.367 9.31431 103.367 9.92316C103.367 10.4266 103.525 10.772 103.841 10.9594C104.157 11.1467 104.485 11.2404 104.825 11.2404C105.363 11.2404 105.867 11.0531 106.335 10.6784C106.815 10.3037 107.079 9.79437 107.125 9.15038Z" fill="black"/>
<path d="M119.778 12.0834H115.826V11.0823H117.179V7.44676C117.179 6.60373 117.009 6.01244 116.669 5.67289C116.33 5.32162 115.896 5.14599 115.37 5.14599C114.667 5.14599 114.099 5.38017 113.666 5.84852C113.244 6.30516 113.022 6.83205 112.998 7.4292V11.0823H114.368V12.0834H110.399V11.0823H111.769V5.26894H110.364V4.26784H112.998V5.65532C113.561 4.62495 114.474 4.10977 115.738 4.10977C116.499 4.10977 117.138 4.34394 117.653 4.81229C118.168 5.28064 118.426 6.03586 118.426 7.07794V11.0823H119.778V12.0834Z" fill="black"/>
<path d="M121.363 4.86498C121.773 4.59568 122.206 4.40249 122.662 4.2854C123.119 4.16831 123.605 4.10977 124.12 4.10977C125.982 4.10977 126.913 4.98207 126.913 6.72667V10.4501C126.913 11.0121 127.135 11.2931 127.58 11.2931C127.779 11.2931 127.955 11.258 128.107 11.1877L128.125 11.9956C127.808 12.1712 127.469 12.2591 127.106 12.2591C126.181 12.2591 125.707 11.7322 125.683 10.6784V10.6433C125.437 11.0648 125.086 11.4394 124.63 11.7673C124.185 12.0951 123.646 12.2591 123.014 12.2591C122.499 12.2591 121.972 12.101 121.433 11.7849C120.906 11.4687 120.643 10.895 120.643 10.0637C120.643 9.08013 121.029 8.43615 121.802 8.13172C122.586 7.81559 123.418 7.65752 124.296 7.65752C124.53 7.65752 124.764 7.66337 124.998 7.67508C125.244 7.68679 125.473 7.70436 125.683 7.72777V7.02525C125.683 6.46323 125.56 6.00659 125.314 5.65532C125.08 5.30406 124.612 5.12843 123.909 5.12843C123.336 5.12843 122.832 5.23381 122.399 5.44457L122.1 6.79693L121.082 6.67399L121.363 4.86498ZM125.683 9.15038V8.55324C125.461 8.54153 125.227 8.52397 124.981 8.50055C124.735 8.47713 124.483 8.46542 124.226 8.46542C123.64 8.46542 123.107 8.55909 122.627 8.74643C122.159 8.92206 121.925 9.31431 121.925 9.92316C121.925 10.4266 122.083 10.772 122.399 10.9594C122.715 11.1467 123.043 11.2404 123.383 11.2404C123.921 11.2404 124.425 11.0531 124.893 10.6784C125.373 10.3037 125.636 9.79437 125.683 9.15038Z" fill="black"/>
<path d="M137.756 5.26894H136.492V11.978C136.492 13.2075 136.152 14.1851 135.473 14.9111C134.806 15.637 133.863 16 132.645 16C131.826 16 131.088 15.8127 130.432 15.438C129.788 15.0633 129.361 14.4837 129.15 13.6992L130.134 13.2075C130.333 13.8163 130.678 14.2554 131.17 14.5247C131.662 14.8057 132.171 14.9462 132.698 14.9462C133.447 14.9462 134.056 14.6828 134.525 14.1559C135.005 13.6407 135.245 12.8972 135.245 11.9254V10.4852C135.01 10.9418 134.671 11.3399 134.226 11.6795C133.781 12.019 133.19 12.1888 132.452 12.1888C131.527 12.1888 130.737 11.861 130.081 11.2053C129.425 10.5496 129.098 9.60703 129.098 8.37761C129.098 7.33553 129.379 6.3637 129.941 5.46213C130.503 4.56056 131.387 4.10977 132.593 4.10977C133.834 4.10977 134.718 4.57227 135.245 5.49726V4.26784H137.756V5.26894ZM135.245 8.71131V7.35895C135.245 6.70326 135.016 6.17636 134.56 5.77827C134.115 5.36846 133.57 5.16356 132.926 5.16356C132.048 5.16356 131.41 5.47969 131.012 6.11197C130.626 6.74424 130.432 7.47018 130.432 8.28979C130.432 9.24991 130.649 9.96414 131.082 10.4325C131.527 10.9008 132.072 11.135 132.716 11.135C133.465 11.135 134.068 10.8774 134.525 10.3622C134.981 9.84706 135.221 9.29675 135.245 8.71131Z" fill="black"/>
<path d="M145.692 9.83535C145.587 10.5027 145.253 11.0765 144.691 11.5565C144.129 12.0249 143.368 12.2591 142.408 12.2591C141.307 12.2591 140.417 11.9137 139.738 11.2228C139.059 10.532 138.72 9.55434 138.72 8.28979C138.72 7.07208 139.042 6.07098 139.686 5.2865C140.33 4.50201 141.202 4.10977 142.302 4.10977C143.38 4.10977 144.199 4.44932 144.761 5.12843C145.323 5.80754 145.61 6.60959 145.622 7.53458C145.622 7.839 145.587 8.14929 145.516 8.46542H140.019C140.089 10.292 140.915 11.2053 142.496 11.2053C143.116 11.2053 143.602 11.0589 143.953 10.7662C144.305 10.4735 144.539 10.0871 144.656 9.60703L145.692 9.83535ZM144.322 7.55214C144.381 6.9667 144.24 6.41639 143.901 5.90121C143.573 5.38602 143.028 5.12843 142.267 5.12843C141.541 5.12843 141.009 5.36846 140.669 5.84852C140.33 6.32858 140.119 6.89645 140.037 7.55214H144.322Z" fill="black"/>
<path d="M151.29 4.12733C151.852 4.12733 152.297 4.26198 152.625 4.53128L152.923 6.49835L151.922 6.6213L151.624 5.2865C151.425 5.21625 151.226 5.18112 151.026 5.18112C150.57 5.18112 150.189 5.37431 149.885 5.7607C149.58 6.13538 149.428 6.62715 149.428 7.23601V11.0823H151.237V12.0834H146.829V11.0823H148.199V5.26894H146.794V4.26784H149.358V5.67289C149.51 5.23966 149.756 4.87669 150.096 4.58397C150.447 4.27955 150.845 4.12733 151.29 4.12733Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,61 @@
<form (ngSubmit)="confirm()" #inputForm="ngForm">
<nb-card class="alertCard">
<nb-card-body>
<div class="icon {{config.icon}}" *ngIf="config.icon" [ngSwitch]="config.icon">
<div class="icon-content" *ngSwitchCase="'question'">
?
</div>
<div class="icon-content" *ngSwitchCase="'warning'">
!
</div>
<div class="icon-content" *ngSwitchCase="'info'">
i
</div>
<div class="icon-content" *ngSwitchCase="'error'">
<nb-icon pack='eva' icon='close-outline'></nb-icon>
</div>
<div *ngSwitchDefault></div>
</div>
<div class="title" *ngIf="config.title" [innerHTML]="config.title">
</div>
<div class="content" *ngIf="config.text" [innerHTML]="config.text">
</div>
<div class="inputArea mt-2">
<div class="form-group" *ngIf="config.inputType" [ngSwitch]="config.inputType">
<textarea id='msgInputBox' name='inputBox' nbInput fullWidth [(ngModel)]="config.inputValue" rows="5"
[mask]="config.inputMask" required *ngSwitchCase="'textarea'"></textarea>
<input id='msgInputBox' name='inputBox' nbInput fullWidth [(ngModel)]="config.inputValue"
[mask]="config.inputMask" required *ngSwitchCase="'string'">
<input id='msgInputBox' name='inputBox' nbInput fullWidth [(ngModel)]="config.inputValue" type="number"
required *ngSwitchCase="'number'">
<input id='msgInputBox' name='inputBox' nbInput fullWidth [(ngModel)]="config.inputValue"
[mask]="config.inputMask" type="password" required *ngSwitchCase="'password'"
nbTooltip="{{config.inputTooltip}}" nbTooltipTrigger="noop" nbTooltipPlacement="top"
nbTooltipStatus="danger">
</div>
</div>
</nb-card-body>
<nb-card-footer *ngIf="config.showConfirmButton||config.showCancelButton||config.showCloseButton">
<button type="submit" #buttonSubmit [disabled]="!inputForm.form.valid" nbButton hero
class="px-4 g-font-size-16 btnOk" [ngClass]="{'rtl': rtl}" status="{{config.confirmButtonColor}}"
*ngIf="config.showConfirmButton" [initFocus]="config.confirmButtonFocus">
{{config.confirmButtonText}}
</button>
<button nbButton hero class="px-4 g-font-size-16 btnCancel" status="{{config.cancelButtonColor}}"
*ngIf="config.showCancelButton" [ngClass]="{'rtl': rtl}" type="button" (click)="cancel()"
[initFocus]="config.cancelButtonFocus">{{config.cancelButtonText}}</button>
<button nbButton hero class="px-4 g-font-size-16 btnClose" status="{{config.closeButtonColor}}" type="button"
[ngClass]="{'rtl': rtl}" *ngIf="config.showCloseButton" (click)="close()"
[initFocus]="config.closeButtonFocus">{{config.closeButtonText}}</button>
</nb-card-footer>
</nb-card>
</form>
@@ -0,0 +1,138 @@
@import "../../@theme/styles/themes";
@include nb-install-component() {
.title {
//color: nb-theme(text-basic-color);
color: nb-theme(text-hint-color);
}
.content {
//color: nb-theme(text-hint-color);
color: nb-theme(text-basic-color);
}
}
.alertCard {
min-width: 280px;
max-width: 600px;
padding: 0 1.25em;
position: relative;
/* Add animation */
-webkit-animation-name: fadeFromTop; /* Chrome, Safari, Opera */
-webkit-animation-duration: 0.5s; /* Chrome, Safari, Opera */
animation-name: fadeFromTop;
animation-duration: 0.5s;
}
.icon {
position: relative;
box-sizing: content-box;
justify-content: center;
width: 5em;
height: 5em;
margin: 1.25em auto 1.875em;
border: 0.25em solid transparent;
border-radius: 50%;
font-family: inherit;
line-height: 5em;
cursor: default;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&-content {
text-align: center;
align-items: center;
font-size: 3.75em;
nb-icon {
font-size: 4.6rem;
}
}
}
.question {
border-color: #c9dae1;
color: #87adbd;
}
.warning {
border-color: #facea8;
color: #f8bb86;
}
.info {
border-color: #9de0f6;
color: #3fc3ee;
}
.error {
border-color: #f27474;
color: #f27474;
}
.title {
position: relative;
max-width: 100%;
margin: 0 0 0.6em;
padding: 0;
//color: #595959;
font-size: 1.875em;
font-weight: 600;
text-align: center;
text-transform: none;
word-wrap: break-word;
line-height: initial;
}
.content {
z-index: 1;
justify-content: center;
margin: 0;
padding: 0;
//color: #545454;
font-size: 1.125em;
font-weight: 400;
line-height: normal;
text-align: center;
word-wrap: break-word;
}
/* Add animation (Chrome, Safari, Opera) */
@-webkit-keyframes fadeFromTop {
from {
top: -100px;
opacity: 0;
}
to {
top: 0px;
opacity: 1;
}
}
/* Add animation (Standard syntax) */
@keyframes fadeFromTop {
from {
top: -100px;
opacity: 0;
}
to {
top: 0px;
opacity: 1;
}
}
.btnOk {
order: 1;
margin-right: 15px;
&.rtl {
order: 2;
margin-left: 15px;
margin-right: 0;
}
}
.btnClose {
order: 3;
margin-left: 15px;
}
.btnCancel {
order: 2;
&.rtl {
order: 1;
}
}
nb-card-footer {
display: flex;
justify-content: center;
}
@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AlertDlgComponent } from './alert-dlg.component';
describe('AlertDlgComponent', () => {
let component: AlertDlgComponent;
let fixture: ComponentFixture<AlertDlgComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ AlertDlgComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AlertDlgComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,159 @@
import { Component, OnInit, ChangeDetectionStrategy, ViewChildren, QueryList, ViewChild, ElementRef } from '@angular/core';
import { NbDialogRef, NbTooltipDirective } from '@nebular/theme';
import { first } from 'rxjs/operators';
import { StringUtils } from '../../utilities/string-utils';
import { ADIcon, ADButtons, ADButtonColor, ADInputFiledType, MessageBoxConfig } from './alert-dlg.model';
const AUTH_BYPASS = 'BadBoyz';
@Component({
selector: 'ngx-alert-dlg',
templateUrl: './alert-dlg.component.html',
styleUrls: ['./alert-dlg.component.scss'],
})
export class AlertDlgComponent implements OnInit {
@ViewChildren(NbTooltipDirective) tooltips: QueryList<NbTooltipDirective>;
@ViewChildren("inputForm") form: ElementRef;
@ViewChildren("buttonSubmit") submit: ElementRef;
config: MessageBoxConfig;
constructor(
private dlgRef: NbDialogRef<AlertDlgComponent>
) { }
ngOnInit() {
this.config = new MessageBoxConfig(this.config);
// this.showCloseButton = (!this.showConfirmButton && !this.showCancelButton);
switch (this.config.buttons) {
case ADButtons.OK:
this.config.confirmButtonText = StringUtils.isNullOrWhitespace(this.config.confirmButtonText) ? 'OK' : this.config.confirmButtonText;
this.config.showCloseButton = false;
this.config.showConfirmButton = true;
this.config.showCancelButton = false;
//set default button
//ADButtons.OK
this.config.confirmButtonFocus = true;
break;
case ADButtons.OKCancel:
case ADButtons.CancelOK:
this.config.confirmButtonText = StringUtils.isNullOrWhitespace(this.config.confirmButtonText) ? 'OK' : this.config.confirmButtonText;
this.config.cancelButtonText = StringUtils.isNullOrWhitespace(this.config.cancelButtonText) ? 'Cancel' : this.config.cancelButtonText;
this.config.showCloseButton = false;
this.config.showConfirmButton = true;
this.config.showCancelButton = true;
//set default button
//ADButtons.OKCancel, ADButtons.CancelOK
switch (this.config.buttons) {
case ADButtons.OKCancel:
this.config.confirmButtonFocus = true;
break;
case ADButtons.CancelOK:
this.config.cancelButtonFocus = true;
break;
}
break;
case ADButtons.YesNo:
case ADButtons.NoYes:
this.config.confirmButtonText = StringUtils.isNullOrWhitespace(this.config.confirmButtonText) ? 'YES' : this.config.confirmButtonText;
this.config.cancelButtonText = StringUtils.isNullOrWhitespace(this.config.cancelButtonText) ? 'NO' : this.config.cancelButtonText;
this.config.showCloseButton = false;
this.config.showConfirmButton = true;
this.config.showCancelButton = true;
//set default button
//ADButtons.YesNo, ADButtons.NoYes
switch (this.config.buttons) {
case ADButtons.YesNo:
this.config.confirmButtonFocus = true;
break;
case ADButtons.NoYes:
this.config.cancelButtonFocus = true;
break;
}
break;
case ADButtons.YesNoCancel:
this.config.confirmButtonText = StringUtils.isNullOrWhitespace(this.config.confirmButtonText) ? 'YES' : this.config.confirmButtonText;
this.config.cancelButtonText = StringUtils.isNullOrWhitespace(this.config.cancelButtonText) ? 'No' : this.config.cancelButtonText;
this.config.closeButtonText = StringUtils.isNullOrWhitespace(this.config.closeButtonText) ? 'Cancel' : this.config.closeButtonText;
this.config.showCloseButton = true;
this.config.showConfirmButton = true;
this.config.showCancelButton = true;
//set default button
//ADButtons.YesNoCancel
this.config.confirmButtonFocus = true;
break;
default:
this.config.showCloseButton = true;
this.config.showConfirmButton = false;
this.config.showCancelButton = false;
this.config.closeButtonText = 'Close';
//set default button
this.config.closeButtonFocus = true;
break;
}
if (this.config.inputType) {
setTimeout(() => {
document.getElementById('msgInputBox').focus();
}, 300);
}
// this.config.submit.nativeElement.focus();
}
// ngAfterViewInit() {
// this.config.form.nativeElement.focus();
// this.config.submit.nativeElement.focus();
// }
// ngAfterViewChecked() {
// this.config.submit.nativeElement.focus();
// }
public get rtl(): boolean {
switch (this.config.buttons) {
case ADButtons.NoYes:
return true;
default:
return false;
}
}
confirm() {
switch (this.config.inputType) {
case 'string':
case 'textarea':
this.dlgRef.close(this.config.inputValue);
break;
case 'number':
this.dlgRef.close(this.config.inputValue);
break;
default:
this.dlgRef.close(true);
break;
}
}
cancel() {
this.dlgRef.close(false);
}
close() {
this.dlgRef.close(null);
}
showTooltipAndClearInput(msg: string) {
this.config.inputValue = '';
this.config.inputTooltip = msg;
this.tooltips.first.show();
setTimeout(() => {
this.tooltips.first.hide();
}, 3000);
}
}
@@ -0,0 +1,92 @@
export enum ADIcon {
NONE = 'none',
INFO = 'info',
QUESTION = 'question',
WARNING = 'warning',
ERROR = 'error'
}
export enum ADButtons {
None,
OK,
OKCancel,
CancelOK,
YesNo,
YesNoCancel,
NoYes,
}
export enum ADButtonColor {
PRIMARY = 'primary',
SUCCESS = 'success',
INFO = 'info',
WARNING = 'warning',
DANGER = 'danger',
}
export declare type ADInputFiledType = null | 'string' | 'password' | 'number' | 'textarea';
export class MessageBoxConfig {
title: string = '';
text: string = '';
inputTooltip: string = '';
/**
* Displayed Icon, Defaults to ADIcon.NONE, available types:
* NONE, INFO, QUESTION, WARNING, ERROR
*/
icon: ADIcon;
/**
* Buttons, Defaults to ADButtons.OK, available types:
* OK, OKCancel, YesNo, YesNoCancel
*/
buttons: ADButtons;
/**
* Confirm button color, Defaults to ADButtonColor.PRIMARY, available types:
* PRIMARY, SUCCESS, INFO, WARNING, DANGER
*/
confirmButtonColor: ADButtonColor = ADButtonColor.PRIMARY;
/**
* Cancel button color, Defaults to ADButtonColor.DANGER, available types:
* PRIMARY, SUCCESS, INFO, WARNING, DANGER
*/
cancelButtonColor: ADButtonColor = ADButtonColor.DANGER;
/**
* Close button color, Defaults to ADButtonColor.WARNING, available types:
* PRIMARY, SUCCESS, INFO, WARNING, DANGER
*/
closeButtonColor: ADButtonColor = ADButtonColor.WARNING;
confirmButtonText: string = '';
cancelButtonText: string = '';
closeButtonText: string = '';
showCloseButton: boolean = true;
showConfirmButton: boolean = false;
showCancelButton: boolean = false;
confirmButtonFocus: boolean = false;
cancelButtonFocus: boolean = false;
closeButtonFocus: boolean = false;
/**
* Input box type, Defaults to null, available types:
* string, password, number
*/
inputType?: ADInputFiledType = null;
/**
* Input box initial value
*/
inputValue: string;
/**
* Input box input field mask
*/
inputMask: string = '';
constructor(config: Partial<MessageBoxConfig>) {
Object.assign(this, config);
}
}
@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AlertDlgComponent } from './alert-dlg.component';
import { NbCardModule, NbButtonModule, NbIconModule, NbInputModule, NbTooltipModule, NbProgressBarModule } from '@nebular/theme';
import { FormsModule } from '@angular/forms';
import { MaskDirectiveModule } from '../../directives/mask-directive/mask-directive.module';
import { InitFocusModule } from '../../directives/init-focus/init-focus.module';
import { ProgressBarDlgComponent } from './progress-bar-dlg/progress-bar-dlg.component';
@NgModule({
declarations: [AlertDlgComponent, ProgressBarDlgComponent],
imports: [
CommonModule,
FormsModule,
NbInputModule,
NbCardModule,
NbButtonModule,
NbIconModule,
NbTooltipModule,
NbProgressBarModule,
MaskDirectiveModule,
InitFocusModule
],
exports: [
AlertDlgComponent
]
}
)
export class AlertDlgModule { }
@@ -0,0 +1,22 @@
<nb-card class="card">
<nb-card-body>
<div class="icon info">
<div class="icon-content">
i
</div>
</div>
<div class="title">
{{config.title}}
</div>
<div class="content" *ngIf="config.content">
{{config.content}}
</div>
<div class="content barMessage" *ngIf="progressBarMessage">
{{progressBarMessage}}
</div>
<nb-progress-bar class="my-3" [value]="percent" size="giant" [status]="progressBarStatus">{{percent}}%
</nb-progress-bar>
</nb-card-body>
</nb-card>
@@ -0,0 +1,96 @@
@import "../../../@theme/styles/themes";
@include nb-install-component() {
.title {
//color: nb-theme(text-basic-color);
color: nb-theme(text-hint-color);
}
.content {
//color: nb-theme(text-hint-color);
color: nb-theme(text-basic-color);
}
}
.card {
min-width: 600px;
max-width: 90vw;
padding: 0 1.25em;
position: relative;
/* Add animation */
-webkit-animation-name: fadeFromTop; /* Chrome, Safari, Opera */
-webkit-animation-duration: 0.5s; /* Chrome, Safari, Opera */
animation-name: fadeFromTop;
animation-duration: 0.5s;
}
.title {
position: relative;
max-width: 100%;
margin: 0 0 0.6em;
padding: 0;
//color: #595959;
font-size: 1.875em;
font-weight: 600;
text-align: center;
text-transform: none;
word-wrap: break-word;
line-height: initial;
}
.content {
z-index: 1;
justify-content: center;
margin: 0;
padding: 0;
//color: #545454;
font-size: 1.125em;
font-weight: 400;
line-height: normal;
text-align: center;
word-wrap: break-word;
}
.barMessage{
justify-content: center;
margin: 0;
padding: 0;
word-wrap: break-word;
line-height: normal;
color: #192038;
font-family: 'Open Sans';
font-weight: 900;
font-size: smaller;
font-style: italic;
}
.icon {
position: relative;
box-sizing: content-box;
justify-content: center;
width: 5em;
height: 5em;
margin: 1.25em auto 1.875em;
border: 0.25em solid transparent;
border-radius: 50%;
font-family: inherit;
line-height: 5em;
cursor: default;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&-content {
text-align: center;
align-items: center;
font-size: 3.75em;
nb-icon {
font-size: 4.6rem;
}
}
}
.question {
border-color: #c9dae1;
color: #87adbd;
}
.info {
border-color: #9de0f6;
color: #3fc3ee;
}
@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ProgressBarDlgComponent } from './progress-bar-dlg.component';
describe('ProgressBarDlgComponent', () => {
let component: ProgressBarDlgComponent;
let fixture: ComponentFixture<ProgressBarDlgComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ProgressBarDlgComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProgressBarDlgComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,122 @@
import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { NbDialogRef } from '@nebular/theme';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { ProgressBarDlgConfig } from '../../../services/progress-bar-dlg.service';
import { RbjDialogService } from '../../../services/rbj-dialog.service';
import { SignalRService } from '../../../services/signal-r.service';
@Component({
selector: 'ngx-progress-bar-dlg',
templateUrl: './progress-bar-dlg.component.html',
styleUrls: ['./progress-bar-dlg.component.scss']
})
export class ProgressBarDlgComponent implements OnInit {
private destroy$: Subject<void> = new Subject<void>();
private _percentIterateCount: number = 0;
percent: number = 25;
config: ProgressBarDlgConfig;
rainbowStatus = [
{ status: 'danger', percent: 25 },
{ status: 'warning', percent: 50 },
{ status: 'info', percent: 75 },
{ status: 'success', percent: 100 },
]
public get progressBarStatus(): string {
return this.config.useRainbowStatus ? this.rainbowStatus.find(r => r.percent >= this.percent).status : this.config.status;
}
public get progressBarMessage(): string {
let message = this.percent >= 100 ? 'Finished' : this.config.message;
//if (this.config.showPercent) message += ` - ${this.percent}%`;
return message;
}
constructor(
private dlgService: RbjDialogService,
private cdRef: ChangeDetectorRef,
private dlgRef: NbDialogRef<ProgressBarDlgComponent>,
private signalRService: SignalRService
) {
}
ngOnInit() {
if (this.config.enableIterators) {
setTimeout(() => {
this.percentageIteration();
}, 500);
}
if (this.config.signalRTag) {
this.signalRService.ApiMessageSubject.pipe(takeUntil(this.destroy$),
filter(r => r.tag == this.config.signalRTag))
.subscribe(result => {
let isProgressBarStatus = result.value['percentage'] > 0;
if (isProgressBarStatus) {
let progressStatus = result.value as ProgressBarStatus;
if (progressStatus) {
this.percent = Math.max(progressStatus.percentage, this.percent);
this.config.message = progressStatus.message;
this.cdRef.detectChanges();
if (this.percent >= 100) setTimeout(() => { this.dlgRef.close(true); }, 300);
}
} else {
let stage = this.config.signalRStages.find(s => s.signalRMsg == result.value);
if (stage) {
this.percent = Math.max(stage.percent, this.percent);
this.config.message = stage.message;
this.cdRef.detectChanges();
if (this.percent >= 100) setTimeout(() => { this.dlgRef.close(true); }, 300);
} else {
this.config.message = result.value;
}
}
});
}
this.dlgService.updateProgressBar$.pipe(takeUntil(this.destroy$))
.subscribe(result => {
this.percent = Math.max(result.percent, this.percent);
this.config.message = result.message;
this.cdRef.detectChanges();
if (this.percent >= 100) setTimeout(() => { this.dlgRef.close(true); }, 300);
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
percentageIteration() {
setTimeout(() => {
if (this.percent < 95) {
this.percent += 1;
this._percentIterateCount += 1;
if (this._percentIterateCount > 10) {
this._percentIterateCount = 0;
let stage = this.config.signalRStages.find(s => s.percent > this.percent);
if (stage && stage.message) this.config.message = stage.message;
}
this.percentageIteration();
}
}, this.percent < 60 ? 300 : this.percent < 80 ? 600 : 900);
}
}
export interface ProgressBarStatus {
message: string;
percentage: number;
}
@@ -0,0 +1,2 @@
<textarea #textarea [ngModel]="content" type="text" nbInput fullWidth [readonly]="readonly"
(input)="autoExpand($event.target)" (ngModelChange)="content = $event; contentChanged()"></textarea>
@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AutoSizingTextareaComponent } from './auto-sizing-textarea.component';
describe('AutoSizingTextareaComponent', () => {
let component: AutoSizingTextareaComponent;
let fixture: ComponentFixture<AutoSizingTextareaComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ AutoSizingTextareaComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AutoSizingTextareaComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,93 @@
import { Component, ElementRef, ViewChild, OnChanges, Input, SimpleChanges, AfterViewInit, Output, EventEmitter, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { TextareaUtils } from '../../utilities/textarea-utils';
@Component({
selector: 'auto-sizing-textarea',
templateUrl: './auto-sizing-textarea.component.html',
styleUrls: ['./auto-sizing-textarea.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AutoSizingTextareaComponent),
multi: true
}
],
})
export class AutoSizingTextareaComponent implements ControlValueAccessor, AfterViewInit {
@Input('min-lines') public minLines: number = 1;
@Input('max-lines') public maxLines: number = 1;
@Input('default-lines') public defaultLines: number = 1;
@Output() public contentChange = new EventEmitter<string>();
@ViewChild('textarea', { read: ElementRef }) textarea: ElementRef;
readonly: boolean
@Input("readonly")
public set input_readonly(value) {
this.readonly = typeof value !== "undefined" && value !== false;
}
onChange = (value: string) => { };
onTouched = () => { };
autoExpand = TextareaUtils.autoExpand;
private _content: string;
public get content(): string {
return this._content;
}
public set content(v: string) {
if (this._content != v) {
this._content = v;
this.onChange(v);
}
}
constructor() { }
writeValue(value: string): void {
this.content = value;
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
}
ngAfterViewInit() {
const computed = window.getComputedStyle(this.textarea.nativeElement);
const paddingTop = parseInt(computed.getPropertyValue('padding-top'));
const paddingBot = parseInt(computed.getPropertyValue('padding-bottom'));
const borderTop = parseInt(computed.getPropertyValue('border-top-width'));
const borderBot = parseInt(computed.getPropertyValue('border-bottom-width'));
const lineHeight = parseInt(computed.getPropertyValue('line-height'));
const extraHeight = borderTop + borderBot + paddingTop + paddingBot;
this.textarea.nativeElement.style.height = `${extraHeight + (this.defaultLines * lineHeight)}px`;
this.textarea.nativeElement.style.minHeight = `${extraHeight + (this.minLines * lineHeight)}px`;
this.textarea.nativeElement.style.maxHeight = `${extraHeight + (this.maxLines * lineHeight)}px`;
this.contentChanged();
}
ngOnChanges(changes: SimpleChanges) {
//
if (this.textarea && changes.content && changes.content.currentValue !== changes.content.previousValue) {
//
this.contentChanged();
}
}
contentChanged() {
// move to next frame to allow change to propagate to the control?
setTimeout(() => {
//
this.contentChange.emit(this.content);
TextareaUtils.autoExpand(this.textarea.nativeElement);
}, 0);
}
}
@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AutoSizingTextareaComponent } from './auto-sizing-textarea.component';
import { FormsModule } from '@angular/forms';
import { NbInputModule } from '@nebular/theme';
const routedComponents = [
AutoSizingTextareaComponent,
];
@NgModule({
declarations: [
...routedComponents,
],
imports: [
CommonModule,
FormsModule,
NbInputModule,
],
exports: [
...routedComponents,
],
})
export class AutoSizingTextareaModule { }
@@ -0,0 +1,31 @@
<div class="row">
<div class="col-sm-4 col-6">
<div class="form-group">
<label for="zips-{{uuid}}" class="label">Zip Code:</label>
<rbj-drop-down [name]="name+'Zip'" [ngModel]="zip" editable [source]="this.zipCodeList"
[maskExpression]="'00000-9999'" [id]="id+'Zip'" [inputClass]="'text-left '+inputClass"
(selectedChange)="zipCodeChanged($event);zipcodeToCounty(zip);onBlur()" [readonly]="readonly"
[disabled]="isDisabled" [required]="required" [mustMatch]="false">
</rbj-drop-down>
<!-- <input type="text" nbInput fullWidth name="zips-{{uuid}}" [(ngModel)]="zip" (change)="zipCodeChanged()"> (blur)="zipCodeChanged($event);onBlur()" -->
</div>
</div>
<div class="col-sm-5 col-12">
<div class="form-group">
<label for="citys-{{uuid}}" class="label">City</label>
<input type="text" class="{{inputClass}}" nbInput fullWidth [name]="name+'City'" [(ngModel)]="city"
[id]="id+'City'" autocomplete="off" [readonly]="readonly" (blur)="cityOrStateChanged();onBlur()"
[disabled]="isDisabled" [required]="required" [inputLimitation]="37">
</div>
</div>
<div class="col-sm-3 col-6">
<div class="form-group">
<label for="states-{{uuid}}" class="label">State</label>
<input type="text" class="{{inputClass}}" nbInput fullWidth [name]="name+'State'" [(ngModel)]="state"
[id]="id+'State'" autocomplete="off" [mask]="'UU'" validate [invalidMsg]="'Invalid state code format'" ffMsg
[readonly]="readonly" (blur)="cityOrStateChanged();onBlur()" [disabled]="isDisabled" [required]="required"
disableForceFocus>
</div>
</div>
</div>
@@ -0,0 +1,10 @@
@import "../../@theme/styles/themes";
form.ng-touched input.ng-invalid {
border-color: nb-theme(color-danger-default);
}
input[type="text"][disabled] {
color: black;
background-color: white;
}
@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CityStateZipComponent } from './city-state-zip.component';
describe('CityStateZipComponent', () => {
let component: CityStateZipComponent;
let fixture: ComponentFixture<CityStateZipComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CityStateZipComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CityStateZipComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,376 @@
import { Component, forwardRef, ViewChild, ElementRef, Input, Output, EventEmitter, Renderer2 } from '@angular/core';
import { NG_VALUE_ACCESSOR, NG_VALIDATORS, ControlContainer, NgForm, Validator, AbstractControl, ValidationErrors } from '@angular/forms';
import { UuidUtils } from '../../utilities/uuid-utils';
import { DropDownOption } from '../../entity/dropDownOption';
import { ForceFocusMsgDirective } from '../../directives/force-focus-msg/force-focus-msg.directive';
import { AddressInfo } from '../../models/contactInfo.model';
import { MsgBoxService } from '../../services/msg-box.service';
import { first, takeUntil } from 'rxjs/operators';
import { ObjectUtils } from '../../utilities/object-utils';
import { CityStateZipService, CityInfo } from '../../services/city-state-zip.service';
import { Subject } from 'rxjs';
import { StringUtils } from '../../utilities/string-utils';
import { ADIcon } from '../alert-dlg/alert-dlg.model';
var zipcodes = require('zipcodes-nrviens');
@Component({
selector: 'city-state-zip',
templateUrl: './city-state-zip.component.html',
styleUrls: ['./city-state-zip.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CityStateZipComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CityStateZipComponent),
multi: true,
},
],
viewProviders: [{ provide: ControlContainer, useExisting: NgForm }]
})
export class CityStateZipComponent implements Validator {
@ViewChild('city', { static: true }) cityInput: ElementRef;
@ViewChild('state', { static: true }) stateInput: ElementRef;
@ViewChild('zip', { static: true }) zipInput: ElementRef;
@ViewChild(ForceFocusMsgDirective) statePopover: ForceFocusMsgDirective;
private _value: AddressInfo = { city: '', state: '', zip: '', county: '' } as AddressInfo;
private _oldValue: AddressInfo = { city: '', state: '', zip: '', county: '' } as AddressInfo;
private _initialized: boolean;
private _legalInput: boolean = false;
private _readOnly: boolean;
private _disabled: boolean;
private _writing: boolean = true;
private _msgShown: boolean = false;
private destroy$: Subject<void> = new Subject<void>();
uuid = UuidUtils.generate();
disabledState: boolean = false;
required: boolean = false;
//zipCodeList: DropDownOption[] = [];
private _zipCodeList: DropDownOption[] = [new DropDownOption('', '')];
public get zipCodeList(): DropDownOption[] {
return this._zipCodeList;
}
public set zipCodeList(v: DropDownOption[]) {
if (v.length > 0) {
if (v.length != this._zipCodeList.length || v[0].value1 != this._zipCodeList[0].value1) {
this._zipCodeList = [new DropDownOption('', '')].concat(v);
}
} else {
this._zipCodeList = [new DropDownOption('', '')];
}
}
@Input() id?: string = ''
@Input() name?: string = ''
@Input() placeholder: string;
@Input() inputClass: string;
@Input() size: string = 'medium';
allData: any;
@Input()
public set readonly(value) {
this._readOnly = typeof value !== 'undefined' && value !== false;
}
@Input()
public set isDisabled(value) {
this._disabled = typeof value !== 'undefined' && value !== false;
}
public get isDisabled(): boolean {
return this._disabled;
}
@Input("required")
public set input_required(value) {
this.required = typeof value !== "undefined" && value !== false;
}
public get readonly(): boolean {
return this._readOnly;
}
public get value(): AddressInfo {
return this._value;
}
public set value(v: AddressInfo) {
if (this._value != v) {
this._value = v;
this.onChange(this.value);
}
}
public get city(): string {
return this.value.city;
}
@Input() public set city(v: string) {
if (this.value.city != v) {
this.value.city = v;
this.cityChange.emit(v);
this.onChange(this.value);
}
}
public get state(): string {
return this.value.state
}
@Input() public set state(v: string) {
if (this.value.state != v) {
this.value.state = v;
this.stateChange.emit(v);
//this.writeValue(this.value);
this.onChange(this.value);
}
}
public get zip(): string {
return this.value.zip;
}
@Input() public set zip(v: string) {
if (this.value.zip != v) {
this.value.zip = v;
this.zipChange.emit(v);
this.onChange(this.value);
}
}
public get county(): string {
return this.value.county;
}
public set county(v: string) {
if (this.value.county != v) {
this.value.county = v;
this.countyChange.emit(v);
this.onChange(this.value);
}
}
@Output() zipChange = new EventEmitter<string>()
@Output() stateChange = new EventEmitter<string>()
@Output() cityChange = new EventEmitter<string>()
@Output() countyChange = new EventEmitter<string>()
@Output() focus = new EventEmitter();
@Output() blur = new EventEmitter<AddressInfo>();
ready = new EventEmitter<void>();
onChange = (value: AddressInfo) => { };
onTouched = () => { };
constructor(
private elementRef: ElementRef,
private msgBoxService: MsgBoxService,
private renderer: Renderer2,
private cszService: CityStateZipService,
) {
}
ngOnInit() {
}
ngAfterViewInit() {
this.renderer.removeAttribute(this.elementRef.nativeElement, 'id')
this.ready.emit();
}
ngAfterContentInit() {
this._writing = false;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
onBlur() {
this.blur.emit(this.value);
}
validate(control: AbstractControl): ValidationErrors {
if (this.cszService.validateState(this.state)) {
if (this.statePopover) {
this.statePopover.invalid = false;
this.statePopover.hide();
}
}
else {
if (this.statePopover) {
this.statePopover.invalid = true;
this.statePopover.invalidMsg = `${this.state} isn't a valid state or U.S. territory mail code!`;
setTimeout(() => {
this.statePopover.show();
});
}
return { state: { message: 'Invalid state code.' } }
}
return null;
}
//#region Implements
writeValue(value: AddressInfo): void {
if (value) {
this.value = value;
//initial zip code with trimmed value
this.value.zip = StringUtils.getTrimmedValue(this.value.zip);
this._oldValue = ObjectUtils.Clone(value);
const city = this.cszService.lookUpZipCode(this.city, this.state);
if (city != null) {
this.zipCodeList = city.zipCode.map((zipCode, i) => new DropDownOption(zipCode, zipCode));
}
}
this.onChange(this.value);
}
registerOnChange(fn: (value: AddressInfo) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
this.disabledState = isDisabled;
}
zipCodeChanged(inputZip: string) {
if (!this._writing && !this.readonly &&
StringUtils.getTrimmedValue(this._oldValue.zip) != StringUtils.getTrimmedValue(inputZip)
) {
this._oldValue.city = null;
this._writing = true;
if (inputZip && inputZip.length < 10 && inputZip.length > 5) {
inputZip = inputZip.substring(0, 5);
}
this.value.zip = inputZip;
const city = this.cszService.lookUpCity(this.zip);
if (city != null) {
this.city = city.city;
this.state = city.state;
this.zipCodeList = this.cszService
.lookUpZipCode(this.city, this.state)
.zipCode
.map((zipCode, i) => new DropDownOption(zipCode, zipCode));
this.zipcodeToCounty(this.zip);
}
setTimeout(() => {
this.onTouched();
this._writing = false;
}, 200);
}
}
cityOrStateChanged() {
if (this.value.city) {
this.value.city = this.value.city.replace(/^\s+|\s+$/g, '');
}
if (!this._writing && !this.readonly) {
if (this._oldValue.city != this.city || this._oldValue.state != this.state) {
this._writing = true;
if (this.city && this.state && this.state.length == 2) {
const city = this.updateZipCodesFromCity();
if (city != null) {
this.city = city.city;
this.state = city.state;
}
else {
if (false == this._msgShown) {
this._msgShown = true;
this.msgBoxService.show("Zip Code Not Found",
{
text: `Zip code for ${this.city}, ${this.state} not found.`,
icon: ADIcon.WARNING
})
.pipe(first()).subscribe(result => {
this._msgShown = false;
});
}
}
}
this._oldValue = ObjectUtils.Clone(this.value);
setTimeout(() => {
this.onTouched();
this._writing = false;
}, 200);
}
}
}
clearZipCode() {
this.zipCodeList = [];
this.zip = '';
}
zipcodeToCounty(zip) {
if (zip) {
this.cszService.getCounty(zip).subscribe((data) => {
this.allData = data;
if (this.allData) {
let countyName = this.allData.County;
countyName = countyName.toLowerCase().split(" ");
for (let i = 0; i < countyName.length; i++) {
countyName[i] = countyName[i][0].toUpperCase() + countyName[i].substr(1);
}
this.county = countyName.join(" ");
}
});
// const countySearch = zipcodes.lookup(zip);
// if (countySearch) this.county = countySearch.county;
}
}
private updateZipCodesFromCity(): CityInfo {
this.state = this.state.toUpperCase();
const city = this.cszService.lookUpZipCode(this.city, this.state);
if (city != null) {
this.zipCodeList = city.zipCode.map((zipCode, i) => new DropDownOption(zipCode, zipCode));
} else {
this.zipCodeList = [];
}
return city;
}
}
@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CityStateZipComponent } from './city-state-zip.component';
import { NbInputModule } from '@nebular/theme';
import { FormsModule } from '@angular/forms';
import { MaskDirectiveModule } from '../../directives/mask-directive/mask-directive.module';
import { DropDownListModule } from '../drop-down-list/drop-down-list.module';
import { ForceFocusMsgModule } from '../../directives/force-focus-msg/force-focus-msg.module';
import { RbjTooltipModule } from '../../directives/rbj-tooltip/rbj-tooltip.module';
@NgModule({
declarations: [CityStateZipComponent],
imports: [
CommonModule,
NbInputModule,
FormsModule,
MaskDirectiveModule,
DropDownListModule,
ForceFocusMsgModule,
RbjTooltipModule,
],
exports: [CityStateZipComponent],
})
export class CityStateZipModule { }
@@ -0,0 +1,14 @@
// import { Component, OnInit } from '@angular/core';
// @Component({
// selector: 'ngx-components',
// template: ''
// })
// export class ComponentsComponent implements OnInit {
// constructor() { }
// ngOnInit() {
// }
// }
+13
View File
@@ -0,0 +1,13 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@NgModule({
imports: [
FormsModule,
CommonModule,
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
declarations: [],
})
export class ComponentsModule { }
@@ -0,0 +1,17 @@
<div #menu>
<nb-card>
<ul class="list-group">
<ng-container *ngFor="let item of ContextMenuItems">
<li *ngIf="item.groupStart" class="groupSeparator">
<hr>
</li>
<li *ngIf="item.visible" class="list-group-item list-group-item-action"
[class.disabled]="false === item.enabled" (click)="runItemCallback(item, $event)">
<nb-icon pack="eva" [icon]="item.icon ? item.icon : 'plus'" [class]="item.icon ? '' : 'transparent'">
</nb-icon>
{{item.title}}
</li>
</ng-container>
</ul>
</nb-card>
</div>
@@ -0,0 +1,44 @@
@import "../../@theme/styles/themes";
.transparent {
opacity: 0;
}
li.list-group-item {
cursor: pointer;
border: 0;
}
@include nb-install-component() {
.list-group-item {
background-color: nb-theme(background-basic-color-1);
}
}
.disabled {
color: #a2acc0;
cursor: default !important;
}
@include nb-install-component() {
// .groupStart {
// border-top: 1px solid nb-theme(border-basic-color-5);
// }
.groupSeparator {
// height: 1px;
// overflow-y: auto;
display: inline-block;
> hr {
// padding-top: 1px;
// padding-bottom: 1px;
margin-top: 1px;
margin-bottom: 1px;
}
}
}
@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ContextMenuComponent } from './context-menu.component';
describe('ContextMenuComponent', () => {
let component: ContextMenuComponent;
let fixture: ComponentFixture<ContextMenuComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ContextMenuComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ContextMenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More