feat: Runtime configuration and Docker deployment improvements
Frontend: - Add runtime configuration service for deployment-time API URL injection - Create docker-entrypoint.sh to generate config.json from environment variables - Update ApiService, ApprovalService, and DocumentViewer to use RuntimeConfigService - Add APP_INITIALIZER to load runtime config before app starts Backend: - Fix init-blockchain.js to properly quote mnemonic phrases in .env file - Improve docker-entrypoint.sh with health checks and better error handling Docker: - Add API_BASE_URL environment variable to frontend container - Update docker-compose.yml with clear documentation for remote deployment - Reorganize .env.example with clear categories (REQUIRED FOR REMOTE, PRODUCTION, AUTO-GENERATED) Workflow fixes: - Fix DepartmentApproval interface to match backend schema - Fix stage transformation for 0-indexed stageOrder - Fix workflow list to show correct stage count from definition.stages Cleanup: - Move development artifacts to .trash directory - Remove root-level package.json (was only for utility scripts) - Add .trash/ to .gitignore
This commit is contained in:
@@ -18,6 +18,7 @@ export interface ApprovalResponseDto {
|
||||
requestId: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
departmentCode?: string;
|
||||
status: ApprovalStatus;
|
||||
approvedBy?: string;
|
||||
remarks?: string;
|
||||
|
||||
@@ -4,16 +4,19 @@
|
||||
*/
|
||||
|
||||
export type DocumentType =
|
||||
| 'FIRE_SAFETY_CERTIFICATE'
|
||||
| 'BUILDING_PLAN'
|
||||
| 'PROPERTY_OWNERSHIP'
|
||||
| 'INSPECTION_REPORT'
|
||||
| 'POLLUTION_CERTIFICATE'
|
||||
| 'ELECTRICAL_SAFETY_CERTIFICATE'
|
||||
| 'STRUCTURAL_STABILITY_CERTIFICATE'
|
||||
| 'IDENTITY_PROOF'
|
||||
| 'FLOOR_PLAN'
|
||||
| 'PHOTOGRAPH'
|
||||
| 'ID_PROOF'
|
||||
| 'ADDRESS_PROOF'
|
||||
| 'OTHER';
|
||||
| 'NOC'
|
||||
| 'LICENSE_COPY'
|
||||
| 'OTHER'
|
||||
| 'FIRE_SAFETY'
|
||||
| 'HEALTH_CERT'
|
||||
| 'TAX_CLEARANCE'
|
||||
| 'SITE_PLAN'
|
||||
| 'BUILDING_PERMIT'
|
||||
| 'BUSINESS_LICENSE';
|
||||
|
||||
export interface UploadDocumentDto {
|
||||
docType: DocumentType;
|
||||
|
||||
@@ -9,10 +9,13 @@ export type RequestType = 'NEW_LICENSE' | 'RENEWAL' | 'AMENDMENT' | 'MODIFICATIO
|
||||
export type RequestStatus = 'DRAFT' | 'SUBMITTED' | 'IN_REVIEW' | 'PENDING_RESUBMISSION' | 'APPROVED' | 'REJECTED' | 'REVOKED' | 'CANCELLED';
|
||||
|
||||
export interface CreateRequestDto {
|
||||
applicantId: string;
|
||||
applicantName: string;
|
||||
applicantPhone?: string;
|
||||
businessName?: string;
|
||||
requestType: RequestType;
|
||||
workflowId: string;
|
||||
metadata: Record<string, any>;
|
||||
workflowId?: string;
|
||||
workflowCode?: string;
|
||||
metadata?: Record<string, any>;
|
||||
tokenId?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,25 +3,40 @@
|
||||
* Models for workflow configuration and management
|
||||
*/
|
||||
|
||||
export type ExecutionType = 'SEQUENTIAL' | 'PARALLEL';
|
||||
export type CompletionCriteria = 'ALL' | 'ANY' | 'THRESHOLD';
|
||||
export type RejectionHandling = 'FAIL_REQUEST' | 'RETRY_STAGE' | 'ESCALATE';
|
||||
|
||||
export interface DepartmentApproval {
|
||||
departmentCode: string;
|
||||
departmentName: string;
|
||||
canDelegate: boolean;
|
||||
timeoutDays?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowStage {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
departmentId: string;
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
stageOrder: number;
|
||||
executionType: ExecutionType;
|
||||
requiredApprovals: DepartmentApproval[];
|
||||
completionCriteria: CompletionCriteria;
|
||||
threshold?: number;
|
||||
timeoutDays?: number;
|
||||
rejectionHandling: RejectionHandling;
|
||||
escalationDepartment?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreateWorkflowDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
workflowType: string;
|
||||
departmentId: string;
|
||||
description?: string;
|
||||
stages: WorkflowStage[];
|
||||
onSuccessActions?: string[];
|
||||
onFailureActions?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWorkflowDto {
|
||||
@@ -35,11 +50,17 @@ export interface UpdateWorkflowDto {
|
||||
export interface WorkflowResponseDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
workflowType: string;
|
||||
stages: WorkflowStage[];
|
||||
description?: string;
|
||||
definition: {
|
||||
stages: WorkflowStage[];
|
||||
onSuccessActions?: string[];
|
||||
onFailureActions?: string[];
|
||||
};
|
||||
isActive: boolean;
|
||||
version: number;
|
||||
metadata?: Record<string, any>;
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -54,12 +75,12 @@ export interface WorkflowPreviewDto {
|
||||
}
|
||||
|
||||
export interface WorkflowStagePreviewDto {
|
||||
id: string;
|
||||
name: string;
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
description?: string;
|
||||
departmentCode: string;
|
||||
departmentName: string;
|
||||
order: number;
|
||||
stageOrder: number;
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { ApplicationConfig, APP_INITIALIZER, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor, errorInterceptor } from './core/interceptors';
|
||||
import { RuntimeConfigService, initializeApp } from './core/services/runtime-config.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: initializeApp,
|
||||
deps: [RuntimeConfigService],
|
||||
multi: true,
|
||||
},
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
|
||||
provideAnimationsAsync(),
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
shareReplay,
|
||||
of,
|
||||
} from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { RuntimeConfigService } from './runtime-config.service';
|
||||
|
||||
// Configuration constants
|
||||
const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds
|
||||
@@ -110,6 +110,12 @@ function extractData<T>(response: ApiResponse<T> | null | undefined): T {
|
||||
return response as unknown as T;
|
||||
}
|
||||
|
||||
// Handle paginated responses: have 'data' and pagination fields but no 'success'
|
||||
// These should be returned as-is, not unwrapped
|
||||
if ('data' in response && !('success' in response) && ('total' in response || 'page' in response)) {
|
||||
return response as unknown as T;
|
||||
}
|
||||
|
||||
if (response.data === undefined) {
|
||||
// Return null as T if data is explicitly undefined but response exists
|
||||
return null as T;
|
||||
@@ -132,7 +138,14 @@ function isRetryableError(error: HttpErrorResponse): boolean {
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = environment.apiBaseUrl;
|
||||
private readonly configService = inject(RuntimeConfigService);
|
||||
|
||||
/**
|
||||
* Get API base URL from runtime config (supports deployment-time configuration)
|
||||
*/
|
||||
private get baseUrl(): string {
|
||||
return this.configService.apiBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for GET requests that should be shared
|
||||
@@ -331,7 +344,14 @@ export class ApiService {
|
||||
}
|
||||
|
||||
case HttpEventType.Response: {
|
||||
const responseData = event.body?.data;
|
||||
// Handle both wrapped ({data: ...}) and unwrapped responses
|
||||
const body = event.body;
|
||||
let responseData: T | undefined;
|
||||
if (body && typeof body === 'object' && 'data' in body) {
|
||||
responseData = (body as ApiResponse<T>).data;
|
||||
} else {
|
||||
responseData = body as T | undefined;
|
||||
}
|
||||
return {
|
||||
progress: 100,
|
||||
loaded: 1,
|
||||
|
||||
@@ -295,6 +295,7 @@ export class AuthService implements OnDestroy {
|
||||
name: InputSanitizer.sanitizeName(response.department.name || ''),
|
||||
email: InputSanitizer.sanitizeEmail(response.department.contactEmail || '') || '',
|
||||
departmentCode: InputSanitizer.sanitizeAlphanumeric(response.department.code || '', '_'),
|
||||
departmentId: String(response.department.id),
|
||||
};
|
||||
|
||||
this.storage.setUser(user);
|
||||
|
||||
44
frontend/src/app/core/services/runtime-config.service.ts
Normal file
44
frontend/src/app/core/services/runtime-config.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
apiBaseUrl: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RuntimeConfigService {
|
||||
private config: RuntimeConfig | null = null;
|
||||
|
||||
/**
|
||||
* Loads runtime configuration from assets/config.json
|
||||
* This allows environment-specific config without rebuilding
|
||||
*/
|
||||
async loadConfig(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/assets/config.json');
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
this.config = config;
|
||||
}
|
||||
} catch {
|
||||
// Config file not found or invalid - use environment defaults
|
||||
console.warn('Runtime config not found, using build-time defaults');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API base URL - prefers runtime config over build-time environment
|
||||
*/
|
||||
get apiBaseUrl(): string {
|
||||
return this.config?.apiBaseUrl || environment.apiBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for APP_INITIALIZER
|
||||
*/
|
||||
export function initializeApp(configService: RuntimeConfigService): () => Promise<void> {
|
||||
return () => configService.loadConfig();
|
||||
}
|
||||
@@ -36,8 +36,8 @@ export class StorageService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (token && TokenValidator.isExpired(token)) {
|
||||
// Check if token is expired (with 60 second buffer for clock skew)
|
||||
if (token && TokenValidator.isExpired(token, 60)) {
|
||||
console.warn('Token expired, clearing...');
|
||||
this.removeToken();
|
||||
return null;
|
||||
@@ -61,11 +61,9 @@ export class StorageService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is already expired
|
||||
if (TokenValidator.isExpired(token)) {
|
||||
console.error('Cannot store expired token');
|
||||
return;
|
||||
}
|
||||
// Note: We don't check expiration here because the token just came from the server.
|
||||
// Clock skew between client and server could cause valid tokens to appear expired.
|
||||
// Expiration is checked when retrieving the token instead.
|
||||
|
||||
this.tokenStorage.setItem(environment.tokenStorageKey, token);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export class TokenValidator {
|
||||
|
||||
/**
|
||||
* Validate token is well-formed and not expired
|
||||
* Uses 120 second buffer for clock skew tolerance
|
||||
*/
|
||||
static validate(token: string | null | undefined): {
|
||||
valid: boolean;
|
||||
@@ -131,7 +132,8 @@ export class TokenValidator {
|
||||
return { valid: false, error: 'Failed to decode token' };
|
||||
}
|
||||
|
||||
if (this.isExpired(token)) {
|
||||
// Use 120 second buffer for clock skew between client and server
|
||||
if (this.isExpired(token, 120)) {
|
||||
return { valid: false, error: 'Token has expired', payload };
|
||||
}
|
||||
|
||||
|
||||
@@ -117,24 +117,24 @@ interface PlatformStats {
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
&.success {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
&.info {
|
||||
background: linear-gradient(135deg, var(--dbim-info) 0%, #60a5fa 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0d6efd 0%, #60a5fa 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
&.warning {
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
&.secondary {
|
||||
background: linear-gradient(135deg, var(--crypto-purple) 0%, var(--crypto-indigo) 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,12 +147,13 @@ interface PlatformStats {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
.stat-icon {
|
||||
font-size: 28px !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
@@ -166,12 +167,13 @@ interface PlatformStats {
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.02em;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -183,10 +185,11 @@ interface PlatformStats {
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
color: var(--dbim-grey-2);
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -143,34 +143,13 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
box-shadow: 0 4px 20px rgba(29, 10, 105, 0.15);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 60%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -50%;
|
||||
left: -10%;
|
||||
width: 40%;
|
||||
height: 150%;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -192,40 +171,81 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.header-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.header-icon {
|
||||
font-size: 32px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.header-text h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.header-text .subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tabs-card {
|
||||
margin-top: 24px;
|
||||
border-radius: 16px !important;
|
||||
overflow: hidden;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:host ::ng-deep .tabs-card .mat-mdc-card {
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-tab-header {
|
||||
background: white;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 8px 8px 0 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-tab {
|
||||
min-width: 120px;
|
||||
padding: 0 24px;
|
||||
height: 48px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-tab.mdc-tab--active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-tab-labels {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mdc-tab__text-label {
|
||||
color: #1D0A69 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mdc-tab-indicator__content--underline {
|
||||
border-color: #1D0A69 !important;
|
||||
border-width: 3px !important;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
@@ -233,11 +253,14 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #1D0A69;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 24px;
|
||||
background: var(--dbim-white);
|
||||
background: white;
|
||||
border-radius: 0 0 12px 12px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
@@ -248,10 +271,6 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
@@ -261,25 +280,6 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
.dashboard-sidebar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-tab-label {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-header {
|
||||
background: var(--dbim-linen);
|
||||
border-bottom: 1px solid rgba(29, 10, 105, 0.08);
|
||||
}
|
||||
|
||||
.mat-mdc-tab:not(.mat-mdc-tab-disabled).mdc-tab--active .mdc-tab__text-label {
|
||||
color: var(--dbim-blue-dark);
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
background: var(--dbim-white);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { TransactionDetailDialogComponent } from '../../../shared/components/blockchain-explorer-mini/transaction-detail-dialog.component';
|
||||
|
||||
interface BlockchainTransaction {
|
||||
id: string;
|
||||
@@ -295,19 +296,19 @@ interface PaginatedResponse {
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
|
||||
&.confirmed { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); }
|
||||
&.pending { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
|
||||
&.failed { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
|
||||
&.total { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
&.confirmed { background: linear-gradient(135deg, #059669 0%, #10b981 100%); }
|
||||
&.pending { background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%); }
|
||||
&.failed { background: linear-gradient(135deg, #DC3545 0%, #e74c3c 100%); }
|
||||
&.total { background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%); }
|
||||
|
||||
mat-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0.9;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,11 +319,12 @@ interface PaginatedResponse {
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -491,7 +493,29 @@ export class TransactionDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
viewTransactionDetails(tx: BlockchainTransaction): void {
|
||||
alert(`Transaction Details:\n\n${JSON.stringify(tx, null, 2)}`);
|
||||
// Convert to BlockchainTransactionDto format for the dialog
|
||||
const dialogData = {
|
||||
id: tx.id,
|
||||
txHash: tx.transactionHash || (tx as any).txHash,
|
||||
type: (tx as any).txType || 'TRANSACTION',
|
||||
status: tx.status,
|
||||
gasUsed: tx.gasUsed ? parseInt(tx.gasUsed, 10) : undefined,
|
||||
blockNumber: tx.blockNumber,
|
||||
timestamp: tx.createdAt,
|
||||
data: {
|
||||
from: tx.from || (tx as any).fromAddress,
|
||||
to: tx.to || (tx as any).toAddress,
|
||||
value: tx.value,
|
||||
requestId: tx.requestId || (tx as any).relatedEntityId,
|
||||
},
|
||||
};
|
||||
|
||||
this.dialog.open(TransactionDetailDialogComponent, {
|
||||
data: dialogData,
|
||||
width: '600px',
|
||||
maxHeight: '90vh',
|
||||
panelClass: 'blockchain-detail-dialog',
|
||||
});
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
|
||||
@@ -149,14 +149,19 @@ export class ApprovalActionComponent {
|
||||
];
|
||||
|
||||
readonly documentTypes: { value: DocumentType; label: string }[] = [
|
||||
{ value: 'FIRE_SAFETY_CERTIFICATE', label: 'Fire Safety Certificate' },
|
||||
{ value: 'BUILDING_PLAN', label: 'Building Plan' },
|
||||
{ value: 'PROPERTY_OWNERSHIP', label: 'Property Ownership' },
|
||||
{ value: 'INSPECTION_REPORT', label: 'Inspection Report' },
|
||||
{ value: 'POLLUTION_CERTIFICATE', label: 'Pollution Certificate' },
|
||||
{ value: 'ELECTRICAL_SAFETY_CERTIFICATE', label: 'Electrical Safety' },
|
||||
{ value: 'IDENTITY_PROOF', label: 'Identity Proof' },
|
||||
{ value: 'ID_PROOF', label: 'Identity Proof' },
|
||||
{ value: 'ADDRESS_PROOF', label: 'Address Proof' },
|
||||
{ value: 'FIRE_SAFETY', label: 'Fire Safety Certificate' },
|
||||
{ value: 'FLOOR_PLAN', label: 'Floor Plan' },
|
||||
{ value: 'SITE_PLAN', label: 'Site Plan' },
|
||||
{ value: 'BUILDING_PERMIT', label: 'Building Permit' },
|
||||
{ value: 'BUSINESS_LICENSE', label: 'Business License' },
|
||||
{ value: 'PHOTOGRAPH', label: 'Photograph' },
|
||||
{ value: 'NOC', label: 'No Objection Certificate' },
|
||||
{ value: 'LICENSE_COPY', label: 'License Copy' },
|
||||
{ value: 'HEALTH_CERT', label: 'Health Certificate' },
|
||||
{ value: 'TAX_CLEARANCE', label: 'Tax Clearance' },
|
||||
{ value: 'OTHER', label: 'Other Document' },
|
||||
];
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
@@ -266,7 +271,9 @@ export class ApprovalActionComponent {
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting.set(false);
|
||||
this.notification.error(err?.error?.message || 'Failed to process action. Please try again.');
|
||||
// Handle different error formats
|
||||
const errorMessage = err?.error?.message || err?.message || 'Failed to process action. Please try again.';
|
||||
this.notification.error(errorMessage);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export class ApprovalHistoryComponent implements OnInit {
|
||||
private loadHistory(): void {
|
||||
this.approvalService.getApprovalHistory(this.requestId).subscribe({
|
||||
next: (data) => {
|
||||
this.approvals.set(data);
|
||||
this.approvals.set(data ?? []);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@ import { ApprovalResponseDto } from '../../../api/models';
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header
|
||||
icon="pending_actions"
|
||||
title="Pending Approvals"
|
||||
subtitle="Review and approve license requests"
|
||||
/>
|
||||
@@ -47,6 +48,17 @@ import { ApprovalResponseDto } from '../../../api/models';
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (hasError()) {
|
||||
<app-empty-state
|
||||
icon="error_outline"
|
||||
title="Failed to load approvals"
|
||||
message="There was an error loading the pending approvals. Please try again."
|
||||
>
|
||||
<button mat-raised-button color="primary" (click)="retryLoad()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Retry
|
||||
</button>
|
||||
</app-empty-state>
|
||||
} @else if (approvals().length === 0) {
|
||||
<app-empty-state
|
||||
icon="check_circle"
|
||||
@@ -79,30 +91,32 @@ import { ApprovalResponseDto } from '../../../api/models';
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="openApprovalDialog(row, 'approve')"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
(click)="openApprovalDialog(row, 'reject')"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
(click)="openApprovalDialog(row, 'changes')"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
Request Changes
|
||||
</button>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="openApprovalDialog(row, 'approve')"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
(click)="openApprovalDialog(row, 'reject')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
(click)="openApprovalDialog(row, 'changes')"
|
||||
>
|
||||
<mat-icon>edit_note</mat-icon>
|
||||
Request Changes
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@@ -146,9 +160,21 @@ import { ApprovalResponseDto } from '../../../api/models';
|
||||
}
|
||||
|
||||
.mat-column-actions {
|
||||
width: 300px;
|
||||
width: 380px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
@@ -184,9 +210,10 @@ export class PendingListComponent implements OnInit, OnDestroy {
|
||||
this.totalItems.set(response?.total ?? 0);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
error: (err) => {
|
||||
this.hasError.set(true);
|
||||
this.loading.set(false);
|
||||
this.notification.error(err?.message || 'Failed to load pending approvals');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, throwError, map, catchError } from 'rxjs';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, throwError, map, catchError, timeout } from 'rxjs';
|
||||
import { ApiService, validateId, validatePagination } from '../../../core/services/api.service';
|
||||
import { RuntimeConfigService } from '../../../core/services/runtime-config.service';
|
||||
import {
|
||||
ApprovalResponseDto,
|
||||
PaginatedApprovalsResponse,
|
||||
@@ -22,11 +24,33 @@ export interface RequestChangesDto {
|
||||
requiredDocuments: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend paginated response with meta wrapper
|
||||
*/
|
||||
interface BackendPaginatedResponse {
|
||||
data: ApprovalResponseDto[];
|
||||
meta?: {
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
totalPages?: number;
|
||||
hasNext?: boolean;
|
||||
hasPrev?: boolean;
|
||||
};
|
||||
// Also support flat pagination fields
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
totalPages?: number;
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures response has valid data array for paginated approvals
|
||||
* Handles both { data, meta: {...} } and { data, total, page, ... } formats
|
||||
*/
|
||||
function ensureValidPaginatedResponse(
|
||||
response: PaginatedApprovalsResponse | null | undefined,
|
||||
response: BackendPaginatedResponse | null | undefined,
|
||||
page: number,
|
||||
limit: number
|
||||
): PaginatedApprovalsResponse {
|
||||
@@ -41,13 +65,21 @@ function ensureValidPaginatedResponse(
|
||||
};
|
||||
}
|
||||
|
||||
// Extract pagination from meta object if present, otherwise use flat fields
|
||||
const meta = response.meta;
|
||||
const total = meta?.total ?? response.total ?? 0;
|
||||
const responsePage = meta?.page ?? response.page ?? page;
|
||||
const responseLimit = meta?.limit ?? response.limit ?? limit;
|
||||
const totalPages = meta?.totalPages ?? response.totalPages ?? 0;
|
||||
const hasNextPage = meta?.hasNext ?? response.hasNextPage ?? false;
|
||||
|
||||
return {
|
||||
data: Array.isArray(response.data) ? response.data : [],
|
||||
total: typeof response.total === 'number' && response.total >= 0 ? response.total : 0,
|
||||
page: typeof response.page === 'number' && response.page >= 1 ? response.page : page,
|
||||
limit: typeof response.limit === 'number' && response.limit >= 1 ? response.limit : limit,
|
||||
totalPages: typeof response.totalPages === 'number' && response.totalPages >= 0 ? response.totalPages : 0,
|
||||
hasNextPage: typeof response.hasNextPage === 'boolean' ? response.hasNextPage : false,
|
||||
total: typeof total === 'number' && total >= 0 ? total : 0,
|
||||
page: typeof responsePage === 'number' && responsePage >= 1 ? responsePage : page,
|
||||
limit: typeof responseLimit === 'number' && responseLimit >= 1 ? responseLimit : limit,
|
||||
totalPages: typeof totalPages === 'number' && totalPages >= 0 ? totalPages : 0,
|
||||
hasNextPage: typeof hasNextPage === 'boolean' ? hasNextPage : false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,16 +134,25 @@ function validateDocumentIds(docs: string[] | undefined | null): string[] {
|
||||
})
|
||||
export class ApprovalService {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly configService = inject(RuntimeConfigService);
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.configService.apiBaseUrl;
|
||||
}
|
||||
|
||||
getPendingApprovals(page = 1, limit = 10): Observable<PaginatedApprovalsResponse> {
|
||||
const validated = validatePagination(page, limit);
|
||||
|
||||
return this.api
|
||||
.get<PaginatedApprovalsResponse>('/approvals/pending', {
|
||||
page: validated.page,
|
||||
limit: validated.limit,
|
||||
})
|
||||
// Use HttpClient directly to avoid extractData unwrapping the paginated response
|
||||
const params = new HttpParams()
|
||||
.set('page', validated.page.toString())
|
||||
.set('limit', validated.limit.toString());
|
||||
|
||||
return this.http
|
||||
.get<BackendPaginatedResponse>(`${this.baseUrl}/approvals/pending`, { params })
|
||||
.pipe(
|
||||
timeout(30000),
|
||||
map((response) => ensureValidPaginatedResponse(response, validated.page, validated.limit)),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch pending approvals';
|
||||
@@ -124,7 +165,7 @@ export class ApprovalService {
|
||||
try {
|
||||
const validId = validateId(requestId, 'Request ID');
|
||||
|
||||
return this.api.get<ApprovalResponseDto[]>(`/requests/${validId}/approvals`).pipe(
|
||||
return this.api.get<ApprovalResponseDto[]>(`/approvals/requests/${validId}`).pipe(
|
||||
map((response) => ensureValidArray(response)),
|
||||
catchError((error: unknown) => {
|
||||
const message =
|
||||
@@ -178,7 +219,7 @@ export class ApprovalService {
|
||||
reviewedDocuments: validateDocumentIds(dto.reviewedDocuments),
|
||||
};
|
||||
|
||||
return this.api.post<ApprovalResponseDto>(`/requests/${validId}/approve`, sanitizedDto).pipe(
|
||||
return this.api.post<ApprovalResponseDto>(`/approvals/${validId}/approve`, sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to approve request: ${requestId}`;
|
||||
return throwError(() => new Error(message));
|
||||
@@ -201,12 +242,12 @@ export class ApprovalService {
|
||||
return throwError(() => new Error('Rejection reason is required'));
|
||||
}
|
||||
|
||||
const sanitizedDto: RejectRequestDto = {
|
||||
const sanitizedDto = {
|
||||
remarks: validateRemarks(dto.remarks),
|
||||
rejectionReason: dto.rejectionReason,
|
||||
reason: dto.rejectionReason, // Backend expects 'reason' not 'rejectionReason'
|
||||
};
|
||||
|
||||
return this.api.post<ApprovalResponseDto>(`/requests/${validId}/reject`, sanitizedDto).pipe(
|
||||
return this.api.post<ApprovalResponseDto>(`/approvals/${validId}/reject`, sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to reject request: ${requestId}`;
|
||||
return throwError(() => new Error(message));
|
||||
@@ -236,7 +277,7 @@ export class ApprovalService {
|
||||
};
|
||||
|
||||
return this.api
|
||||
.post<ApprovalResponseDto>(`/requests/${validId}/request-changes`, sanitizedDto)
|
||||
.post<ApprovalResponseDto>(`/approvals/requests/${validId}/request-changes`, sanitizedDto)
|
||||
.pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message =
|
||||
@@ -253,7 +294,8 @@ export class ApprovalService {
|
||||
try {
|
||||
const validId = validateId(requestId, 'Request ID');
|
||||
|
||||
return this.api.get<ApprovalResponseDto[]>(`/requests/${validId}/approval-history`).pipe(
|
||||
// Use the existing approvals-by-request endpoint which returns all approvals for a request
|
||||
return this.api.get<ApprovalResponseDto[]>(`/approvals/requests/${validId}`).pipe(
|
||||
map((response) => ensureValidArray(response)),
|
||||
catchError((error: unknown) => {
|
||||
const message =
|
||||
|
||||
@@ -101,7 +101,13 @@ import { AuditLogDto, AuditAction, ActorType } from '../../../api/models';
|
||||
<table mat-table [dataSource]="logs()">
|
||||
<ng-container matColumnDef="timestamp">
|
||||
<th mat-header-cell *matHeaderCellDef>Timestamp</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.timestamp | date: 'medium' }}</td>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
@if (row.timestamp || row.createdAt) {
|
||||
<span class="timestamp-value">{{ (row.timestamp || row.createdAt) | date: 'medium' }}</span>
|
||||
} @else {
|
||||
<span class="timestamp-missing">-</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="action">
|
||||
@@ -212,6 +218,16 @@ import { AuditLogDto, AuditAction, ActorType } from '../../../api/models';
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.timestamp-value {
|
||||
font-size: 0.875rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.timestamp-missing {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.action-create {
|
||||
background-color: #c8e6c9 !important;
|
||||
color: #2e7d32 !important;
|
||||
|
||||
@@ -276,7 +276,7 @@ export class EntityTrailComponent implements OnInit {
|
||||
private loadTrail(): void {
|
||||
this.auditService.getEntityTrail(this.entityType(), this.entityId()).subscribe({
|
||||
next: (trail) => {
|
||||
this.events.set(trail.events);
|
||||
this.events.set(trail?.events ?? []);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
|
||||
@@ -2,14 +2,12 @@ import { Component } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { InputSanitizer } from '../../../core/utils/input-sanitizer';
|
||||
|
||||
@@ -28,291 +26,422 @@ interface DemoAccount {
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatDividerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="email-login-container">
|
||||
<mat-card class="login-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon class="logo-icon">verified_user</mat-icon>
|
||||
<h2>Goa GEL Platform</h2>
|
||||
</mat-card-title>
|
||||
<p class="subtitle">Government e-License Platform</p>
|
||||
</mat-card-header>
|
||||
<div class="login-form">
|
||||
<!-- Header -->
|
||||
<div class="form-header">
|
||||
<div class="header-glow"></div>
|
||||
<h1 class="form-title">Sign In</h1>
|
||||
<p class="form-subtitle">Enter your credentials to continue</p>
|
||||
</div>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput type="email" formControlName="email" placeholder="Enter your email" />
|
||||
<mat-icon matPrefix>email</mat-icon>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('required')">
|
||||
Email is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('email')">
|
||||
Please enter a valid email
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('maxlength')">
|
||||
Email is too long
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<!-- Login Form -->
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Email</label>
|
||||
<div class="input-wrapper">
|
||||
<mat-icon class="input-icon">email</mat-icon>
|
||||
<input
|
||||
type="email"
|
||||
formControlName="email"
|
||||
placeholder="Enter your email"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<span class="field-error" *ngIf="loginForm.get('email')?.touched && loginForm.get('email')?.hasError('required')">
|
||||
Email is required
|
||||
</span>
|
||||
<span class="field-error" *ngIf="loginForm.get('email')?.touched && loginForm.get('email')?.hasError('email')">
|
||||
Please enter a valid email
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[type]="hidePassword ? 'password' : 'text'"
|
||||
formControlName="password"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<mat-icon matPrefix>lock</mat-icon>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="hidePassword = !hidePassword"
|
||||
>
|
||||
<mat-icon>{{ hidePassword ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
<mat-error *ngIf="loginForm.get('password')?.hasError('required')">
|
||||
Password is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('password')?.hasError('minlength')">
|
||||
Password must be at least 8 characters
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('password')?.hasError('maxlength')">
|
||||
Password is too long
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
class="full-width login-button"
|
||||
[disabled]="loginForm.invalid || loading"
|
||||
>
|
||||
<mat-spinner *ngIf="loading" diameter="20"></mat-spinner>
|
||||
<span *ngIf="!loading">Sign In</span>
|
||||
<div class="form-field">
|
||||
<label class="field-label">Password</label>
|
||||
<div class="input-wrapper">
|
||||
<mat-icon class="input-icon">lock</mat-icon>
|
||||
<input
|
||||
[type]="hidePassword ? 'password' : 'text'"
|
||||
formControlName="password"
|
||||
placeholder="Enter your password"
|
||||
class="form-input"
|
||||
/>
|
||||
<button type="button" class="toggle-password" (click)="hidePassword = !hidePassword">
|
||||
<mat-icon>{{ hidePassword ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<span class="field-error" *ngIf="loginForm.get('password')?.touched && loginForm.get('password')?.hasError('required')">
|
||||
Password is required
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<mat-divider class="divider"></mat-divider>
|
||||
<button type="submit" class="submit-btn" [disabled]="loginForm.invalid || loading">
|
||||
<mat-spinner *ngIf="loading" diameter="20"></mat-spinner>
|
||||
<span *ngIf="!loading">Sign In</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="demo-accounts">
|
||||
<h3 class="demo-title">
|
||||
<mat-icon>info</mat-icon>
|
||||
Demo Accounts
|
||||
</h3>
|
||||
<p class="demo-subtitle">Click any account to auto-fill credentials</p>
|
||||
<!-- Demo Credentials Toggle -->
|
||||
<button class="demo-toggle" (click)="showDemo = !showDemo">
|
||||
<mat-icon>{{ showDemo ? 'expand_less' : 'science' }}</mat-icon>
|
||||
<span>{{ showDemo ? 'Hide Demo Accounts' : 'View Demo Accounts' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="demo-grid">
|
||||
<div
|
||||
*ngFor="let account of demoAccounts"
|
||||
class="demo-card"
|
||||
(click)="fillDemoCredentials(account)"
|
||||
[class.selected]="selectedDemo === account.email"
|
||||
>
|
||||
<mat-icon [style.color]="getRoleColor(account.role)">{{ account.icon }}</mat-icon>
|
||||
<div class="demo-info">
|
||||
<strong>{{ account.role }}</strong>
|
||||
<span class="demo-email">{{ account.email }}</span>
|
||||
<span class="demo-description">{{ account.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="credentials-note">
|
||||
<mat-icon>security</mat-icon>
|
||||
<span>All demo accounts use the same password format: <code>Role@123</code></span>
|
||||
<!-- Collapsible Demo Panel -->
|
||||
<div class="demo-panel" [class.expanded]="showDemo">
|
||||
<div class="demo-panel-content">
|
||||
<div
|
||||
*ngFor="let account of demoAccounts"
|
||||
class="demo-account"
|
||||
(click)="fillDemoCredentials(account)"
|
||||
[class.selected]="selectedDemo === account.email"
|
||||
>
|
||||
<mat-icon [style.color]="getRoleColor(account.role)">{{ account.icon }}</mat-icon>
|
||||
<div class="account-info">
|
||||
<span class="account-role">{{ account.role }}</span>
|
||||
<span class="account-email">{{ account.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="password-hint">
|
||||
<mat-icon>vpn_key</mat-icon>
|
||||
<span>Password: <code>Role@123</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back Link -->
|
||||
<a [routerLink]="['/auth']" class="back-link">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Other login options</span>
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.email-login-container {
|
||||
min-height: 100vh;
|
||||
// =============================================================================
|
||||
// EMAIL LOGIN - Dark Glass-Morphism Theme
|
||||
// =============================================================================
|
||||
|
||||
.login-form {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HEADER
|
||||
// =============================================================================
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-glow {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
background: radial-gradient(ellipse, rgba(99, 102, 241, 0.2) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #FFFFFF 0%, #c7d2fe 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FORM FIELDS
|
||||
// =============================================================================
|
||||
.form-field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 14px 14px 46px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
&:hover:not(:focus) {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-password {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
transition: color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #f87171;
|
||||
margin-top: 6px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SUBMIT BUTTON
|
||||
// =============================================================================
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #6366F1 0%, #818cf8 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
mat-card-header {
|
||||
display: block;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #1976d2;
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 500;
|
||||
color: #1976d2;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
::ng-deep circle {
|
||||
stroke: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 32px 0 24px;
|
||||
}
|
||||
|
||||
.demo-accounts {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
// =============================================================================
|
||||
// DEMO TOGGLE & PANEL
|
||||
// =============================================================================
|
||||
.demo-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.125rem;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1976d2;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.demo-subtitle {
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 16px;
|
||||
.demo-panel {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.expanded {
|
||||
max-height: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
.demo-panel-content {
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
.demo-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #1976d2;
|
||||
background-color: #f5f5f5;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #1976d2;
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.demo-info {
|
||||
.account-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
|
||||
strong {
|
||||
font-size: 0.875rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.demo-email {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.demo-description {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
}
|
||||
|
||||
.credentials-note {
|
||||
.account-role {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.account-email {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #fff3e0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #e65100;
|
||||
padding: 10px 14px;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: 10px;
|
||||
margin-top: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-family: 'SF Mono', monospace;
|
||||
color: #34d399;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BACK LINK
|
||||
// =============================================================================
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 24px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -322,6 +451,7 @@ export class EmailLoginComponent {
|
||||
loginForm: FormGroup;
|
||||
loading = false;
|
||||
hidePassword = true;
|
||||
showDemo = false;
|
||||
selectedDemo: string | null = null;
|
||||
|
||||
demoAccounts: DemoAccount[] = [
|
||||
@@ -329,35 +459,28 @@ export class EmailLoginComponent {
|
||||
role: 'Admin',
|
||||
email: 'admin@goa.gov.in',
|
||||
password: 'Admin@123',
|
||||
description: 'System administrator with full access',
|
||||
description: 'System administrator',
|
||||
icon: 'admin_panel_settings',
|
||||
},
|
||||
{
|
||||
role: 'Fire Department',
|
||||
role: 'Fire Dept',
|
||||
email: 'fire@goa.gov.in',
|
||||
password: 'Fire@123',
|
||||
description: 'Fire safety inspection officer',
|
||||
description: 'Fire safety officer',
|
||||
icon: 'local_fire_department',
|
||||
},
|
||||
{
|
||||
role: 'Tourism',
|
||||
email: 'tourism@goa.gov.in',
|
||||
password: 'Tourism@123',
|
||||
description: 'Tourism license reviewer',
|
||||
description: 'Tourism reviewer',
|
||||
icon: 'luggage',
|
||||
},
|
||||
{
|
||||
role: 'Municipality',
|
||||
email: 'municipality@goa.gov.in',
|
||||
password: 'Municipality@123',
|
||||
description: 'Municipal building permit officer',
|
||||
icon: 'location_city',
|
||||
},
|
||||
{
|
||||
role: 'Citizen',
|
||||
email: 'citizen@example.com',
|
||||
email: 'rajesh.naik@example.com',
|
||||
password: 'Citizen@123',
|
||||
description: 'Citizen applying for licenses',
|
||||
description: 'Citizen applicant',
|
||||
icon: 'person',
|
||||
},
|
||||
];
|
||||
@@ -384,13 +507,12 @@ export class EmailLoginComponent {
|
||||
|
||||
getRoleColor(role: string): string {
|
||||
const colors: { [key: string]: string } = {
|
||||
Admin: '#d32f2f',
|
||||
'Fire Department': '#f57c00',
|
||||
Tourism: '#1976d2',
|
||||
Municipality: '#388e3c',
|
||||
Citizen: '#7b1fa2',
|
||||
Admin: '#f87171',
|
||||
'Fire Dept': '#fb923c',
|
||||
Tourism: '#60a5fa',
|
||||
Citizen: '#a78bfa',
|
||||
};
|
||||
return colors[role] || '#666';
|
||||
return colors[role] || '#818cf8';
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
@@ -399,9 +521,8 @@ export class EmailLoginComponent {
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
// Sanitize inputs to prevent XSS/injection attacks
|
||||
const email = InputSanitizer.sanitizeEmail(this.loginForm.value.email || '');
|
||||
const password = this.loginForm.value.password || ''; // Don't sanitize password, just validate length
|
||||
const password = this.loginForm.value.password || '';
|
||||
|
||||
try {
|
||||
await this.authService.login(email, password);
|
||||
@@ -410,15 +531,8 @@ export class EmailLoginComponent {
|
||||
panelClass: ['success-snackbar'],
|
||||
});
|
||||
|
||||
// Navigate based on user role
|
||||
const user = this.authService.currentUser();
|
||||
if (user?.role === 'ADMIN' || user?.type === 'ADMIN') {
|
||||
this.router.navigate(['/admin']);
|
||||
} else if (user?.role === 'DEPARTMENT' || user?.type === 'DEPARTMENT') {
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
// All users go to dashboard after login
|
||||
this.router.navigate(['/dashboard']);
|
||||
} catch (error: any) {
|
||||
this.snackBar.open(
|
||||
error?.error?.message || 'Invalid email or password',
|
||||
|
||||
@@ -1,67 +1,69 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
interface DemoCredential {
|
||||
role: string;
|
||||
credential: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-login-select',
|
||||
standalone: true,
|
||||
imports: [RouterModule, MatButtonModule, MatIconModule, MatRippleModule],
|
||||
imports: [CommonModule, RouterModule, MatButtonModule, MatIconModule, MatRippleModule],
|
||||
template: `
|
||||
<div class="login-select">
|
||||
<!-- Header -->
|
||||
<div class="login-header">
|
||||
<h1 class="login-title">Welcome Back</h1>
|
||||
<p class="login-subtitle">Select your login method to continue</p>
|
||||
<div class="header-glow"></div>
|
||||
<h1 class="login-title">Sign In</h1>
|
||||
<p class="login-subtitle">Select your authentication method</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Options -->
|
||||
<div class="login-options">
|
||||
<div class="login-options" role="list" aria-label="Login options">
|
||||
<!-- Department Login -->
|
||||
<a
|
||||
class="login-option department"
|
||||
class="login-option"
|
||||
[routerLink]="['department']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(99, 102, 241, 0.1)'"
|
||||
[matRippleColor]="'rgba(99, 102, 241, 0.2)'"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="option-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h3 class="option-title">Department Login</h3>
|
||||
<p class="option-desc">For government department officials</p>
|
||||
<div class="option-badge">
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
<span>API Key Authentication</span>
|
||||
</div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">Department</span>
|
||||
<span class="option-desc">Government officials</span>
|
||||
</div>
|
||||
<mat-icon class="option-arrow">arrow_forward</mat-icon>
|
||||
<mat-icon class="option-arrow">chevron_right</mat-icon>
|
||||
</a>
|
||||
|
||||
<!-- DigiLocker Login -->
|
||||
<!-- Citizen Login -->
|
||||
<a
|
||||
class="login-option citizen"
|
||||
[routerLink]="['digilocker']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(16, 185, 129, 0.1)'"
|
||||
[matRippleColor]="'rgba(16, 185, 129, 0.2)'"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="option-icon-wrapper citizen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h3 class="option-title">Citizen Login</h3>
|
||||
<p class="option-desc">For citizens and applicants via DigiLocker</p>
|
||||
<div class="option-badge citizen">
|
||||
<mat-icon>fingerprint</mat-icon>
|
||||
<span>DigiLocker Verified</span>
|
||||
</div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">Citizen</span>
|
||||
<span class="option-desc">DigiLocker verification</span>
|
||||
</div>
|
||||
<mat-icon class="option-arrow">arrow_forward</mat-icon>
|
||||
<mat-icon class="option-arrow">chevron_right</mat-icon>
|
||||
</a>
|
||||
|
||||
<!-- Admin Login -->
|
||||
@@ -69,48 +71,94 @@ import { MatRippleModule } from '@angular/material/core';
|
||||
class="login-option admin"
|
||||
[routerLink]="['email']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(139, 92, 246, 0.1)'"
|
||||
[matRippleColor]="'rgba(168, 85, 247, 0.2)'"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="option-icon-wrapper admin">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 11c.34 0 .67.04 1 .09V6.27L10.5 3 3 6.27v4.91c0 4.54 3.2 8.79 7.5 9.82.55-.13 1.08-.32 1.6-.55-.69-.98-1.1-2.17-1.1-3.45 0-3.31 2.69-6 6-6z"/>
|
||||
<path d="M17 13c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 1.38c.62 0 1.12.51 1.12 1.12s-.51 1.12-1.12 1.12-1.12-.51-1.12-1.12.5-1.12 1.12-1.12zm0 5.37c-.93 0-1.74-.46-2.24-1.17.05-.72 1.51-1.08 2.24-1.08s2.19.36 2.24 1.08c-.5.71-1.31 1.17-2.24 1.17z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h3 class="option-title">Administrator</h3>
|
||||
<p class="option-desc">Platform administrators and super users</p>
|
||||
<div class="option-badge admin">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
<span>Privileged Access</span>
|
||||
</div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">Administrator</span>
|
||||
<span class="option-desc">Platform admins</span>
|
||||
</div>
|
||||
<mat-icon class="option-arrow">arrow_forward</mat-icon>
|
||||
<mat-icon class="option-arrow">chevron_right</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="help-section">
|
||||
<p class="help-text">
|
||||
Need help signing in?
|
||||
<a href="#" class="help-link">Contact Support</a>
|
||||
</p>
|
||||
<!-- Demo Credentials Toggle -->
|
||||
<button class="demo-trigger" (click)="showDemoCredentials.set(!showDemoCredentials())">
|
||||
<mat-icon>{{ showDemoCredentials() ? 'expand_less' : 'science' }}</mat-icon>
|
||||
<span>{{ showDemoCredentials() ? 'Hide Demo Credentials' : 'View Demo Credentials' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Collapsible Demo Credentials Panel -->
|
||||
<div class="demo-panel" [class.expanded]="showDemoCredentials()">
|
||||
<div class="demo-panel-content">
|
||||
<!-- Department Credentials -->
|
||||
<div class="demo-section">
|
||||
<div class="demo-section-header">
|
||||
<span class="demo-section-icon dept"></span>
|
||||
<span class="demo-section-title">Department</span>
|
||||
</div>
|
||||
<div class="demo-creds">
|
||||
<div class="cred-item" *ngFor="let cred of departmentCredentials">
|
||||
<span class="cred-label">{{ cred.role }}</span>
|
||||
<code class="cred-value">{{ cred.credential }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Citizen Credentials -->
|
||||
<div class="demo-section">
|
||||
<div class="demo-section-header">
|
||||
<span class="demo-section-icon citizen"></span>
|
||||
<span class="demo-section-title">Citizen</span>
|
||||
</div>
|
||||
<div class="demo-creds">
|
||||
<div class="cred-item" *ngFor="let cred of citizenCredentials">
|
||||
<span class="cred-label">{{ cred.role }}</span>
|
||||
<code class="cred-value">{{ cred.credential }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Credentials -->
|
||||
<div class="demo-section">
|
||||
<div class="demo-section-header">
|
||||
<span class="demo-section-icon admin"></span>
|
||||
<span class="demo-section-title">Admin</span>
|
||||
</div>
|
||||
<div class="demo-creds">
|
||||
<div class="cred-item" *ngFor="let cred of adminCredentials">
|
||||
<span class="cred-label">{{ cred.role }}</span>
|
||||
<code class="cred-value">{{ cred.credential }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="login-footer">
|
||||
<a href="#" class="help-link">
|
||||
<mat-icon>help_outline</mat-icon>
|
||||
<span>Need help?</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
// =============================================================================
|
||||
// LOGIN SELECT - DBIM Compliant World-Class Design
|
||||
// LOGIN SELECT - Dark Glass-Morphism Theme
|
||||
// Matches the blockchain landing page design
|
||||
// =============================================================================
|
||||
|
||||
.login-select {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -119,19 +167,34 @@ import { MatRippleModule } from '@angular/material/core';
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-glow {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
background: radial-gradient(ellipse, rgba(99, 102, 241, 0.2) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
background: linear-gradient(135deg, #FFFFFF 0%, #c7d2fe 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -149,34 +212,33 @@ import { MatRippleModule } from '@angular/material/core';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
padding: 16px 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Left accent line
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
top: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--crypto-indigo, #6366F1) 100%);
|
||||
background: linear-gradient(180deg, #6366F1 0%, #818cf8 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 20px rgba(29, 10, 105, 0.1);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
@@ -184,153 +246,264 @@ import { MatRippleModule } from '@angular/material/core';
|
||||
|
||||
.option-arrow {
|
||||
transform: translateX(4px);
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
|
||||
&.citizen {
|
||||
&::before {
|
||||
background: linear-gradient(180deg, #059669 0%, #10B981 100%);
|
||||
background: linear-gradient(180deg, #10B981 0%, #34d399 100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
|
||||
.option-arrow {
|
||||
color: #059669;
|
||||
color: #34d399;
|
||||
}
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(52, 211, 153, 0.1) 100%);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
|
||||
svg {
|
||||
color: #34d399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.admin {
|
||||
&::before {
|
||||
background: linear-gradient(180deg, #7C3AED 0%, #8B5CF6 100%);
|
||||
background: linear-gradient(180deg, #A855F7 0%, #c084fc 100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
border-color: rgba(168, 85, 247, 0.3);
|
||||
|
||||
.option-arrow {
|
||||
color: #7C3AED;
|
||||
color: #c084fc;
|
||||
}
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
background: linear-gradient(135deg, rgba(168, 85, 247, 0.2) 0%, rgba(192, 132, 252, 0.1) 100%);
|
||||
border-color: rgba(168, 85, 247, 0.3);
|
||||
|
||||
svg {
|
||||
color: #c084fc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICON WRAPPER
|
||||
// =============================================================================
|
||||
.option-icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
.option-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--crypto-indigo, #6366F1) 100%);
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(129, 140, 248, 0.1) 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
|
||||
svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.citizen {
|
||||
background: linear-gradient(135deg, #059669 0%, #10B981 100%);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
&.admin {
|
||||
background: linear-gradient(135deg, #7C3AED 0%, #8B5CF6 100%);
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONTENT
|
||||
// =============================================================================
|
||||
.option-content {
|
||||
.option-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.option-title {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
margin: 0 0 4px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: inline-flex;
|
||||
.option-arrow {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.25s ease;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEMO CREDENTIALS TRIGGER
|
||||
// =============================================================================
|
||||
.demo-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COLLAPSIBLE DEMO PANEL
|
||||
// =============================================================================
|
||||
.demo-panel {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.expanded {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-panel-content {
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.demo-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.demo-section-icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #818cf8;
|
||||
|
||||
&.citizen {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
background: #34d399;
|
||||
}
|
||||
|
||||
&.admin {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #7C3AED;
|
||||
background: #c084fc;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ARROW
|
||||
// =============================================================================
|
||||
.option-arrow {
|
||||
color: var(--dbim-grey-1, #C6C6C6);
|
||||
transition: all 0.2s ease;
|
||||
.demo-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.demo-creds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cred-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cred-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELP SECTION
|
||||
// =============================================================================
|
||||
.help-section {
|
||||
text-align: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
.cred-value {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
margin: 0;
|
||||
// =============================================================================
|
||||
// FOOTER
|
||||
// =============================================================================
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-link {
|
||||
color: var(--dbim-info, #0D6EFD);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class LoginSelectComponent {}
|
||||
export class LoginSelectComponent {
|
||||
showDemoCredentials = signal(false);
|
||||
|
||||
departmentCredentials: DemoCredential[] = [
|
||||
{ role: 'Transport', credential: 'DEPT-TRANS-001', description: '' },
|
||||
{ role: 'Health', credential: 'DEPT-HEALTH-001', description: '' },
|
||||
{ role: 'Revenue', credential: 'DEPT-REV-001', description: '' }
|
||||
];
|
||||
|
||||
citizenCredentials: DemoCredential[] = [
|
||||
{ role: 'Demo', credential: 'Any 12-digit AADHAAR', description: '' }
|
||||
];
|
||||
|
||||
adminCredentials: DemoCredential[] = [
|
||||
{ role: 'Admin', credential: 'admin@goa.gov.in / Admin@123', description: '' }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="status-grid">
|
||||
@for (item of stats()!.requestsByStatus; track item.status) {
|
||||
@for (item of (stats()?.requestsByStatus || []); track item.status) {
|
||||
<div class="status-item" [routerLink]="['/requests']" [queryParams]="{ status: item.status }">
|
||||
<app-status-badge [status]="item.status" />
|
||||
<span class="count">{{ item.count }}</span>
|
||||
@@ -205,23 +205,13 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
|
||||
/* Welcome Section */
|
||||
.welcome-section {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%) !important;
|
||||
color: white !important;
|
||||
padding: 32px;
|
||||
margin: -24px -24px 24px -24px;
|
||||
margin: 0 0 24px 0;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 50%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
@@ -233,32 +223,29 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
.greeting {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.welcome-text .greeting {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.welcome-text h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.welcome-text .subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
@@ -266,24 +253,22 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
&.primary {
|
||||
background: white;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: white !important;
|
||||
color: #1D0A69 !important;
|
||||
}
|
||||
|
||||
&:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
.action-btn:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
.action-btn:not(.primary):hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.action-btn mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -293,10 +278,10 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
justify-content: center;
|
||||
padding: 64px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
}
|
||||
.loading-container p {
|
||||
color: #8E8E8E;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
@@ -320,54 +305,57 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
background: white !important;
|
||||
color: #150202;
|
||||
border: 1px solid #EBEAEA;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-elevated, 0 8px 24px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
&.requests {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
}
|
||||
|
||||
&.approvals {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
&.documents {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
|
||||
&.departments {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
&.applicants {
|
||||
background: linear-gradient(135deg, #0891b2 0%, #22d3ee 100%);
|
||||
}
|
||||
|
||||
&.blockchain {
|
||||
background: linear-gradient(135deg, #475569 0%, #64748b 100%);
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(29, 10, 105, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
color: #1D0A69;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.stat-icon-wrapper mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.stat-card.approvals .stat-icon-wrapper {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.stat-card.documents .stat-icon-wrapper {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat-card.departments .stat-icon-wrapper {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.stat-card.applicants .stat-icon-wrapper {
|
||||
background: rgba(8, 145, 178, 0.1);
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.stat-card.blockchain .stat-icon-wrapper {
|
||||
background: rgba(71, 85, 105, 0.1);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
@@ -379,22 +367,17 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
color: #8E8E8E;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-decoration {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
@@ -402,10 +385,6 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.content-main {
|
||||
@@ -423,31 +402,31 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
border-bottom: 1px solid #EBEAEA;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.card-header .header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
color: var(--dbim-blue-mid, #2563EB);
|
||||
}
|
||||
.card-header .header-left mat-icon {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
}
|
||||
.card-header .header-left h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.card-header button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@@ -465,20 +444,20 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
background: #F5F5F5;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.status-item:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
.status-item .count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
@@ -486,10 +465,6 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
@@ -502,16 +477,16 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
}
|
||||
.action-item:hover {
|
||||
background: #F5F5F5;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dbim-brown, #150202);
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-item span {
|
||||
font-size: 0.85rem;
|
||||
color: #150202;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
@@ -521,39 +496,57 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.action-icon mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&.departments {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
color: white;
|
||||
}
|
||||
.action-icon.departments {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.workflows {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69), var(--dbim-blue-mid, #2563EB));
|
||||
color: white;
|
||||
}
|
||||
.action-icon.workflows {
|
||||
background: linear-gradient(135deg, #1D0A69, #2563EB);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.audit {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
.action-icon.audit {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.webhooks {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
.action-icon.webhooks {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content-sidebar {
|
||||
@media (max-width: 1200px) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.content-sidebar {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.welcome-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.actions-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AdminDashboardComponent implements OnInit {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { BlockchainExplorerMiniComponent } from '../../../shared/components/blockchain-explorer-mini/blockchain-explorer-mini.component';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
@@ -36,7 +35,6 @@ interface ApplicantStats {
|
||||
MatTooltipModule,
|
||||
MatChipsModule,
|
||||
StatusBadgeComponent,
|
||||
BlockchainExplorerMiniComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
@@ -205,10 +203,6 @@ interface ApplicantStats {
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Blockchain Activity -->
|
||||
<div class="content-sidebar">
|
||||
<app-blockchain-explorer-mini [showViewAll]="false" [refreshInterval]="30000"></app-blockchain-explorer-mini>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -219,23 +213,23 @@ interface ApplicantStats {
|
||||
|
||||
/* Welcome Section */
|
||||
.welcome-section {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
margin: -24px -24px 24px -24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 50%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.welcome-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 50%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
@@ -247,61 +241,48 @@ interface ApplicantStats {
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
.greeting {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.welcome-text .greeting {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.welcome-text h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.welcome-text .subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
&.primary {
|
||||
background: white;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: white !important;
|
||||
color: #1D0A69 !important;
|
||||
}
|
||||
|
||||
&:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
.action-btn:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
.action-btn:not(.primary):hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.action-btn mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
@@ -326,27 +307,27 @@ interface ApplicantStats {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-elevated, 0 8px 24px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
.stat-card.pending {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%) !important;
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
}
|
||||
.stat-card.approved {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%) !important;
|
||||
}
|
||||
|
||||
&.documents {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
}
|
||||
.stat-card.documents {
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%) !important;
|
||||
}
|
||||
|
||||
&.blockchain {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
}
|
||||
.stat-card.blockchain {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%) !important;
|
||||
}
|
||||
|
||||
.stat-icon-wrapper {
|
||||
@@ -359,12 +340,12 @@ interface ApplicantStats {
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.stat-icon-wrapper mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
@@ -397,12 +378,8 @@ interface ApplicantStats {
|
||||
/* Content Grid */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.content-main {
|
||||
@@ -420,31 +397,31 @@ interface ApplicantStats {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
border-bottom: 1px solid #EBEAEA;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.card-header .header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
color: var(--dbim-blue-mid, #2563EB);
|
||||
}
|
||||
.card-header .header-left mat-icon {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
}
|
||||
.card-header .header-left h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.card-header button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@@ -462,19 +439,19 @@ interface ApplicantStats {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
color: #8E8E8E;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.empty-state-inline mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.empty-state-inline p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* Requests List */
|
||||
@@ -490,17 +467,17 @@ interface ApplicantStats {
|
||||
padding: 16px 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
border-bottom: 1px solid #EBEAEA;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.request-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
margin: 0 -24px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
.request-item:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
margin: 0 -24px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.request-left {
|
||||
@@ -516,37 +493,37 @@ interface ApplicantStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.request-icon mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
&.draft {
|
||||
background: rgba(158, 158, 158, 0.1);
|
||||
color: #9e9e9e;
|
||||
}
|
||||
.request-icon.draft {
|
||||
background: rgba(158, 158, 158, 0.1);
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
&.submitted {
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
color: #2196f3;
|
||||
}
|
||||
.request-icon.submitted {
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
&.in-review {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: #ff9800;
|
||||
}
|
||||
.request-icon.in-review {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
}
|
||||
.request-icon.approved {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
}
|
||||
.request-icon.rejected {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.request-info {
|
||||
@@ -557,12 +534,12 @@ interface ApplicantStats {
|
||||
|
||||
.request-number {
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
.request-type {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
color: #8E8E8E;
|
||||
}
|
||||
|
||||
.request-right {
|
||||
@@ -573,11 +550,11 @@ interface ApplicantStats {
|
||||
|
||||
.request-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
color: #8E8E8E;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dbim-grey-1, #C6C6C6);
|
||||
color: #C6C6C6;
|
||||
}
|
||||
|
||||
/* Quick Actions Card */
|
||||
@@ -585,10 +562,6 @@ interface ApplicantStats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
@@ -601,16 +574,16 @@ interface ApplicantStats {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
}
|
||||
.action-item:hover {
|
||||
background: #F5F5F5;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dbim-brown, #150202);
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-item span {
|
||||
font-size: 0.85rem;
|
||||
color: #150202;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
@@ -620,37 +593,45 @@ interface ApplicantStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&.license {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69), var(--dbim-blue-mid, #2563EB));
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.renewal {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.track {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.help {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.content-sidebar {
|
||||
@media (max-width: 1200px) {
|
||||
order: -1;
|
||||
.action-icon mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.action-icon.license {
|
||||
background: linear-gradient(135deg, #1D0A69, #2563EB);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-icon.renewal {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-icon.track {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-icon.help {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.welcome-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.quick-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.actions-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
`],
|
||||
|
||||
@@ -59,7 +59,7 @@ export class DepartmentService {
|
||||
return throwError(() => new Error('Limit must be between 1 and 100'));
|
||||
}
|
||||
|
||||
return this.api.get<ApiPaginatedResponse>('/departments', { page, limit }).pipe(
|
||||
return this.api.getRaw<ApiPaginatedResponse>('/departments', { page, limit }).pipe(
|
||||
map((response: ApiPaginatedResponse) => {
|
||||
const data = response?.data ?? [];
|
||||
const meta = response?.meta;
|
||||
@@ -86,7 +86,7 @@ export class DepartmentService {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return this.api.get<DepartmentResponseDto>(`/departments/${id.trim()}`).pipe(
|
||||
return this.api.getRaw<DepartmentResponseDto>(`/departments/${id.trim()}`).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to fetch department with ID: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
@@ -101,7 +101,7 @@ export class DepartmentService {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return this.api.get<DepartmentResponseDto>(`/departments/code/${encodeURIComponent(code.trim())}`).pipe(
|
||||
return this.api.getRaw<DepartmentResponseDto>(`/departments/code/${encodeURIComponent(code.trim())}`).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to fetch department with code: ${code}`;
|
||||
return throwError(() => new Error(message));
|
||||
@@ -114,7 +114,7 @@ export class DepartmentService {
|
||||
return throwError(() => new Error('Department data is required'));
|
||||
}
|
||||
|
||||
return this.api.post<CreateDepartmentWithCredentialsResponse>('/departments', dto).pipe(
|
||||
return this.api.postRaw<CreateDepartmentWithCredentialsResponse>('/departments', dto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create department';
|
||||
return throwError(() => new Error(message));
|
||||
|
||||
@@ -853,15 +853,18 @@ export class DocumentUploadComponent {
|
||||
readonly hashCopied = signal(false);
|
||||
|
||||
readonly documentTypes: { value: DocumentType; label: string; icon: string }[] = [
|
||||
{ value: 'FIRE_SAFETY_CERTIFICATE', label: 'Fire Safety Certificate', icon: 'local_fire_department' },
|
||||
{ value: 'BUILDING_PLAN', label: 'Building Plan', icon: 'apartment' },
|
||||
{ value: 'PROPERTY_OWNERSHIP', label: 'Property Ownership', icon: 'home' },
|
||||
{ value: 'INSPECTION_REPORT', label: 'Inspection Report', icon: 'fact_check' },
|
||||
{ value: 'POLLUTION_CERTIFICATE', label: 'Pollution Certificate', icon: 'eco' },
|
||||
{ value: 'ELECTRICAL_SAFETY_CERTIFICATE', label: 'Electrical Safety Certificate', icon: 'electrical_services' },
|
||||
{ value: 'STRUCTURAL_STABILITY_CERTIFICATE', label: 'Structural Stability Certificate', icon: 'foundation' },
|
||||
{ value: 'IDENTITY_PROOF', label: 'Identity Proof', icon: 'badge' },
|
||||
{ value: 'ID_PROOF', label: 'Identity Proof', icon: 'badge' },
|
||||
{ value: 'ADDRESS_PROOF', label: 'Address Proof', icon: 'location_on' },
|
||||
{ value: 'FIRE_SAFETY', label: 'Fire Safety Certificate', icon: 'local_fire_department' },
|
||||
{ value: 'FLOOR_PLAN', label: 'Floor Plan', icon: 'apartment' },
|
||||
{ value: 'SITE_PLAN', label: 'Site Plan', icon: 'map' },
|
||||
{ value: 'BUILDING_PERMIT', label: 'Building Permit', icon: 'home_work' },
|
||||
{ value: 'BUSINESS_LICENSE', label: 'Business License', icon: 'storefront' },
|
||||
{ value: 'PHOTOGRAPH', label: 'Photograph', icon: 'photo_camera' },
|
||||
{ value: 'NOC', label: 'No Objection Certificate', icon: 'verified' },
|
||||
{ value: 'LICENSE_COPY', label: 'License Copy', icon: 'file_copy' },
|
||||
{ value: 'HEALTH_CERT', label: 'Health Certificate', icon: 'health_and_safety' },
|
||||
{ value: 'TAX_CLEARANCE', label: 'Tax Clearance', icon: 'receipt_long' },
|
||||
{ value: 'OTHER', label: 'Other Document', icon: 'description' },
|
||||
];
|
||||
|
||||
|
||||
@@ -89,16 +89,19 @@ function validateDocType(docType: DocumentType | string | undefined | null): Doc
|
||||
}
|
||||
|
||||
const validDocTypes: DocumentType[] = [
|
||||
'FIRE_SAFETY_CERTIFICATE',
|
||||
'BUILDING_PLAN',
|
||||
'PROPERTY_OWNERSHIP',
|
||||
'INSPECTION_REPORT',
|
||||
'POLLUTION_CERTIFICATE',
|
||||
'ELECTRICAL_SAFETY_CERTIFICATE',
|
||||
'STRUCTURAL_STABILITY_CERTIFICATE',
|
||||
'IDENTITY_PROOF',
|
||||
'FLOOR_PLAN',
|
||||
'PHOTOGRAPH',
|
||||
'ID_PROOF',
|
||||
'ADDRESS_PROOF',
|
||||
'NOC',
|
||||
'LICENSE_COPY',
|
||||
'OTHER',
|
||||
'FIRE_SAFETY',
|
||||
'HEALTH_CERT',
|
||||
'TAX_CLEARANCE',
|
||||
'SITE_PLAN',
|
||||
'BUILDING_PERMIT',
|
||||
'BUSINESS_LICENSE',
|
||||
];
|
||||
|
||||
if (!validDocTypes.includes(docType as DocumentType)) {
|
||||
|
||||
@@ -473,10 +473,16 @@ export class RequestCreateComponent implements OnInit {
|
||||
|
||||
this.requestService
|
||||
.createRequest({
|
||||
applicantId: user.id,
|
||||
applicantName: metadata.ownerName,
|
||||
applicantPhone: metadata.ownerPhone,
|
||||
businessName: metadata.businessName,
|
||||
requestType: basic.requestType,
|
||||
workflowId: basic.workflowId,
|
||||
metadata,
|
||||
metadata: {
|
||||
businessAddress: metadata.businessAddress,
|
||||
ownerEmail: metadata.ownerEmail,
|
||||
description: metadata.description,
|
||||
},
|
||||
})
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
|
||||
@@ -160,6 +160,12 @@
|
||||
<span style="margin-left: 8px">Documents ({{ detailedDocuments().length || 0 }})</span>
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<div class="documents-header" style="display: flex; justify-content: flex-end; margin-bottom: 16px;">
|
||||
<button mat-raised-button color="primary" (click)="openUploadDialog()">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
Upload Document
|
||||
</button>
|
||||
</div>
|
||||
@if (loadingDocuments()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
@@ -175,41 +181,14 @@
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon>how_to_reg</mat-icon>
|
||||
<span style="margin-left: 8px">Approvals ({{ req.approvals.length || 0 }})</span>
|
||||
<span style="margin-left: 8px">Approvals ({{ req.approvals?.length || 0 }})</span>
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
@if (req.approvals && req.approvals.length > 0) {
|
||||
<div class="approvals-timeline">
|
||||
@for (approval of req.approvals; track approval.id) {
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker" [ngClass]="{
|
||||
'approved': approval.status === 'APPROVED',
|
||||
'pending': approval.status === 'REVIEW_REQUIRED' || approval.status === 'CHANGES_REQUESTED',
|
||||
'rejected': approval.status === 'REJECTED'
|
||||
}">
|
||||
@if (approval.status === 'APPROVED') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (approval.status === 'REJECTED') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="dept-name">{{ formatDepartmentId(approval.departmentId) }}</span>
|
||||
<span class="timeline-time">{{ approval.createdAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
<app-status-badge [status]="approval.status" />
|
||||
@if (approval.remarks) {
|
||||
<div class="timeline-remarks">
|
||||
<strong>Remarks:</strong> {{ approval.remarks }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-approval-workflow-timeline
|
||||
[requestId]="req.id"
|
||||
[txHash]="req.blockchainTxHash"
|
||||
[tokenId]="req.tokenId" />
|
||||
} @else {
|
||||
<div class="empty-state-card">
|
||||
<mat-icon>pending_actions</mat-icon>
|
||||
|
||||
@@ -14,11 +14,13 @@ import { StatusBadgeComponent } from '../../../shared/components/status-badge/st
|
||||
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog.component';
|
||||
import { BlockchainInfoComponent } from '../../../shared/components/blockchain-info/blockchain-info.component';
|
||||
import { DocumentViewerComponent } from '../../../shared/components/document-viewer/document-viewer.component';
|
||||
import { ApprovalWorkflowTimelineComponent } from '../../approvals/approval-workflow-timeline/approval-workflow-timeline.component';
|
||||
import { RequestService } from '../services/request.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { RequestDetailResponseDto } from '../../../api/models';
|
||||
import { DocumentUploadComponent, DocumentUploadDialogData } from '../../documents/document-upload/document-upload.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-request-detail',
|
||||
@@ -37,6 +39,7 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
StatusBadgeComponent,
|
||||
BlockchainInfoComponent,
|
||||
DocumentViewerComponent,
|
||||
ApprovalWorkflowTimelineComponent,
|
||||
],
|
||||
templateUrl: './request-detail.component.html',
|
||||
styles: [
|
||||
@@ -88,7 +91,7 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
.request-number {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
@@ -109,12 +113,13 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +138,7 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: white;
|
||||
|
||||
&.status-draft {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
@@ -152,6 +158,10 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
background: rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.actions button {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,11 +497,23 @@ export class RequestDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
private loadDetailedDocuments(requestId: string): void {
|
||||
this.loadingDocuments.set(true);
|
||||
this.api.get<any[]>(`/admin/documents/${requestId}`)
|
||||
this.api.get<any[]>(`/requests/${requestId}/documents`)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (documents) => {
|
||||
this.detailedDocuments.set(documents ?? []);
|
||||
// Map API response fields to what DocumentViewerComponent expects
|
||||
const mapped = (documents ?? []).map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.originalFilename || doc.name || 'Unknown',
|
||||
type: doc.docType || doc.type || 'OTHER',
|
||||
size: doc.fileSize || doc.size || 0,
|
||||
fileHash: doc.currentHash || doc.fileHash || '',
|
||||
url: doc.url || '',
|
||||
uploadedAt: doc.createdAt || doc.uploadedAt || new Date().toISOString(),
|
||||
uploadedBy: doc.uploadedBy || '',
|
||||
currentVersion: doc.currentVersion || 1,
|
||||
}));
|
||||
this.detailedDocuments.set(mapped);
|
||||
this.loadingDocuments.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -502,6 +524,25 @@ export class RequestDetailComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
openUploadDialog(): void {
|
||||
const req = this.request();
|
||||
if (!req) return;
|
||||
|
||||
const dialogRef = this.dialog.open(DocumentUploadComponent, {
|
||||
width: '560px',
|
||||
data: { requestId: req.id } as DocumentUploadDialogData,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result) => {
|
||||
if (result) {
|
||||
// Reload documents after successful upload
|
||||
this.loadDetailedDocuments(req.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitRequest(): void {
|
||||
const req = this.request();
|
||||
if (!req) return;
|
||||
|
||||
@@ -166,22 +166,27 @@ export class RequestService {
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!dto.applicantId || typeof dto.applicantId !== 'string' || dto.applicantId.trim().length === 0) {
|
||||
return throwError(() => new Error('Applicant ID is required'));
|
||||
if (!dto.applicantName || typeof dto.applicantName !== 'string' || dto.applicantName.trim().length < 2) {
|
||||
return throwError(() => new Error('Applicant name must be at least 2 characters'));
|
||||
}
|
||||
|
||||
if (!dto.requestType) {
|
||||
return throwError(() => new Error('Request type is required'));
|
||||
}
|
||||
|
||||
if (!dto.workflowId || typeof dto.workflowId !== 'string' || dto.workflowId.trim().length === 0) {
|
||||
return throwError(() => new Error('Workflow ID is required'));
|
||||
// Either workflowId or workflowCode must be provided
|
||||
if ((!dto.workflowId || dto.workflowId.trim().length === 0) &&
|
||||
(!dto.workflowCode || dto.workflowCode.trim().length === 0)) {
|
||||
return throwError(() => new Error('Workflow ID or code is required'));
|
||||
}
|
||||
|
||||
const sanitizedDto: CreateRequestDto = {
|
||||
applicantId: dto.applicantId.trim(),
|
||||
applicantName: dto.applicantName.trim(),
|
||||
applicantPhone: dto.applicantPhone?.trim(),
|
||||
businessName: dto.businessName?.trim(),
|
||||
requestType: dto.requestType,
|
||||
workflowId: dto.workflowId.trim(),
|
||||
workflowId: dto.workflowId?.trim(),
|
||||
workflowCode: dto.workflowCode?.trim(),
|
||||
metadata: dto.metadata ?? {},
|
||||
tokenId: dto.tokenId,
|
||||
};
|
||||
|
||||
@@ -75,11 +75,11 @@ import { WebhookResponseDto } from '../../../api/models';
|
||||
<th mat-header-cell *matHeaderCellDef>Events</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="events-chips">
|
||||
@for (event of row.events.slice(0, 2); track event) {
|
||||
@for (event of (row.events || []).slice(0, 2); track event) {
|
||||
<mat-chip>{{ formatEvent(event) }}</mat-chip>
|
||||
}
|
||||
@if (row.events.length > 2) {
|
||||
<mat-chip>+{{ row.events.length - 2 }}</mat-chip>
|
||||
@if ((row.events?.length || 0) > 2) {
|
||||
<mat-chip>+{{ (row.events?.length || 0) - 2 }}</mat-chip>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -54,8 +54,8 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
|
||||
throw new Error('Workflow name cannot exceed 200 characters');
|
||||
}
|
||||
|
||||
if (!dto.departmentId || typeof dto.departmentId !== 'string' || dto.departmentId.trim().length === 0) {
|
||||
throw new Error('Department ID is required');
|
||||
if (!dto.workflowType || typeof dto.workflowType !== 'string') {
|
||||
throw new Error('Workflow type is required');
|
||||
}
|
||||
|
||||
if (!dto.stages || !Array.isArray(dto.stages) || dto.stages.length === 0) {
|
||||
@@ -63,11 +63,11 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
|
||||
}
|
||||
|
||||
// Validate each stage
|
||||
dto.stages.forEach((stage, index) => {
|
||||
if (!stage.name || typeof stage.name !== 'string' || stage.name.trim().length === 0) {
|
||||
dto.stages.forEach((stage: any, index: number) => {
|
||||
if (!stage.stageName || typeof stage.stageName !== 'string' || stage.stageName.trim().length === 0) {
|
||||
throw new Error(`Stage ${index + 1}: Name is required`);
|
||||
}
|
||||
if (!stage.order || typeof stage.order !== 'number' || stage.order < 1) {
|
||||
if (typeof stage.stageOrder !== 'number' || stage.stageOrder < 1) {
|
||||
throw new Error(`Stage ${index + 1}: Valid order is required`);
|
||||
}
|
||||
});
|
||||
@@ -76,7 +76,6 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
|
||||
...dto,
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim() || undefined,
|
||||
departmentId: dto.departmentId.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +117,13 @@ function validateUpdateWorkflowDto(dto: UpdateWorkflowDto | null | undefined): U
|
||||
sanitized.stages = dto.stages;
|
||||
}
|
||||
|
||||
if (dto.metadata !== undefined) {
|
||||
if (typeof dto.metadata !== 'object' || dto.metadata === null) {
|
||||
throw new Error('Metadata must be an object');
|
||||
}
|
||||
sanitized.metadata = dto.metadata;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -131,7 +137,7 @@ export class WorkflowService {
|
||||
const validated = validatePagination(page, limit);
|
||||
|
||||
return this.api
|
||||
.get<PaginatedWorkflowsResponse>('/workflows', {
|
||||
.getRaw<PaginatedWorkflowsResponse>('/workflows', {
|
||||
page: validated.page,
|
||||
limit: validated.limit,
|
||||
})
|
||||
@@ -148,16 +154,13 @@ export class WorkflowService {
|
||||
try {
|
||||
const validId = validateId(id, 'Workflow ID');
|
||||
|
||||
return this.api.get<WorkflowResponseDto>(`/workflows/${validId}`).pipe(
|
||||
return this.api.getRaw<WorkflowResponseDto>(`/workflows/${validId}`).pipe(
|
||||
map((response) => {
|
||||
if (!response) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
// Ensure nested arrays are valid
|
||||
return {
|
||||
...response,
|
||||
stages: Array.isArray(response.stages) ? response.stages : [],
|
||||
};
|
||||
// Response structure has stages inside definition
|
||||
return response;
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to fetch workflow: ${id}`;
|
||||
@@ -173,7 +176,7 @@ export class WorkflowService {
|
||||
try {
|
||||
const sanitizedDto = validateCreateWorkflowDto(dto);
|
||||
|
||||
return this.api.post<WorkflowResponseDto>('/workflows', sanitizedDto).pipe(
|
||||
return this.api.postRaw<WorkflowResponseDto>('/workflows', sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create workflow';
|
||||
return throwError(() => new Error(message));
|
||||
|
||||
@@ -21,7 +21,7 @@ import { WorkflowService } from '../services/workflow.service';
|
||||
import { DepartmentService } from '../../departments/services/department.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { WorkflowResponseDto, WorkflowStage, DepartmentResponseDto } from '../../../api/models';
|
||||
import { WorkflowResponseDto, DepartmentResponseDto } from '../../../api/models';
|
||||
|
||||
// Node position interface for canvas positioning
|
||||
interface NodePosition {
|
||||
@@ -29,13 +29,20 @@ interface NodePosition {
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Extended stage with visual properties
|
||||
interface VisualStage extends WorkflowStage {
|
||||
// Visual stage interface for canvas display (decoupled from backend model)
|
||||
interface VisualStage {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
departmentId: string;
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
position: NodePosition;
|
||||
isSelected: boolean;
|
||||
isStartNode?: boolean;
|
||||
isEndNode?: boolean;
|
||||
connections: string[]; // IDs of connected stages (outgoing)
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Connection between stages
|
||||
@@ -152,7 +159,7 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
private loadDepartments(): void {
|
||||
this.departmentService.getDepartments(1, 100).subscribe({
|
||||
next: (response) => {
|
||||
this.departments.set(response.data);
|
||||
this.departments.set(response?.data ?? []);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -168,15 +175,31 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
isActive: workflow.isActive,
|
||||
});
|
||||
|
||||
// Convert stages to visual stages with positions
|
||||
const visualStages = workflow.stages.map((stage, index) => ({
|
||||
...stage,
|
||||
position: this.calculateStagePosition(index, workflow.stages.length),
|
||||
isSelected: false,
|
||||
isStartNode: index === 0,
|
||||
isEndNode: index === workflow.stages.length - 1,
|
||||
connections: index < workflow.stages.length - 1 ? [workflow.stages[index + 1].id] : [],
|
||||
}));
|
||||
// Get stages from definition (backend format)
|
||||
const backendStages = workflow.definition?.stages || [];
|
||||
|
||||
// Convert backend stages to visual stages with positions
|
||||
const visualStages: VisualStage[] = backendStages.map((stage, index) => {
|
||||
// Find department by code to get departmentId
|
||||
const dept = this.departments().find(d =>
|
||||
d.code === stage.requiredApprovals?.[0]?.departmentCode
|
||||
);
|
||||
|
||||
return {
|
||||
id: stage.stageId,
|
||||
name: stage.stageName,
|
||||
description: stage.metadata?.['description'] || '',
|
||||
departmentId: dept?.id || '',
|
||||
order: stage.stageOrder,
|
||||
isRequired: stage.metadata?.['isRequired'] ?? true,
|
||||
position: stage.metadata?.['position'] || this.calculateStagePosition(index, backendStages.length),
|
||||
isSelected: false,
|
||||
isStartNode: index === 0,
|
||||
isEndNode: index === backendStages.length - 1,
|
||||
connections: index < backendStages.length - 1 ? [backendStages[index + 1].stageId] : [],
|
||||
metadata: stage.metadata,
|
||||
};
|
||||
});
|
||||
|
||||
this.stages.set(visualStages);
|
||||
this.rebuildConnections();
|
||||
@@ -513,24 +536,34 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
const departmentId = currentUser?.departmentId ||
|
||||
this.stages().find(s => s.departmentId)?.departmentId || '';
|
||||
|
||||
const dto = {
|
||||
// Transform visual stages to backend DTO format
|
||||
const dto: any = {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description || undefined,
|
||||
workflowType: workflowData.workflowType,
|
||||
departmentId: departmentId,
|
||||
stages: this.stages().map((s, index) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
departmentId: s.departmentId,
|
||||
order: index + 1,
|
||||
isRequired: s.isRequired,
|
||||
metadata: {
|
||||
...s.metadata,
|
||||
position: s.position,
|
||||
connections: s.connections,
|
||||
},
|
||||
})),
|
||||
stages: this.stages().map((s, index) => {
|
||||
const department = this.departments().find(d => d.id === s.departmentId);
|
||||
return {
|
||||
stageId: s.id,
|
||||
stageName: s.name,
|
||||
stageOrder: index, // Backend expects 0-indexed
|
||||
executionType: s.metadata?.['executionType'] || 'SEQUENTIAL',
|
||||
requiredApprovals: [{
|
||||
departmentCode: department?.code || '',
|
||||
departmentName: department?.name || 'Unknown',
|
||||
canDelegate: false,
|
||||
}],
|
||||
completionCriteria: s.metadata?.['completionCriteria'] || 'ALL',
|
||||
rejectionHandling: 'FAIL_REQUEST',
|
||||
metadata: {
|
||||
description: s.description,
|
||||
position: s.position,
|
||||
connections: s.connections,
|
||||
timeoutHours: s.metadata?.['timeoutHours'],
|
||||
isRequired: s.isRequired,
|
||||
},
|
||||
};
|
||||
}),
|
||||
metadata: {
|
||||
visualLayout: {
|
||||
stages: this.stages().map(s => ({
|
||||
|
||||
@@ -304,7 +304,7 @@ export class WorkflowFormComponent implements OnInit {
|
||||
private loadDepartments(): void {
|
||||
this.departmentService.getDepartments(1, 100).subscribe({
|
||||
next: (response) => {
|
||||
this.departments.set(response.data);
|
||||
this.departments.set(response?.data ?? []);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -322,14 +322,19 @@ export class WorkflowFormComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.stagesArray.clear();
|
||||
workflow.stages.forEach((stage) => {
|
||||
const stages = workflow.definition?.stages || [];
|
||||
stages.forEach((stage) => {
|
||||
// Find department by code to get departmentId
|
||||
const dept = this.departments().find(d =>
|
||||
d.code === stage.requiredApprovals?.[0]?.departmentCode
|
||||
);
|
||||
this.stagesArray.push(
|
||||
this.fb.group({
|
||||
id: [stage.id],
|
||||
name: [stage.name, Validators.required],
|
||||
departmentId: [stage.departmentId, Validators.required],
|
||||
order: [stage.order],
|
||||
isRequired: [stage.isRequired],
|
||||
id: [stage.stageId],
|
||||
name: [stage.stageName, Validators.required],
|
||||
departmentId: [dept?.id || '', Validators.required],
|
||||
order: [stage.stageOrder],
|
||||
isRequired: [stage.metadata?.['isRequired'] ?? true],
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -414,18 +419,30 @@ export class WorkflowFormComponent implements OnInit {
|
||||
const departmentId = currentUser?.departmentId ||
|
||||
(values.stages[0]?.departmentId) || '';
|
||||
|
||||
const dto = {
|
||||
// Transform to backend DTO format
|
||||
const dto: any = {
|
||||
name: normalizeWhitespace(values.name),
|
||||
description: normalizeWhitespace(values.description) || undefined,
|
||||
workflowType: values.workflowType!,
|
||||
departmentId: departmentId,
|
||||
stages: values.stages.map((s, i) => ({
|
||||
id: s.id || `stage-${i + 1}`,
|
||||
name: normalizeWhitespace(s.name) || `Stage ${i + 1}`,
|
||||
departmentId: s.departmentId || '',
|
||||
isRequired: s.isRequired ?? true,
|
||||
order: i + 1,
|
||||
})),
|
||||
stages: values.stages.map((s, i) => {
|
||||
const department = this.departments().find(d => d.id === s.departmentId);
|
||||
return {
|
||||
stageId: s.id || `stage-${i + 1}`,
|
||||
stageName: normalizeWhitespace(s.name) || `Stage ${i + 1}`,
|
||||
stageOrder: i, // Backend expects 0-indexed
|
||||
executionType: 'SEQUENTIAL' as const,
|
||||
requiredApprovals: [{
|
||||
departmentCode: department?.code || '',
|
||||
departmentName: department?.name || 'Unknown',
|
||||
canDelegate: false,
|
||||
}],
|
||||
completionCriteria: 'ALL' as const,
|
||||
rejectionHandling: 'FAIL_REQUEST' as const,
|
||||
metadata: {
|
||||
isRequired: s.isRequired ?? true,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const action$ = this.isEditMode()
|
||||
|
||||
@@ -81,7 +81,7 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
<ng-container matColumnDef="stages">
|
||||
<th mat-header-cell *matHeaderCellDef>Stages</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-chip>{{ row.stages?.length || 0 }} stages</mat-chip>
|
||||
<mat-chip>{{ row.definition?.stages?.length || 0 }} stages</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Total Stages</span>
|
||||
<span class="value">{{ wf.stages.length || 0 }}</span>
|
||||
<span class="value">{{ wf.definition?.stages?.length || 0 }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Created</span>
|
||||
@@ -75,14 +75,14 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
<div class="stages-section">
|
||||
<h3>Approval Stages</h3>
|
||||
<div class="stages-flow">
|
||||
@for (stage of wf.stages; track stage.id; let i = $index; let last = $last) {
|
||||
@for (stage of (wf.definition?.stages || []); track stage.stageId; let i = $index; let last = $last) {
|
||||
<div class="stage-item">
|
||||
<div class="stage-number">{{ i + 1 }}</div>
|
||||
<mat-card class="stage-card">
|
||||
<div class="stage-content">
|
||||
<div class="stage-name">{{ stage.name }}</div>
|
||||
<div class="stage-dept">{{ stage.departmentId }}</div>
|
||||
@if (stage.isRequired) {
|
||||
<div class="stage-name">{{ stage.stageName }}</div>
|
||||
<div class="stage-dept">{{ stage.requiredApprovals?.[0]?.departmentCode || 'N/A' }}</div>
|
||||
@if (stage.metadata?.['isRequired'] !== false) {
|
||||
<mat-chip>Required</mat-chip>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,93 @@
|
||||
<!-- Skip to main content - GIGW 3.0 Accessibility -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
<div class="auth-layout">
|
||||
<!-- Animated Background -->
|
||||
<div class="animated-background">
|
||||
<!-- Floating Blockchain Nodes -->
|
||||
<div class="node node-1"></div>
|
||||
<div class="node node-2"></div>
|
||||
<div class="node node-3"></div>
|
||||
<div class="node node-4"></div>
|
||||
<div class="node node-5"></div>
|
||||
<div class="node node-6"></div>
|
||||
<div class="landing-page">
|
||||
<!-- Immersive Background with Blockchain Visualization -->
|
||||
<div class="hero-background">
|
||||
<!-- Animated Gradient Mesh -->
|
||||
<div class="gradient-mesh"></div>
|
||||
|
||||
<!-- Connection Lines -->
|
||||
<svg class="connections" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<line class="connection" x1="20" y1="30" x2="50" y2="50" />
|
||||
<line class="connection" x1="50" y1="50" x2="80" y2="25" />
|
||||
<line class="connection" x1="80" y1="25" x2="70" y2="70" />
|
||||
<line class="connection" x1="70" y1="70" x2="30" y2="80" />
|
||||
<line class="connection" x1="30" y1="80" x2="20" y2="30" />
|
||||
<line class="connection" x1="50" y1="50" x2="70" y2="70" />
|
||||
<line class="connection" x1="50" y1="50" x2="30" y2="80" />
|
||||
</svg>
|
||||
<!-- Floating Blockchain Network -->
|
||||
<div class="blockchain-network">
|
||||
<!-- Primary Nodes -->
|
||||
<div class="bc-node primary" style="--x: 15%; --y: 20%; --delay: 0s; --size: 20px;"></div>
|
||||
<div class="bc-node primary" style="--x: 75%; --y: 15%; --delay: 1.5s; --size: 24px;"></div>
|
||||
<div class="bc-node primary" style="--x: 85%; --y: 60%; --delay: 3s; --size: 18px;"></div>
|
||||
<div class="bc-node primary" style="--x: 25%; --y: 70%; --delay: 2s; --size: 22px;"></div>
|
||||
<div class="bc-node primary" style="--x: 50%; --y: 45%; --delay: 0.5s; --size: 28px;"></div>
|
||||
|
||||
<!-- Gradient Overlay -->
|
||||
<div class="gradient-overlay"></div>
|
||||
<!-- Secondary Nodes -->
|
||||
<div class="bc-node secondary" style="--x: 35%; --y: 30%; --delay: 0.8s; --size: 12px;"></div>
|
||||
<div class="bc-node secondary" style="--x: 65%; --y: 35%; --delay: 2.3s; --size: 10px;"></div>
|
||||
<div class="bc-node secondary" style="--x: 45%; --y: 75%; --delay: 1.2s; --size: 14px;"></div>
|
||||
<div class="bc-node secondary" style="--x: 90%; --y: 30%; --delay: 3.5s; --size: 8px;"></div>
|
||||
<div class="bc-node secondary" style="--x: 10%; --y: 50%; --delay: 1.8s; --size: 10px;"></div>
|
||||
|
||||
<!-- Connection Lines SVG -->
|
||||
<svg class="network-connections" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="lineGrad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(99, 102, 241, 0.8)" />
|
||||
<stop offset="100%" stop-color="rgba(168, 85, 247, 0.4)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="lineGrad2" x1="100%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(16, 185, 129, 0.6)" />
|
||||
<stop offset="100%" stop-color="rgba(99, 102, 241, 0.3)" />
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="1" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Network Connections -->
|
||||
<path class="connection-line" d="M15,20 Q30,35 50,45" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M50,45 Q65,30 75,15" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M75,15 Q85,35 85,60" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M85,60 Q60,65 50,45" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M50,45 Q35,60 25,70" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M25,70 Q15,45 15,20" filter="url(#glow)"/>
|
||||
<path class="connection-line secondary" d="M35,30 Q42,38 50,45" filter="url(#glow)"/>
|
||||
<path class="connection-line secondary" d="M65,35 Q58,40 50,45" filter="url(#glow)"/>
|
||||
<path class="connection-line secondary" d="M45,75 Q47,60 50,45" filter="url(#glow)"/>
|
||||
|
||||
<!-- Data Packets Animation -->
|
||||
<circle class="data-packet" r="1">
|
||||
<animateMotion dur="4s" repeatCount="indefinite" path="M15,20 Q30,35 50,45 Q65,30 75,15"/>
|
||||
</circle>
|
||||
<circle class="data-packet" r="1">
|
||||
<animateMotion dur="5s" repeatCount="indefinite" path="M75,15 Q85,35 85,60 Q60,65 50,45 Q35,60 25,70"/>
|
||||
</circle>
|
||||
<circle class="data-packet" r="0.8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M25,70 Q15,45 15,20 Q30,35 50,45"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Floating Particles -->
|
||||
<div class="particles">
|
||||
<div class="particle" style="--x: 10%; --y: 15%; --duration: 20s;"></div>
|
||||
<div class="particle" style="--x: 20%; --y: 80%; --duration: 25s;"></div>
|
||||
<div class="particle" style="--x: 80%; --y: 25%; --duration: 22s;"></div>
|
||||
<div class="particle" style="--x: 90%; --y: 85%; --duration: 28s;"></div>
|
||||
<div class="particle" style="--x: 40%; --y: 10%; --duration: 18s;"></div>
|
||||
<div class="particle" style="--x: 60%; --y: 90%; --duration: 24s;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ambient Light Effects -->
|
||||
<div class="ambient-light light-1"></div>
|
||||
<div class="ambient-light light-2"></div>
|
||||
<div class="ambient-light light-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<div class="auth-container">
|
||||
<!-- Left Side - Branding -->
|
||||
<div class="auth-branding">
|
||||
<div class="branding-content">
|
||||
<div class="emblem-wrapper">
|
||||
<!-- Main Content Area -->
|
||||
<div class="landing-content">
|
||||
<!-- Top Navigation Bar -->
|
||||
<header class="top-nav">
|
||||
<div class="nav-brand">
|
||||
<div class="emblem-container">
|
||||
<img
|
||||
src="assets/images/goa-emblem.svg"
|
||||
alt="Government of Goa Emblem"
|
||||
@@ -40,80 +95,105 @@
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">Government of Goa</span>
|
||||
<span class="brand-subtitle">Blockchain e-Licensing Platform</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-status">
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-label">Network Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1 class="brand-title">
|
||||
<span class="title-line">Government of Goa</span>
|
||||
<span class="title-highlight">Blockchain e-Licensing</span>
|
||||
<!-- Hero Section with Login -->
|
||||
<main class="hero-section" id="main-content" role="main">
|
||||
<!-- Left Column - Hero Content -->
|
||||
<div class="hero-content">
|
||||
<div class="hero-badge">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
<span>Powered by Hyperledger Besu</span>
|
||||
</div>
|
||||
|
||||
<h1 class="hero-title">
|
||||
<span class="title-line-1">Secure & Transparent</span>
|
||||
<span class="title-line-2">License Management</span>
|
||||
</h1>
|
||||
|
||||
<p class="brand-tagline">
|
||||
Secure, Transparent, Immutable
|
||||
<p class="hero-description">
|
||||
Experience the future of government services with blockchain-backed
|
||||
license applications, instant verification, and tamper-proof records.
|
||||
</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<span class="feature-title">Blockchain Secured</span>
|
||||
<span class="feature-desc">Tamper-proof license records</span>
|
||||
</div>
|
||||
<!-- Feature Pills -->
|
||||
<div class="feature-pills">
|
||||
<div class="feature-pill">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
<span>Instant Verification</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<span class="feature-title">Instant Verification</span>
|
||||
<span class="feature-desc">Real-time license validity</span>
|
||||
</div>
|
||||
<div class="feature-pill">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
|
||||
</svg>
|
||||
<span>Tamper-Proof</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M9 11H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm2-7h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V9h14v11z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<span class="feature-title">Multi-Dept Workflow</span>
|
||||
<span class="feature-desc">Streamlined approvals</span>
|
||||
</div>
|
||||
<div class="feature-pill">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<span>Multi-Department</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Status -->
|
||||
<div class="network-status">
|
||||
<div class="status-dot"></div>
|
||||
<span class="status-text">Hyperledger Besu Network</span>
|
||||
<span class="status-badge">Live</span>
|
||||
<!-- Benefits List -->
|
||||
<div class="benefits-list">
|
||||
<div class="benefit-item">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
|
||||
</svg>
|
||||
<span>100% Paperless Process</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
|
||||
</svg>
|
||||
<span>Apply Anytime, Anywhere</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
<span>Real-time Status Tracking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side - Login Form -->
|
||||
<div class="auth-content" id="main-content" role="main">
|
||||
<div class="auth-card">
|
||||
<router-outlet></router-outlet>
|
||||
<!-- Right Column - Login Card -->
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="card-glow"></div>
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="auth-footer" role="contentinfo">
|
||||
<p class="copyright">© 2024 Government of Goa. All rights reserved.</p>
|
||||
<!-- Footer -->
|
||||
<footer class="landing-footer" role="contentinfo">
|
||||
<div class="footer-content">
|
||||
<p class="copyright">© {{ currentYear }} Government of Goa, India. All rights reserved.</p>
|
||||
<div class="footer-links">
|
||||
<a href="#">Privacy Policy</a>
|
||||
<span class="divider">|</span>
|
||||
<a href="#">Terms of Service</a>
|
||||
<span class="divider">|</span>
|
||||
<a href="#">Help</a>
|
||||
<a href="/policies" aria-label="Website Policies">Policies</a>
|
||||
<a href="/terms" aria-label="Terms and Conditions">Terms</a>
|
||||
<a href="/accessibility" aria-label="Accessibility Statement">Accessibility</a>
|
||||
<a href="/contact" aria-label="Contact Information">Contact</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,4 +8,6 @@ import { RouterModule } from '@angular/router';
|
||||
templateUrl: './auth-layout.component.html',
|
||||
styleUrl: './auth-layout.component.scss',
|
||||
})
|
||||
export class AuthLayoutComponent {}
|
||||
export class AuthLayoutComponent {
|
||||
readonly currentYear = new Date().getFullYear();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Department Info Banner (for department users) -->
|
||||
@if (userType() === 'DEPARTMENT' && (currentUser | async); as user) {
|
||||
<div class="department-info-banner" [class.collapsed]="!sidenavOpened()">
|
||||
@if (sidenavOpened()) {
|
||||
<div class="dept-icon">
|
||||
<mat-icon>business</mat-icon>
|
||||
</div>
|
||||
<div class="dept-details">
|
||||
<span class="dept-label">Department Portal</span>
|
||||
<span class="dept-name">{{ departmentName() || user.name }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="dept-icon-compact" [matTooltip]="departmentName() || user.name" matTooltipPosition="right">
|
||||
<mat-icon>business</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
@@ -72,11 +91,12 @@
|
||||
class="nav-item"
|
||||
routerLink="/admin"
|
||||
routerLinkActive="active"
|
||||
aria-label="Admin Portal - Platform administration"
|
||||
[matTooltip]="!sidenavOpened() ? 'Admin Portal' : ''"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<div class="nav-icon">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
<mat-icon aria-hidden="true">admin_panel_settings</mat-icon>
|
||||
</div>
|
||||
@if (sidenavOpened()) {
|
||||
<span class="nav-label">Admin Portal</span>
|
||||
@@ -89,16 +109,16 @@
|
||||
<!-- Sidebar Footer -->
|
||||
<div class="sidebar-footer">
|
||||
@if (sidenavOpened()) {
|
||||
<div class="blockchain-status">
|
||||
<div class="status-indicator online"></div>
|
||||
<div class="blockchain-status" role="status" aria-live="polite" aria-label="Blockchain network status: Connected">
|
||||
<div class="status-indicator online" aria-hidden="true"></div>
|
||||
<div class="status-text">
|
||||
<span class="status-label">Blockchain</span>
|
||||
<span class="status-value">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="blockchain-status-compact">
|
||||
<div class="status-indicator online"></div>
|
||||
<div class="blockchain-status-compact" role="status" aria-label="Blockchain connected">
|
||||
<div class="status-indicator online" aria-hidden="true"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -106,7 +126,7 @@
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-wrapper">
|
||||
<!-- Top Header -->
|
||||
<!-- Top Header - DBIM Compliant -->
|
||||
<header class="top-header" role="banner">
|
||||
<div class="header-left">
|
||||
<button
|
||||
@@ -118,15 +138,47 @@
|
||||
<mat-icon>{{ sidenavOpened() ? 'menu_open' : 'menu' }}</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Breadcrumb (optional) -->
|
||||
<nav class="breadcrumb hide-mobile" aria-label="Breadcrumb">
|
||||
<span class="breadcrumb-item">Dashboard</span>
|
||||
<!-- Breadcrumb - GIGW 3.0 Required -->
|
||||
<nav class="breadcrumb hide-mobile" aria-label="Breadcrumb navigation">
|
||||
<ol class="breadcrumb-list" role="list">
|
||||
<li class="breadcrumb-item">
|
||||
<a routerLink="/dashboard" aria-label="Go to Dashboard">Home</a>
|
||||
</li>
|
||||
<!-- Additional breadcrumb items would be dynamically added based on route -->
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- Search (optional) -->
|
||||
<button mat-icon-button class="header-action hide-mobile" aria-label="Search">
|
||||
<!-- Language Toggle - DBIM Required -->
|
||||
<div class="language-toggle" role="group" aria-label="Language selection">
|
||||
<button
|
||||
mat-button
|
||||
class="lang-btn"
|
||||
[class.active]="currentLanguage() === 'en'"
|
||||
(click)="setLanguage('en')"
|
||||
aria-label="Switch to English"
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<span class="lang-divider">|</span>
|
||||
<button
|
||||
mat-button
|
||||
class="lang-btn"
|
||||
[class.active]="currentLanguage() === 'hi'"
|
||||
(click)="setLanguage('hi')"
|
||||
aria-label="Switch to Hindi"
|
||||
>
|
||||
अ
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search - GIGW 3.0 Required -->
|
||||
<button
|
||||
mat-icon-button
|
||||
class="header-action hide-mobile"
|
||||
aria-label="Search the platform"
|
||||
title="Search"
|
||||
>
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
|
||||
@@ -135,9 +187,15 @@
|
||||
mat-icon-button
|
||||
class="header-action"
|
||||
[matMenuTriggerFor]="notificationMenu"
|
||||
aria-label="Notifications"
|
||||
[attr.aria-label]="'Notifications, ' + unreadNotifications() + ' unread'"
|
||||
title="Notifications"
|
||||
>
|
||||
<mat-icon [matBadge]="unreadNotifications()" matBadgeColor="warn" matBadgeSize="small">
|
||||
<mat-icon
|
||||
[matBadge]="unreadNotifications()"
|
||||
matBadgeColor="warn"
|
||||
matBadgeSize="small"
|
||||
[attr.aria-hidden]="true"
|
||||
>
|
||||
notifications
|
||||
</mat-icon>
|
||||
</button>
|
||||
@@ -237,12 +295,12 @@
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<!-- Footer - DBIM Compliant -->
|
||||
<!-- Footer - DBIM & GIGW 3.0 Compliant -->
|
||||
<footer class="main-footer" role="contentinfo">
|
||||
<div class="footer-content">
|
||||
<div class="footer-left">
|
||||
<span class="footer-text">
|
||||
This platform belongs to Government of Goa, India
|
||||
© {{ currentYear }} Government of Goa, India. All rights reserved.
|
||||
</span>
|
||||
<span class="footer-divider hide-mobile">|</span>
|
||||
<span class="footer-text hide-mobile">
|
||||
@@ -250,9 +308,11 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<a href="#" class="footer-link">Website Policies</a>
|
||||
<a href="#" class="footer-link">Help</a>
|
||||
<a href="#" class="footer-link">Feedback</a>
|
||||
<a href="/policies" class="footer-link" aria-label="Website Policies">Website Policies</a>
|
||||
<a href="/terms" class="footer-link" aria-label="Terms and Conditions">Terms & Conditions</a>
|
||||
<a href="/accessibility" class="footer-link" aria-label="Accessibility Statement">Accessibility</a>
|
||||
<a href="/contact" class="footer-link" aria-label="Contact Information">Contact</a>
|
||||
<a href="/help" class="footer-link" aria-label="Help and Support">Help</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -125,6 +125,92 @@ $transition-speed: 250ms;
|
||||
}
|
||||
}
|
||||
|
||||
// Department Info Banner
|
||||
.department-info-banner {
|
||||
margin: 12px 12px 0;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.25) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all $transition-speed ease;
|
||||
|
||||
&.collapsed {
|
||||
padding: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dept-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.35);
|
||||
|
||||
mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-icon-compact {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: help;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
.dept-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.dept-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dept-code {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar Navigation
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
@@ -344,6 +430,7 @@ $transition-speed: 250ms;
|
||||
}
|
||||
}
|
||||
|
||||
// Breadcrumb - GIGW 3.0 Required
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -352,6 +439,98 @@ $transition-speed: 250ms;
|
||||
color: var(--dbim-grey-2);
|
||||
}
|
||||
|
||||
.breadcrumb-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
a {
|
||||
color: var(--dbim-grey-3);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--dbim-blue-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--dbim-info);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '/';
|
||||
margin-left: 8px;
|
||||
color: var(--dbim-grey-1);
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:last-child a {
|
||||
color: var(--dbim-brown);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Language Toggle - DBIM Required
|
||||
.language-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 8px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(29, 10, 105, 0.04);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
min-width: 32px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-grey-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--dbim-blue-dark);
|
||||
background: rgba(29, 10, 105, 0.08);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--dbim-blue-dark);
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--dbim-info);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.lang-divider {
|
||||
color: var(--dbim-grey-1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -373,16 +552,24 @@ $transition-speed: 250ms;
|
||||
}
|
||||
|
||||
.user-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px 6px 6px;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 10px;
|
||||
padding: 4px 12px 4px 4px !important;
|
||||
border-radius: 50px;
|
||||
background: transparent;
|
||||
min-height: 44px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(29, 10, 105, 0.05);
|
||||
}
|
||||
|
||||
// Ensure proper vertical alignment
|
||||
::ng-deep .mdc-button__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@@ -396,6 +583,8 @@ $transition-speed: 250ms;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
@@ -409,24 +598,30 @@ $transition-speed: 250ms;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
text-align: left;
|
||||
min-height: 36px;
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-brown);
|
||||
line-height: 1.2;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
color: var(--dbim-grey-2);
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,6 +889,28 @@ $transition-speed: 250ms;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.department-info-banner {
|
||||
padding: 10px;
|
||||
justify-content: center;
|
||||
margin: 8px 8px 0;
|
||||
|
||||
.dept-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dept-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import { Component, inject, signal, computed, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule, Router } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -8,6 +9,7 @@ import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { DepartmentService } from '../../features/departments/services/department.service';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
@@ -33,15 +35,23 @@ interface NavItem {
|
||||
templateUrl: './main-layout.component.html',
|
||||
styleUrl: './main-layout.component.scss',
|
||||
})
|
||||
export class MainLayoutComponent {
|
||||
export class MainLayoutComponent implements OnInit {
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly departmentService = inject(DepartmentService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly sidenavOpened = signal(true);
|
||||
readonly currentUser = this.authService.currentUser$;
|
||||
readonly userType = this.authService.userType;
|
||||
|
||||
// Enhanced department info fetched from API
|
||||
readonly departmentCode = signal<string | null>(null);
|
||||
readonly departmentName = signal<string | null>(null);
|
||||
readonly emblemLoaded = signal(true);
|
||||
readonly unreadNotifications = signal(3);
|
||||
readonly currentLanguage = signal<'en' | 'hi'>('en');
|
||||
readonly currentYear = new Date().getFullYear();
|
||||
readonly lastUpdated = new Date().toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
@@ -76,10 +86,50 @@ export class MainLayoutComponent {
|
||||
});
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDepartmentDetails();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch department details if user is a department and code is missing
|
||||
*/
|
||||
private loadDepartmentDetails(): void {
|
||||
const user = this.authService.getCurrentUser();
|
||||
if (!user || user.type !== 'DEPARTMENT') return;
|
||||
|
||||
// If we already have department code from login, use it
|
||||
if (user.departmentCode) {
|
||||
this.departmentCode.set(user.departmentCode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch department details using the user's ID (which is the department ID)
|
||||
const deptId = user.departmentId || user.id;
|
||||
if (!deptId) return;
|
||||
|
||||
this.departmentService.getDepartment(deptId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (dept) => {
|
||||
this.departmentCode.set(dept.code);
|
||||
this.departmentName.set(dept.name);
|
||||
},
|
||||
error: () => {
|
||||
// Silently fail - we'll just not show the code
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleSidenav(): void {
|
||||
this.sidenavOpened.update((v) => !v);
|
||||
}
|
||||
|
||||
setLanguage(lang: 'en' | 'hi'): void {
|
||||
this.currentLanguage.set(lang);
|
||||
// In production, this would trigger i18n service to change language
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { BlockDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-detail-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDividerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<mat-icon>view_module</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Block #{{ data.blockNumber | number }}</h2>
|
||||
<p class="subtitle">Block Details</p>
|
||||
</div>
|
||||
<button mat-icon-button (click)="close()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-content">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Block Number</div>
|
||||
<div class="detail-value highlight">{{ data.blockNumber | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Block Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.hash)">
|
||||
<code>{{ data.hash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Parent Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.parentHash)">
|
||||
<code>{{ data.parentHash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Timestamp</div>
|
||||
<div class="detail-value">{{ data.timestamp | date:'medium' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Transactions</div>
|
||||
<div class="detail-value">
|
||||
<span class="tx-count">{{ data.transactionCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Used</div>
|
||||
<div class="detail-value">{{ data.gasUsed | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Limit</div>
|
||||
<div class="detail-value">{{ data.gasLimit | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Size</div>
|
||||
<div class="detail-value">{{ formatSize(data.size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button mat-button (click)="close()">Close</button>
|
||||
<button mat-flat-button color="primary" (click)="copyAllDetails()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.dialog-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.95rem;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
|
||||
&.highlight {
|
||||
color: #2563EB;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.hash {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
padding: 4px 12px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563EB;
|
||||
border-radius: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #fafafa;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class BlockDetailDialogComponent {
|
||||
readonly data = inject<BlockDto>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<BlockDetailDialogComponent>);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
this.clipboard.copy(text);
|
||||
}
|
||||
|
||||
copyAllDetails(): void {
|
||||
const details = `Block #${this.data.blockNumber}
|
||||
Hash: ${this.data.hash}
|
||||
Parent Hash: ${this.data.parentHash}
|
||||
Timestamp: ${this.data.timestamp}
|
||||
Transactions: ${this.data.transactionCount}
|
||||
Gas Used: ${this.data.gasUsed}
|
||||
Gas Limit: ${this.data.gasLimit}
|
||||
Size: ${this.data.size} bytes`;
|
||||
|
||||
this.clipboard.copy(details);
|
||||
}
|
||||
|
||||
formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
import { BlockDetailDialogComponent } from './block-detail-dialog.component';
|
||||
import { TransactionDetailDialogComponent } from './transaction-detail-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blockchain-explorer-mini',
|
||||
@@ -25,6 +28,7 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
MatProgressSpinnerModule,
|
||||
MatTabsModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
RouterModule,
|
||||
],
|
||||
template: `
|
||||
@@ -240,18 +244,17 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-text h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.header-text .subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -598,6 +601,7 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private refreshSubscription?: Subscription;
|
||||
|
||||
@Input() showViewAll = true;
|
||||
@@ -655,11 +659,12 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
{ limit: 5 }
|
||||
).toPromise();
|
||||
|
||||
if (blocksResponse?.data) {
|
||||
if (blocksResponse?.data && blocksResponse.data.length > 0) {
|
||||
this.blocks.set(blocksResponse.data);
|
||||
if (blocksResponse.data.length > 0) {
|
||||
this.latestBlock.set(blocksResponse.data[0].blockNumber);
|
||||
}
|
||||
this.latestBlock.set(blocksResponse.data[0].blockNumber);
|
||||
} else {
|
||||
// No real blocks - use mock data for demo
|
||||
this.loadMockBlocks();
|
||||
}
|
||||
|
||||
// Fetch transactions
|
||||
@@ -687,18 +692,24 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
// Mock blocks
|
||||
private loadMockBlocks(): void {
|
||||
const mockBlocks: BlockDto[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
blockNumber: 12345 - i,
|
||||
blockNumber: 1628260 - i,
|
||||
hash: `0x${this.generateRandomHash()}`,
|
||||
parentHash: `0x${this.generateRandomHash()}`,
|
||||
timestamp: new Date(Date.now() - i * 15000).toISOString(),
|
||||
transactionCount: Math.floor(Math.random() * 20) + 1,
|
||||
gasUsed: Math.floor(Math.random() * 8000000),
|
||||
transactionCount: Math.floor(Math.random() * 5) + 1,
|
||||
gasUsed: Math.floor(Math.random() * 500000) + 100000,
|
||||
gasLimit: 15000000,
|
||||
size: Math.floor(Math.random() * 50000) + 10000,
|
||||
}));
|
||||
this.blocks.set(mockBlocks);
|
||||
this.latestBlock.set(mockBlocks[0].blockNumber);
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
// Mock blocks
|
||||
this.loadMockBlocks();
|
||||
|
||||
const mockTx: BlockchainTransactionDto[] = [
|
||||
{
|
||||
@@ -746,9 +757,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
];
|
||||
|
||||
this.blocks.set(mockBlocks);
|
||||
this.transactions.set(mockTx);
|
||||
this.latestBlock.set(mockBlocks[0].blockNumber);
|
||||
this.totalTransactions.set(1234);
|
||||
this.pendingTransactions.set(3);
|
||||
this.networkStatus.set('HEALTHY');
|
||||
@@ -761,11 +770,12 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
truncateHash(hash: string): string {
|
||||
if (!hash || hash.length <= 18) return hash;
|
||||
if (!hash || hash.length <= 18) return hash || '';
|
||||
return `${hash.substring(0, 10)}...${hash.substring(hash.length - 6)}`;
|
||||
}
|
||||
|
||||
getRelativeTime(timestamp: string): string {
|
||||
if (!timestamp) return '';
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffSeconds = Math.floor((now.getTime() - time.getTime()) / 1000);
|
||||
@@ -785,6 +795,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
getTxTypeIcon(type: string): string {
|
||||
if (!type) return 'receipt_long';
|
||||
const icons: Record<string, string> = {
|
||||
LICENSE_MINT: 'verified',
|
||||
DOCUMENT_HASH: 'fingerprint',
|
||||
@@ -796,6 +807,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
formatTxType(type: string): string {
|
||||
if (!type) return 'Unknown';
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -805,12 +817,20 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
viewBlock(block: BlockDto): void {
|
||||
// Could open a dialog or navigate
|
||||
console.log('View block:', block);
|
||||
this.dialog.open(BlockDetailDialogComponent, {
|
||||
data: block,
|
||||
width: '600px',
|
||||
maxHeight: '90vh',
|
||||
panelClass: 'blockchain-detail-dialog',
|
||||
});
|
||||
}
|
||||
|
||||
viewTransaction(tx: BlockchainTransactionDto): void {
|
||||
// Could open a dialog or navigate
|
||||
console.log('View transaction:', tx);
|
||||
this.dialog.open(TransactionDetailDialogComponent, {
|
||||
data: tx,
|
||||
width: '600px',
|
||||
maxHeight: '90vh',
|
||||
panelClass: 'blockchain-detail-dialog',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { BlockchainTransactionDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-transaction-detail-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDividerModule,
|
||||
MatChipsModule,
|
||||
],
|
||||
template: `
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header" [class]="'status-' + data.status.toLowerCase()">
|
||||
<div class="header-icon">
|
||||
<mat-icon>{{ getStatusIcon() }}</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Transaction Details</h2>
|
||||
<p class="subtitle">{{ formatTxType(data.type) }}</p>
|
||||
</div>
|
||||
<button mat-icon-button (click)="close()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-content">
|
||||
<div class="status-banner" [class]="data.status.toLowerCase()">
|
||||
<mat-icon>{{ getStatusIcon() }}</mat-icon>
|
||||
<span>{{ data.status }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Transaction Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.txHash)">
|
||||
<code>{{ data.txHash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Type</div>
|
||||
<div class="detail-value">
|
||||
<mat-icon class="type-icon">{{ getTxTypeIcon() }}</mat-icon>
|
||||
{{ formatTxType(data.type) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Status</div>
|
||||
<div class="detail-value">
|
||||
<mat-chip [class]="data.status.toLowerCase()">
|
||||
{{ data.status }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (data.blockNumber) {
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Block Number</div>
|
||||
<div class="detail-value highlight">{{ data.blockNumber | number }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Timestamp</div>
|
||||
<div class="detail-value">{{ data.timestamp | date:'medium' }}</div>
|
||||
</div>
|
||||
|
||||
@if (data.gasUsed) {
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Used</div>
|
||||
<div class="detail-value">{{ data.gasUsed | number }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (data.data && hasDataKeys()) {
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Additional Data</div>
|
||||
<div class="detail-value">
|
||||
<pre class="data-json">{{ data.data | json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button mat-button (click)="close()">Close</button>
|
||||
<button mat-flat-button color="primary" (click)="copyAllDetails()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.dialog-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
color: white;
|
||||
|
||||
&.status-confirmed {
|
||||
background: linear-gradient(135deg, #198754 0%, #28a745 100%);
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
background: linear-gradient(135deg, #DC3545 0%, #e74c3c 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&.confirmed {
|
||||
background: rgba(25, 135, 84, 0.1);
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: #DC3545;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.95rem;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&.highlight {
|
||||
color: #2563EB;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.hash {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
color: #2563EB;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #1D0A69;
|
||||
}
|
||||
|
||||
mat-chip {
|
||||
font-size: 0.75rem;
|
||||
|
||||
&.confirmed {
|
||||
background: rgba(25, 135, 84, 0.1) !important;
|
||||
color: #198754 !important;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(37, 99, 235, 0.1) !important;
|
||||
color: #2563EB !important;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: rgba(220, 53, 69, 0.1) !important;
|
||||
color: #DC3545 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #fafafa;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-json {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
background: #f5f5f5;
|
||||
color: #1f2937;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class TransactionDetailDialogComponent {
|
||||
readonly data = inject<BlockchainTransactionDto>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<TransactionDetailDialogComponent>);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
this.clipboard.copy(text);
|
||||
}
|
||||
|
||||
copyAllDetails(): void {
|
||||
const details = `Transaction Hash: ${this.data.txHash}
|
||||
Type: ${this.formatTxType(this.data.type)}
|
||||
Status: ${this.data.status}
|
||||
Block Number: ${this.data.blockNumber || 'Pending'}
|
||||
Timestamp: ${this.data.timestamp}
|
||||
${this.data.gasUsed ? `Gas Used: ${this.data.gasUsed}` : ''}`;
|
||||
|
||||
this.clipboard.copy(details);
|
||||
}
|
||||
|
||||
hasDataKeys(): boolean {
|
||||
return this.data.data && Object.keys(this.data.data).length > 0;
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
switch (this.data.status) {
|
||||
case 'CONFIRMED': return 'check_circle';
|
||||
case 'PENDING': return 'schedule';
|
||||
case 'FAILED': return 'error';
|
||||
default: return 'receipt_long';
|
||||
}
|
||||
}
|
||||
|
||||
getTxTypeIcon(): string {
|
||||
const icons: Record<string, string> = {
|
||||
LICENSE_MINT: 'verified',
|
||||
DOCUMENT_HASH: 'fingerprint',
|
||||
APPROVAL_RECORD: 'approval',
|
||||
LICENSE_TRANSFER: 'swap_horiz',
|
||||
REVOCATION: 'block',
|
||||
};
|
||||
return icons[this.data.type] || 'receipt_long';
|
||||
}
|
||||
|
||||
formatTxType(type: string): string {
|
||||
if (!type) return 'Blockchain Transaction';
|
||||
const typeMap: Record<string, string> = {
|
||||
'LICENSE_MINT': 'License Minting',
|
||||
'DOCUMENT_HASH': 'Document Hash',
|
||||
'APPROVAL_RECORD': 'Approval Record',
|
||||
'LICENSE_TRANSFER': 'License Transfer',
|
||||
'REVOCATION': 'License Revocation',
|
||||
'TRANSACTION': 'Blockchain Transaction',
|
||||
};
|
||||
return typeMap[type] || type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input, OnInit, OnDestroy, OnChanges, SimpleChanges, AfterViewInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -9,6 +9,10 @@ import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Subject } from 'rxjs';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { StorageService } from '../../../core/services/storage.service';
|
||||
import { RuntimeConfigService } from '../../../core/services/runtime-config.service';
|
||||
|
||||
interface DocumentVersion {
|
||||
id: string;
|
||||
@@ -37,6 +41,10 @@ interface Document {
|
||||
ipfsHash?: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
previewDataUrl?: string; // Generated preview data URL
|
||||
previewLoading?: boolean;
|
||||
previewError?: boolean;
|
||||
mimeType?: string;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
currentVersion: number;
|
||||
@@ -69,13 +77,27 @@ interface Document {
|
||||
<div class="document-viewer">
|
||||
<div class="documents-grid" *ngIf="documents && documents.length > 0">
|
||||
<mat-card *ngFor="let doc of documents" class="document-card">
|
||||
<!-- Document Thumbnail/Icon -->
|
||||
<div class="document-thumbnail" [class.has-thumbnail]="doc.thumbnailUrl" (click)="previewDocument(doc)">
|
||||
<img *ngIf="doc.thumbnailUrl" [src]="doc.thumbnailUrl" [alt]="doc.name" />
|
||||
<div *ngIf="!doc.thumbnailUrl" class="document-icon">
|
||||
<!-- Document Thumbnail/Preview -->
|
||||
<div class="document-thumbnail"
|
||||
[class.has-thumbnail]="doc.previewDataUrl || doc.thumbnailUrl"
|
||||
[class.is-loading]="doc.previewLoading"
|
||||
(click)="previewDocument(doc)">
|
||||
<!-- Loading spinner -->
|
||||
<div *ngIf="doc.previewLoading" class="preview-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>Loading preview...</span>
|
||||
</div>
|
||||
<!-- Actual preview image -->
|
||||
<img *ngIf="(doc.previewDataUrl || doc.thumbnailUrl) && !doc.previewLoading"
|
||||
[src]="doc.previewDataUrl || doc.thumbnailUrl"
|
||||
[alt]="doc.name"
|
||||
class="preview-image" />
|
||||
<!-- Fallback icon when no preview available -->
|
||||
<div *ngIf="!doc.previewDataUrl && !doc.thumbnailUrl && !doc.previewLoading" class="document-icon">
|
||||
<mat-icon>{{ getFileIcon(doc.type) }}</mat-icon>
|
||||
<span class="file-extension">{{ getFileExtension(doc.name) }}</span>
|
||||
</div>
|
||||
<!-- Hover overlay -->
|
||||
<div class="preview-overlay">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>Preview</span>
|
||||
@@ -252,22 +274,41 @@ interface Document {
|
||||
.document-thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
&.has-thumbnail {
|
||||
background: #f5f5f5;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
img {
|
||||
&.is-loading {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.document-icon {
|
||||
@@ -502,17 +543,194 @@ interface Document {
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentViewerComponent implements OnInit {
|
||||
export class DocumentViewerComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges {
|
||||
@Input() documents: Document[] = [];
|
||||
@Input() showVersionHistory = true;
|
||||
@Input() showDepartmentReviews = true;
|
||||
@Input() apiBaseUrl?: string;
|
||||
|
||||
versionColumns = ['version', 'uploadedAt', 'uploadedBy', 'fileHash', 'actions'];
|
||||
|
||||
constructor(private dialog: MatDialog) {}
|
||||
private destroy$ = new Subject<void>();
|
||||
private storage = inject(StorageService);
|
||||
private configService = inject(RuntimeConfigService);
|
||||
|
||||
/**
|
||||
* Get effective API base URL (input override or runtime config)
|
||||
*/
|
||||
private get effectiveApiBaseUrl(): string {
|
||||
return this.apiBaseUrl || this.configService.apiBaseUrl;
|
||||
}
|
||||
|
||||
constructor(private dialog: MatDialog) {
|
||||
// Configure PDF.js worker - use local asset
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/assets/pdf.worker.min.js';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize component
|
||||
// Load previews after documents are set
|
||||
this.loadDocumentPreviews();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Ensure previews are loaded after view is ready
|
||||
setTimeout(() => this.loadDocumentPreviews(), 100);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private loadDocumentPreviews(): void {
|
||||
if (!this.documents?.length) return;
|
||||
|
||||
this.documents.forEach(doc => {
|
||||
if (!doc.previewDataUrl && !doc.previewLoading) {
|
||||
this.generatePreview(doc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private generatePreview(doc: Document): void {
|
||||
const mimeType = doc.mimeType || doc.metadata?.mimeType || this.getMimeTypeFromFilename(doc.name);
|
||||
|
||||
if (this.isImage(mimeType)) {
|
||||
this.loadImagePreview(doc);
|
||||
} else if (this.isPdf(mimeType)) {
|
||||
this.loadPdfPreview(doc);
|
||||
}
|
||||
// Other file types will show the default icon
|
||||
}
|
||||
|
||||
private getMimeTypeFromFilename(filename: string): string {
|
||||
const ext = filename?.split('.').pop()?.toLowerCase() || '';
|
||||
const mimeMap: { [key: string]: string } = {
|
||||
'pdf': 'application/pdf',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'svg': 'image/svg+xml',
|
||||
};
|
||||
return mimeMap[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
private isImage(mimeType: string): boolean {
|
||||
return mimeType?.startsWith('image/') || false;
|
||||
}
|
||||
|
||||
private isPdf(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf';
|
||||
}
|
||||
|
||||
private async loadImagePreview(doc: Document): Promise<void> {
|
||||
doc.previewLoading = true;
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?inline=true`;
|
||||
|
||||
try {
|
||||
// Get authorization token
|
||||
const token = this.storage.getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Use fetch with authorization header
|
||||
const response = await fetch(downloadUrl, {
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Convert blob to data URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
doc.previewDataUrl = reader.result as string;
|
||||
doc.previewLoading = false;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.warn('Failed to read image blob:', reader.error);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (err) {
|
||||
console.warn('Image preview failed, using fallback icon:', err);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPdfPreview(doc: Document): Promise<void> {
|
||||
doc.previewLoading = true;
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download`;
|
||||
|
||||
try {
|
||||
// Get authorization token
|
||||
const token = this.storage.getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Fetch the PDF as an array buffer
|
||||
const response = await fetch(downloadUrl, {
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// Load PDF and render first page
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
|
||||
// Create a canvas for the thumbnail - scale to fit 320x200
|
||||
const originalViewport = page.getViewport({ scale: 1 });
|
||||
const scale = Math.min(320 / originalViewport.width, 200 / originalViewport.height);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
// Fill white background
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Render the page
|
||||
await page.render({
|
||||
canvasContext: ctx,
|
||||
viewport
|
||||
}).promise;
|
||||
|
||||
// Convert to data URL
|
||||
doc.previewDataUrl = canvas.toDataURL('image/png');
|
||||
doc.previewLoading = false;
|
||||
} catch (err) {
|
||||
// If PDF.js fails, still mark as done but use fallback icon
|
||||
console.warn('PDF preview failed, using fallback icon:', err);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
// Don't set previewDataUrl - will show default icon
|
||||
}
|
||||
}
|
||||
|
||||
getFileIcon(type: string): string {
|
||||
@@ -529,7 +747,8 @@ export class DocumentViewerComponent implements OnInit {
|
||||
return iconMap[type] || 'insert_drive_file';
|
||||
}
|
||||
|
||||
getFileExtension(filename: string): string {
|
||||
getFileExtension(filename: string | undefined | null): string {
|
||||
if (!filename) return 'FILE';
|
||||
const parts = filename.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE';
|
||||
}
|
||||
@@ -558,24 +777,37 @@ export class DocumentViewerComponent implements OnInit {
|
||||
}
|
||||
|
||||
downloadDocument(doc: Document): void {
|
||||
// Create a temporary link and trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = doc.url;
|
||||
link.download = doc.name;
|
||||
link.click();
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download`;
|
||||
// Open in new tab to trigger download
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
|
||||
downloadVersion(doc: Document, version: DocumentVersion): void {
|
||||
alert(`Downloading version ${version.version} of ${doc.name}`);
|
||||
// In real implementation, fetch version-specific URL and download
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?version=${version.version}`;
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
|
||||
previewDocument(doc: Document): void {
|
||||
// Open preview dialog or new window
|
||||
window.open(doc.url, '_blank');
|
||||
const mimeType = doc.mimeType || doc.metadata?.mimeType || this.getMimeTypeFromFilename(doc.name);
|
||||
const previewUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?inline=true`;
|
||||
|
||||
if (this.isImage(mimeType) || this.isPdf(mimeType)) {
|
||||
// Open in new window with inline display
|
||||
window.open(previewUrl, '_blank', 'width=900,height=700,scrollbars=yes');
|
||||
} else {
|
||||
// For other types, trigger download
|
||||
this.downloadDocument(doc);
|
||||
}
|
||||
}
|
||||
|
||||
viewVersionHistory(doc: Document): void {
|
||||
alert(`Version History for ${doc.name}\n\nTotal versions: ${doc.versions?.length}`);
|
||||
}
|
||||
|
||||
// Called when documents input changes
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['documents'] && !changes['documents'].firstChange) {
|
||||
this.loadDocumentPreviews();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, MatIconModule],
|
||||
template: `
|
||||
<div class="page-header">
|
||||
<header class="page-header" role="banner">
|
||||
<div class="page-header-content">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="page-subtitle">{{ subtitle }}</p>
|
||||
@if (icon) {
|
||||
<div class="page-header-icon">
|
||||
<mat-icon>{{ icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" [id]="titleId">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="page-subtitle" [attr.aria-describedby]="titleId">{{ subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<div class="page-actions" role="group" aria-label="Page actions">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@@ -30,21 +38,44 @@ import { CommonModule } from '@angular/common';
|
||||
}
|
||||
|
||||
.page-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.page-header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
@@ -58,6 +89,10 @@ import { CommonModule } from '@angular/common';
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
@@ -65,4 +100,8 @@ import { CommonModule } from '@angular/common';
|
||||
export class PageHeaderComponent {
|
||||
@Input({ required: true }) title!: string;
|
||||
@Input() subtitle?: string;
|
||||
@Input() icon?: string;
|
||||
|
||||
// Generate unique ID for accessibility
|
||||
readonly titleId = `page-title-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ import { CommonModule } from '@angular/common';
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
|
||||
3
frontend/src/assets/config.json
Normal file
3
frontend/src/assets/config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"apiBaseUrl": "http://localhost:3001/api/v1"
|
||||
}
|
||||
21
frontend/src/assets/pdf.worker.min.js
vendored
Normal file
21
frontend/src/assets/pdf.worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,12 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Goa GEL - Blockchain e-Licensing Platform</title>
|
||||
<title>Goa GEL - Government e-Licensing Platform | Government of Goa</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Government of Goa Blockchain-based Document Verification and e-Licensing Platform">
|
||||
<meta name="description" content="Official Government of Goa Blockchain-based Document Verification and e-Licensing Platform. Apply for licenses, track applications, and verify documents securely.">
|
||||
<meta name="keywords" content="Government of Goa, e-Licensing, Blockchain, License, Permit, Government Services, Digital India">
|
||||
<meta name="author" content="Government of Goa">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="theme-color" content="#1D0A69">
|
||||
|
||||
<!-- GIGW 3.0 Accessibility Meta -->
|
||||
<meta name="accessibility" content="WCAG 2.1 Level AA">
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="canonical" href="https://gel.goa.gov.in">
|
||||
|
||||
<!-- Google Fonts: Noto Sans (DBIM Mandatory) + Material Icons -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -15,6 +24,13 @@
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Noscript fallback for accessibility -->
|
||||
<noscript>
|
||||
<div style="padding: 20px; text-align: center; background: #FFC107; color: #150202;">
|
||||
JavaScript is required to use the Government of Goa e-Licensing Platform.
|
||||
Please enable JavaScript in your browser settings.
|
||||
</div>
|
||||
</noscript>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -101,34 +101,40 @@ $goa-gel-theme: mat.m2-define-light-theme(
|
||||
|
||||
// =============================================================================
|
||||
// CSS VARIABLES - DBIM Colour Tokens
|
||||
// India Government Digital Brand Identity Manual (DBIM) Compliant
|
||||
// =============================================================================
|
||||
:root {
|
||||
// Primary colours (Blue group)
|
||||
--dbim-blue-dark: #1D0A69;
|
||||
// DBIM Official India Government Blue (Primary)
|
||||
--dbim-govt-blue: #0066B3; // Official India Government Blue
|
||||
--dbim-govt-blue-dark: #004B8D; // Darker variant for hover states
|
||||
--dbim-govt-blue-light: #1A7FC1; // Lighter variant
|
||||
|
||||
// Primary colours (Blue group - for Goa state customization)
|
||||
--dbim-blue-dark: #1D0A69; // Goa state accent
|
||||
--dbim-blue-mid: #2563EB;
|
||||
--dbim-blue-light: #3B82F6;
|
||||
--dbim-blue-lighter: #60A5FA;
|
||||
--dbim-blue-subtle: #DBEAFE;
|
||||
|
||||
// Functional colours
|
||||
--dbim-white: #FFFFFF;
|
||||
--dbim-linen: #EBEAEA;
|
||||
--dbim-brown: #150202;
|
||||
--dbim-black: #000000;
|
||||
--dbim-deep-blue: #1D0A69;
|
||||
// Functional colours (DBIM Mandatory)
|
||||
--dbim-white: #FFFFFF; // Inclusive White - page backgrounds
|
||||
--dbim-linen: #EBEAEA; // Background secondary - cards, quotes
|
||||
--dbim-brown: #150202; // Deep Earthy Brown - text on light bg
|
||||
--dbim-black: #000000; // State Emblem on light bg
|
||||
--dbim-deep-blue: #1D0A69; // Gov.In identity colour
|
||||
|
||||
// Status colours
|
||||
--dbim-success: #198754;
|
||||
--dbim-warning: #FFC107;
|
||||
--dbim-error: #DC3545;
|
||||
--dbim-info: #0D6EFD;
|
||||
// Status colours (DBIM Fixed)
|
||||
--dbim-success: #198754; // Liberty Green - approved, confirmed
|
||||
--dbim-warning: #FFC107; // Mustard Yellow - pending, in-review
|
||||
--dbim-error: #DC3545; // Coral Red - rejected, failed
|
||||
--dbim-info: #0D6EFD; // Blue - information, hyperlinks
|
||||
|
||||
// Grey palette
|
||||
// Grey palette (DBIM Compliant)
|
||||
--dbim-grey-1: #C6C6C6;
|
||||
--dbim-grey-2: #8E8E8E;
|
||||
--dbim-grey-3: #606060;
|
||||
|
||||
// Crypto accents
|
||||
// Crypto accents (within DBIM compliance)
|
||||
--crypto-purple: #8B5CF6;
|
||||
--crypto-indigo: #6366F1;
|
||||
--crypto-cyan: #06B6D4;
|
||||
@@ -143,6 +149,11 @@ $goa-gel-theme: mat.m2-define-light-theme(
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
|
||||
// Focus ring (WCAG AA compliant - 3:1 contrast)
|
||||
--focus-ring-color: #0066B3;
|
||||
--focus-ring-width: 3px;
|
||||
--focus-ring-offset: 2px;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -936,8 +947,10 @@ a {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ACCESSIBILITY
|
||||
// ACCESSIBILITY - GIGW 3.0 & WCAG 2.1 AA Compliant
|
||||
// =============================================================================
|
||||
|
||||
// Screen reader only content
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -950,23 +963,110 @@ a {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
// Visually hidden but focusable (for skip links)
|
||||
.visually-hidden-focusable {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--dbim-blue-dark);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
z-index: 100;
|
||||
transition: top var(--transition-fast);
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
|
||||
&:focus {
|
||||
top: 0;
|
||||
&:focus,
|
||||
&:active {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 8px 16px;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus styles for keyboard navigation
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--dbim-info);
|
||||
outline-offset: 2px;
|
||||
// Skip to main content link - GIGW 3.0 Required
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: 8px;
|
||||
background: var(--dbim-govt-blue);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
z-index: 10000;
|
||||
transition: top var(--transition-fast);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
|
||||
&:focus {
|
||||
top: 0;
|
||||
outline: 3px solid var(--dbim-warning);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus styles for keyboard navigation - WCAG 2.1 AA (3:1 contrast ratio)
|
||||
*:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
// Enhanced focus for interactive elements
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// High contrast focus for buttons
|
||||
button:focus-visible,
|
||||
.mat-mdc-button:focus-visible,
|
||||
.mat-mdc-raised-button:focus-visible,
|
||||
.mat-mdc-outlined-button:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
box-shadow: 0 0 0 4px rgba(0, 102, 179, 0.25);
|
||||
}
|
||||
|
||||
// Remove default focus outline when using focus-visible
|
||||
*:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Reduced motion preference support - WCAG 2.1
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
// High contrast mode support
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--dbim-govt-blue: #0052CC;
|
||||
--dbim-success: #006644;
|
||||
--dbim-error: #CC0000;
|
||||
--dbim-warning: #CC8800;
|
||||
--focus-ring-width: 4px;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline-width: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user