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:
Mahi
2026-02-08 18:44:05 -04:00
parent 2c10cd5662
commit d9de183e51
171 changed files with 10236 additions and 8386 deletions

View File

@@ -18,6 +18,7 @@ export interface ApprovalResponseDto {
requestId: string;
departmentId: string;
departmentName: string;
departmentCode?: string;
status: ApprovalStatus;
approvedBy?: string;
remarks?: string;

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: () => {

View File

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

View File

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

View File

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

View File

@@ -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: () => {

View File

@@ -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&#64;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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => ({

View File

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

View File

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

View File

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

View File

@@ -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 &amp; 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">&copy; 2024 Government of Goa. All rights reserved.</p>
<!-- Footer -->
<footer class="landing-footer" role="contentinfo">
<div class="footer-content">
<p class="copyright">&copy; {{ 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>

View File

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

View File

@@ -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"
>
&#2309;
</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
&copy; {{ 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 &amp; 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
{
"apiBaseUrl": "http://localhost:3001/api/v1"
}

21
frontend/src/assets/pdf.worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

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