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

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