WIP
This commit is contained in:
@@ -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]))
|
||||
]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<router-outlet></router-outlet>
|
||||
<div kendoDialogContainer></div>
|
||||
@@ -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' }
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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})` : '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Export user models
|
||||
export * from './user.model';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user