feat: Goa GEL Blockchain e-Licensing Platform - Full Stack Implementation
Complete implementation of the Goa Government e-Licensing platform with: Backend: - NestJS API with JWT authentication - PostgreSQL database with Knex ORM - Redis caching and session management - MinIO document storage - Hyperledger Besu blockchain integration - Multi-department workflow system - Comprehensive API tests (266/282 passing) Frontend: - Angular 21 with standalone components - Angular Material + TailwindCSS UI - Visual workflow builder - Document upload with progress tracking - Blockchain explorer integration - Role-based dashboards (Admin, Department, Citizen) - E2E tests with Playwright (37 tests) Infrastructure: - Docker Compose orchestration - Blockscout blockchain explorer - Development and production configurations
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import { Component, Input, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { ApprovalService } from '../services/approval.service';
|
||||
import { ApprovalResponseDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-approval-history',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
StatusBadgeComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="approval-history">
|
||||
<h3>Approval History</h3>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
} @else if (approvals().length === 0) {
|
||||
<app-empty-state
|
||||
icon="history"
|
||||
title="No approval history"
|
||||
message="No approval actions have been taken yet."
|
||||
/>
|
||||
} @else {
|
||||
<div class="timeline">
|
||||
@for (approval of approvals(); track approval.id) {
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker" [class]="getMarkerClass(approval.status)">
|
||||
<mat-icon>{{ getStatusIcon(approval.status) }}</mat-icon>
|
||||
</div>
|
||||
<mat-card class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="department">{{ approval.departmentName }}</span>
|
||||
<app-status-badge [status]="approval.status" />
|
||||
</div>
|
||||
@if (approval.remarks) {
|
||||
<p class="remarks">{{ approval.remarks }}</p>
|
||||
}
|
||||
@if (approval.rejectionReason) {
|
||||
<p class="rejection-reason">
|
||||
<strong>Reason:</strong> {{ formatReason(approval.rejectionReason) }}
|
||||
</p>
|
||||
}
|
||||
<div class="timeline-meta">
|
||||
<span>{{ approval.updatedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.approval-history {
|
||||
margin-top: 24px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
top: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #e0e0e0;
|
||||
z-index: 1;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
&.changes {
|
||||
background-color: #2196f3;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.department {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remarks {
|
||||
margin: 8px 0;
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rejection-reason {
|
||||
margin: 8px 0;
|
||||
color: #f44336;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ApprovalHistoryComponent implements OnInit {
|
||||
@Input({ required: true }) requestId!: string;
|
||||
|
||||
private readonly approvalService = inject(ApprovalService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly approvals = signal<ApprovalResponseDto[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
this.approvalService.getApprovalHistory(this.requestId).subscribe({
|
||||
next: (data) => {
|
||||
this.approvals.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'APPROVED':
|
||||
return 'check';
|
||||
case 'REJECTED':
|
||||
return 'close';
|
||||
case 'CHANGES_REQUESTED':
|
||||
return 'edit';
|
||||
default:
|
||||
return 'hourglass_empty';
|
||||
}
|
||||
}
|
||||
|
||||
getMarkerClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'APPROVED':
|
||||
return 'approved';
|
||||
case 'REJECTED':
|
||||
return 'rejected';
|
||||
case 'CHANGES_REQUESTED':
|
||||
return 'changes';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
formatReason(reason: string): string {
|
||||
return reason.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user