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,816 @@
|
||||
import { Component, OnInit, OnDestroy, inject, signal, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blockchain-explorer-mini',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTabsModule,
|
||||
MatChipsModule,
|
||||
RouterModule,
|
||||
],
|
||||
template: `
|
||||
<mat-card class="explorer-card">
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<mat-icon>link</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h3>Blockchain Explorer</h3>
|
||||
<p class="subtitle">Real-time network activity</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="live-indicator" [class.active]="isLive()">
|
||||
<span class="pulse"></span>
|
||||
<span class="label">{{ isLive() ? 'Live' : 'Paused' }}</span>
|
||||
</div>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleLive()"
|
||||
[matTooltip]="isLive() ? 'Pause updates' : 'Resume updates'"
|
||||
>
|
||||
<mat-icon>{{ isLive() ? 'pause' : 'play_arrow' }}</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="refresh()" [disabled]="loading()" matTooltip="Refresh">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ latestBlock() | number }}</div>
|
||||
<div class="stat-label">Latest Block</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ totalTransactions() | number }}</div>
|
||||
<div class="stat-label">Total Txns</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ pendingTransactions() }}</div>
|
||||
<div class="stat-label">Pending</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value status" [class]="networkStatus().toLowerCase()">
|
||||
<mat-icon>{{ getNetworkIcon() }}</mat-icon>
|
||||
{{ networkStatus() }}
|
||||
</div>
|
||||
<div class="stat-label">Network</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<mat-tab-group class="explorer-tabs" animationDuration="200ms">
|
||||
<!-- Blocks Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon>view_module</mat-icon>
|
||||
Blocks
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
} @else if (blocks().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>view_module</mat-icon>
|
||||
<p>No blocks yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="list-container">
|
||||
@for (block of blocks(); track block.blockNumber) {
|
||||
<div class="list-item block-item" (click)="viewBlock(block)">
|
||||
<div class="item-icon block-icon">
|
||||
<mat-icon>view_module</mat-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-header">
|
||||
<span class="block-number">#{{ block.blockNumber | number }}</span>
|
||||
<span class="item-time">{{ getRelativeTime(block.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<span class="tx-count">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
{{ block.transactionCount }} txns
|
||||
</span>
|
||||
<span class="hash" (click)="copyHash(block.hash, $event)">
|
||||
{{ truncateHash(block.hash) }}
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Transactions Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
Transactions
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
} @else if (transactions().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<p>No transactions yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="list-container">
|
||||
@for (tx of transactions(); track tx.id) {
|
||||
<div class="list-item tx-item" (click)="viewTransaction(tx)">
|
||||
<div class="item-icon" [class]="'status-' + tx.status.toLowerCase()">
|
||||
@switch (tx.status) {
|
||||
@case ('CONFIRMED') {
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
}
|
||||
@case ('PENDING') {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
@case ('FAILED') {
|
||||
<mat-icon>error</mat-icon>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-header">
|
||||
<span class="tx-hash" (click)="copyHash(tx.txHash, $event)">
|
||||
{{ truncateHash(tx.txHash) }}
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</span>
|
||||
<mat-chip class="status-chip" [class]="tx.status.toLowerCase()">
|
||||
{{ tx.status }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<span class="tx-type">
|
||||
<mat-icon>{{ getTxTypeIcon(tx.type) }}</mat-icon>
|
||||
{{ formatTxType(tx.type) }}
|
||||
</span>
|
||||
<span class="item-time">{{ getRelativeTime(tx.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
<!-- Footer -->
|
||||
@if (showViewAll) {
|
||||
<div class="card-footer">
|
||||
<a mat-button color="primary" routerLink="/admin/transactions">
|
||||
View All Transactions
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [`
|
||||
.explorer-card {
|
||||
border-radius: 16px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.active .pulse {
|
||||
background: #4ade80;
|
||||
opacity: 1;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.5); opacity: 0.5; }
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
|
||||
&.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.healthy {
|
||||
color: var(--dbim-success, #198754);
|
||||
}
|
||||
&.degraded {
|
||||
color: var(--dbim-warning, #FFC107);
|
||||
}
|
||||
&.down {
|
||||
color: var(--dbim-error, #DC3545);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.explorer-tabs {
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-labels {
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 120px;
|
||||
|
||||
.mdc-tab__content {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
min-height: 240px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-container {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&.block-icon {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69), var(--dbim-blue-mid, #2563EB));
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.status-confirmed {
|
||||
background: rgba(25, 135, 84, 0.1);
|
||||
color: var(--dbim-success, #198754);
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: var(--dbim-blue-mid, #2563EB);
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: var(--dbim-error, #DC3545);
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.block-number {
|
||||
font-weight: 600;
|
||||
color: var(--dbim-blue-mid, #2563EB);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tx-hash {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.copy-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:hover .copy-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
}
|
||||
|
||||
.tx-count,
|
||||
.tx-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.hash {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
.copy-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
|
||||
.copy-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-size: 0.65rem;
|
||||
min-height: 20px;
|
||||
padding: 0 8px;
|
||||
|
||||
&.confirmed {
|
||||
background: rgba(25, 135, 84, 0.1) !important;
|
||||
color: var(--dbim-success, #198754) !important;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(37, 99, 235, 0.1) !important;
|
||||
color: var(--dbim-blue-mid, #2563EB) !important;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: rgba(220, 53, 69, 0.1) !important;
|
||||
color: var(--dbim-error, #DC3545) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #fafafa;
|
||||
|
||||
a {
|
||||
mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
private refreshSubscription?: Subscription;
|
||||
|
||||
@Input() showViewAll = true;
|
||||
@Input() refreshInterval = 10000; // 10 seconds
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly isLive = signal(true);
|
||||
readonly latestBlock = signal(0);
|
||||
readonly totalTransactions = signal(0);
|
||||
readonly pendingTransactions = signal(0);
|
||||
readonly networkStatus = signal<'HEALTHY' | 'DEGRADED' | 'DOWN'>('HEALTHY');
|
||||
readonly blocks = signal<BlockDto[]>([]);
|
||||
readonly transactions = signal<BlockchainTransactionDto[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
private startAutoRefresh(): void {
|
||||
if (this.refreshSubscription) return;
|
||||
this.refreshSubscription = interval(this.refreshInterval).subscribe(() => {
|
||||
if (this.isLive()) {
|
||||
this.loadData(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private stopAutoRefresh(): void {
|
||||
this.refreshSubscription?.unsubscribe();
|
||||
this.refreshSubscription = undefined;
|
||||
}
|
||||
|
||||
toggleLive(): void {
|
||||
this.isLive.update(v => !v);
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
async loadData(showLoading = true): Promise<void> {
|
||||
if (showLoading) {
|
||||
this.loading.set(true);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch blocks
|
||||
const blocksResponse = await this.api.get<{ data: BlockDto[] }>(
|
||||
'/admin/blockchain/blocks',
|
||||
{ limit: 5 }
|
||||
).toPromise();
|
||||
|
||||
if (blocksResponse?.data) {
|
||||
this.blocks.set(blocksResponse.data);
|
||||
if (blocksResponse.data.length > 0) {
|
||||
this.latestBlock.set(blocksResponse.data[0].blockNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch transactions
|
||||
const txResponse = await this.api.get<{
|
||||
data: BlockchainTransactionDto[];
|
||||
total: number;
|
||||
}>('/admin/blockchain/transactions', { limit: 5 }).toPromise();
|
||||
|
||||
if (txResponse) {
|
||||
this.transactions.set(txResponse.data);
|
||||
this.totalTransactions.set(txResponse.total);
|
||||
this.pendingTransactions.set(
|
||||
txResponse.data.filter(tx => tx.status === 'PENDING').length
|
||||
);
|
||||
}
|
||||
|
||||
this.networkStatus.set('HEALTHY');
|
||||
} catch (error) {
|
||||
console.error('Failed to load blockchain data:', error);
|
||||
this.networkStatus.set('DOWN');
|
||||
// Use mock data for demo
|
||||
this.loadMockData();
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
// Mock blocks
|
||||
const mockBlocks: BlockDto[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
blockNumber: 12345 - i,
|
||||
hash: `0x${this.generateRandomHash()}`,
|
||||
parentHash: `0x${this.generateRandomHash()}`,
|
||||
timestamp: new Date(Date.now() - i * 15000).toISOString(),
|
||||
transactionCount: Math.floor(Math.random() * 20) + 1,
|
||||
gasUsed: Math.floor(Math.random() * 8000000),
|
||||
gasLimit: 15000000,
|
||||
size: Math.floor(Math.random() * 50000) + 10000,
|
||||
}));
|
||||
|
||||
const mockTx: BlockchainTransactionDto[] = [
|
||||
{
|
||||
id: '1',
|
||||
txHash: `0x${this.generateRandomHash()}`,
|
||||
type: 'LICENSE_MINT',
|
||||
status: 'CONFIRMED',
|
||||
blockNumber: 12345,
|
||||
timestamp: new Date(Date.now() - 30000).toISOString(),
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
txHash: `0x${this.generateRandomHash()}`,
|
||||
type: 'DOCUMENT_HASH',
|
||||
status: 'CONFIRMED',
|
||||
blockNumber: 12344,
|
||||
timestamp: new Date(Date.now() - 60000).toISOString(),
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
txHash: `0x${this.generateRandomHash()}`,
|
||||
type: 'APPROVAL_RECORD',
|
||||
status: 'PENDING',
|
||||
timestamp: new Date(Date.now() - 90000).toISOString(),
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
txHash: `0x${this.generateRandomHash()}`,
|
||||
type: 'LICENSE_TRANSFER',
|
||||
status: 'CONFIRMED',
|
||||
blockNumber: 12343,
|
||||
timestamp: new Date(Date.now() - 120000).toISOString(),
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
txHash: `0x${this.generateRandomHash()}`,
|
||||
type: 'REVOCATION',
|
||||
status: 'FAILED',
|
||||
timestamp: new Date(Date.now() - 180000).toISOString(),
|
||||
data: {},
|
||||
},
|
||||
];
|
||||
|
||||
this.blocks.set(mockBlocks);
|
||||
this.transactions.set(mockTx);
|
||||
this.latestBlock.set(mockBlocks[0].blockNumber);
|
||||
this.totalTransactions.set(1234);
|
||||
this.pendingTransactions.set(3);
|
||||
this.networkStatus.set('HEALTHY');
|
||||
}
|
||||
|
||||
private generateRandomHash(): string {
|
||||
return Array.from({ length: 64 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join('');
|
||||
}
|
||||
|
||||
truncateHash(hash: string): string {
|
||||
if (!hash || hash.length <= 18) return hash;
|
||||
return `${hash.substring(0, 10)}...${hash.substring(hash.length - 6)}`;
|
||||
}
|
||||
|
||||
getRelativeTime(timestamp: string): string {
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffSeconds = Math.floor((now.getTime() - time.getTime()) / 1000);
|
||||
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
||||
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
|
||||
return `${Math.floor(diffSeconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
getNetworkIcon(): string {
|
||||
switch (this.networkStatus()) {
|
||||
case 'HEALTHY': return 'check_circle';
|
||||
case 'DEGRADED': return 'warning';
|
||||
case 'DOWN': return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
getTxTypeIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
LICENSE_MINT: 'verified',
|
||||
DOCUMENT_HASH: 'fingerprint',
|
||||
APPROVAL_RECORD: 'approval',
|
||||
LICENSE_TRANSFER: 'swap_horiz',
|
||||
REVOCATION: 'block',
|
||||
};
|
||||
return icons[type] || 'receipt_long';
|
||||
}
|
||||
|
||||
formatTxType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
copyHash(hash: string, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.clipboard.copy(hash);
|
||||
}
|
||||
|
||||
viewBlock(block: BlockDto): void {
|
||||
// Could open a dialog or navigate
|
||||
console.log('View block:', block);
|
||||
}
|
||||
|
||||
viewTransaction(tx: BlockchainTransactionDto): void {
|
||||
// Could open a dialog or navigate
|
||||
console.log('View transaction:', tx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blockchain-info',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatIconModule, MatButtonModule, MatTooltipModule],
|
||||
template: `
|
||||
@if (tokenId || txHash) {
|
||||
<div class="blockchain-info" [class.compact]="compact">
|
||||
<div class="header">
|
||||
<mat-icon class="chain-icon">token</mat-icon>
|
||||
<span class="title">Blockchain Record</span>
|
||||
<span class="verified-badge">
|
||||
<mat-icon>verified</mat-icon>
|
||||
Verified
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (tokenId) {
|
||||
<div class="info-row">
|
||||
<span class="label">License NFT Token ID</span>
|
||||
<span class="value token-id">#{{ tokenId }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (txHash) {
|
||||
<div class="info-row">
|
||||
<span class="label">Transaction Hash</span>
|
||||
<div class="tx-hash-container">
|
||||
<code class="tx-hash">{{ truncateHash(txHash) }}</code>
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Copy full hash"
|
||||
(click)="copyToClipboard(txHash)"
|
||||
class="copy-btn"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showExplorer && txHash) {
|
||||
<div class="explorer-link">
|
||||
<a mat-button color="primary" [href]="getExplorerUrl()" target="_blank">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
View on Block Explorer
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.blockchain-info {
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #e3f2fd 100%);
|
||||
border: 1px solid #c8e6c9;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.blockchain-info.compact {
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chain-icon {
|
||||
color: #2e7d32;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
color: #1b5e20;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.verified-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.token-id {
|
||||
font-size: 1.125rem;
|
||||
color: #1565c0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tx-hash-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tx-hash {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #455a64;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-link {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.compact .header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.compact .info-row {
|
||||
padding: 4px 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class BlockchainInfoComponent {
|
||||
@Input() tokenId?: string | number;
|
||||
@Input() txHash?: string;
|
||||
@Input() compact = false;
|
||||
@Input() showExplorer = false;
|
||||
@Input() explorerBaseUrl = 'http://localhost:25000';
|
||||
|
||||
truncateHash(hash: string): string {
|
||||
if (hash.length <= 20) return hash;
|
||||
return `${hash.substring(0, 10)}...${hash.substring(hash.length - 8)}`;
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
getExplorerUrl(): string {
|
||||
return `${this.explorerBaseUrl}/tx/${this.txHash}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
export interface ConfirmDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmColor?: 'primary' | 'accent' | 'warn';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatDialogModule, MatButtonModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content>
|
||||
<p>{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">
|
||||
{{ data.cancelText || 'Cancel' }}
|
||||
</button>
|
||||
<button mat-raised-button [color]="data.confirmColor || 'primary'" (click)="onConfirm()">
|
||||
{{ data.confirmText || 'Confirm' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
mat-dialog-content p {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
readonly dialogRef = inject(MatDialogRef<ConfirmDialogComponent>);
|
||||
readonly data: ConfirmDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
onConfirm(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
|
||||
interface DocumentVersion {
|
||||
id: string;
|
||||
version: number;
|
||||
fileHash: string;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
changes?: string;
|
||||
}
|
||||
|
||||
interface DepartmentReview {
|
||||
departmentCode: string;
|
||||
departmentName: string;
|
||||
reviewedAt: string;
|
||||
reviewedBy: string;
|
||||
status: 'APPROVED' | 'REJECTED' | 'PENDING';
|
||||
comments?: string;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
fileHash: string;
|
||||
ipfsHash?: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
currentVersion: number;
|
||||
versions?: DocumentVersion[];
|
||||
departmentReviews?: DepartmentReview[];
|
||||
metadata?: {
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
pages?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-document-viewer',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatTooltipModule,
|
||||
MatExpansionModule,
|
||||
MatTableModule,
|
||||
MatDialogModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="document-viewer">
|
||||
<div class="documents-grid" *ngIf="documents && documents.length > 0">
|
||||
<mat-card *ngFor="let doc of documents" class="document-card">
|
||||
<!-- Document Thumbnail/Icon -->
|
||||
<div class="document-thumbnail" [class.has-thumbnail]="doc.thumbnailUrl" (click)="previewDocument(doc)">
|
||||
<img *ngIf="doc.thumbnailUrl" [src]="doc.thumbnailUrl" [alt]="doc.name" />
|
||||
<div *ngIf="!doc.thumbnailUrl" class="document-icon">
|
||||
<mat-icon>{{ getFileIcon(doc.type) }}</mat-icon>
|
||||
<span class="file-extension">{{ getFileExtension(doc.name) }}</span>
|
||||
</div>
|
||||
<div class="preview-overlay">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>Preview</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Info -->
|
||||
<mat-card-content>
|
||||
<div class="document-header">
|
||||
<h3 class="document-name" [matTooltip]="doc.name">{{ doc.name }}</h3>
|
||||
<mat-chip class="version-chip">v{{ doc.currentVersion }}</mat-chip>
|
||||
</div>
|
||||
|
||||
<div class="document-meta">
|
||||
<div class="meta-item">
|
||||
<mat-icon>storage</mat-icon>
|
||||
<span>{{ formatFileSize(doc.size) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<span>{{ doc.uploadedAt | date:'short' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Hash -->
|
||||
<div class="document-hash" *ngIf="doc.fileHash">
|
||||
<div class="hash-label">
|
||||
<mat-icon>fingerprint</mat-icon>
|
||||
<span>File Hash:</span>
|
||||
</div>
|
||||
<code class="hash-value" [matTooltip]="doc.fileHash">
|
||||
{{ doc.fileHash | slice:0:16 }}...{{ doc.fileHash | slice:-12 }}
|
||||
</code>
|
||||
<button mat-icon-button (click)="copyToClipboard(doc.fileHash)" matTooltip="Copy hash">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- IPFS Hash -->
|
||||
<div class="document-hash" *ngIf="doc.ipfsHash">
|
||||
<div class="hash-label">
|
||||
<mat-icon>cloud</mat-icon>
|
||||
<span>IPFS:</span>
|
||||
</div>
|
||||
<code class="hash-value" [matTooltip]="doc.ipfsHash">
|
||||
{{ doc.ipfsHash | slice:0:16 }}...{{ doc.ipfsHash | slice:-12 }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<!-- Department Reviews -->
|
||||
<div class="department-reviews" *ngIf="doc.departmentReviews && doc.departmentReviews.length > 0">
|
||||
<div class="reviews-header">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
<span>Department Reviews</span>
|
||||
</div>
|
||||
<div class="reviews-list">
|
||||
<div *ngFor="let review of doc.departmentReviews" class="review-item">
|
||||
<div class="review-dept">{{ review.departmentName }}</div>
|
||||
<mat-chip
|
||||
[style.background-color]="getReviewStatusColor(review.status)"
|
||||
[style.color]="'white'"
|
||||
>
|
||||
{{ review.status }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="document-actions">
|
||||
<button mat-button color="primary" (click)="downloadDocument(doc)">
|
||||
<mat-icon>download</mat-icon>
|
||||
Download
|
||||
</button>
|
||||
<button mat-button (click)="previewDocument(doc)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
*ngIf="doc.versions && doc.versions.length > 1"
|
||||
(click)="viewVersionHistory(doc)"
|
||||
>
|
||||
<mat-icon>history</mat-icon>
|
||||
History ({{ doc.versions.length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Version History (Expandable) -->
|
||||
<mat-expansion-panel *ngIf="doc.versions && doc.versions.length > 1" class="version-history">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>history</mat-icon>
|
||||
Version History ({{ doc.versions.length }} versions)
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<table mat-table [dataSource]="doc.versions" class="version-table">
|
||||
<ng-container matColumnDef="version">
|
||||
<th mat-header-cell *matHeaderCellDef>Version</th>
|
||||
<td mat-cell *matCellDef="let version">
|
||||
<strong>v{{ version.version }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="uploadedAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Date</th>
|
||||
<td mat-cell *matCellDef="let version">
|
||||
{{ version.uploadedAt | date:'short' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="uploadedBy">
|
||||
<th mat-header-cell *matHeaderCellDef>Uploaded By</th>
|
||||
<td mat-cell *matCellDef="let version">
|
||||
{{ version.uploadedBy }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fileHash">
|
||||
<th mat-header-cell *matHeaderCellDef>Hash</th>
|
||||
<td mat-cell *matCellDef="let version">
|
||||
<code class="small-hash">{{ version.fileHash | slice:0:8 }}...</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let version">
|
||||
<button mat-icon-button (click)="downloadVersion(doc, version)" matTooltip="Download this version">
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="versionColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: versionColumns"></tr>
|
||||
</table>
|
||||
</mat-expansion-panel>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- No Documents Message -->
|
||||
<div *ngIf="!documents || documents.length === 0" class="no-documents">
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
<p>No documents uploaded</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.document-viewer {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.documents-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.document-thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&.has-thumbnail {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.document-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: white;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.file-extension {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
color: white;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .preview-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.document-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.document-name {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.version-chip {
|
||||
flex-shrink: 0;
|
||||
background-color: #e3f2fd !important;
|
||||
color: #1565c0 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.document-hash {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.hash-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.hash-value {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
background-color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.department-reviews {
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reviews-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: #666;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.reviews-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.review-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.review-dept {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.document-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.version-history {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.version-table {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
|
||||
.small-hash {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.no-documents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentViewerComponent implements OnInit {
|
||||
@Input() documents: Document[] = [];
|
||||
@Input() showVersionHistory = true;
|
||||
@Input() showDepartmentReviews = true;
|
||||
|
||||
versionColumns = ['version', 'uploadedAt', 'uploadedBy', 'fileHash', 'actions'];
|
||||
|
||||
constructor(private dialog: MatDialog) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize component
|
||||
}
|
||||
|
||||
getFileIcon(type: string): string {
|
||||
const iconMap: { [key: string]: string } = {
|
||||
pdf: 'picture_as_pdf',
|
||||
image: 'image',
|
||||
video: 'videocam',
|
||||
audio: 'audiotrack',
|
||||
document: 'description',
|
||||
spreadsheet: 'table_chart',
|
||||
presentation: 'slideshow',
|
||||
archive: 'archive',
|
||||
};
|
||||
return iconMap[type] || 'insert_drive_file';
|
||||
}
|
||||
|
||||
getFileExtension(filename: string): string {
|
||||
const parts = filename.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE';
|
||||
}
|
||||
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
getReviewStatusColor(status: string): string {
|
||||
const colors: { [key: string]: string } = {
|
||||
APPROVED: '#4caf50',
|
||||
REJECTED: '#f44336',
|
||||
PENDING: '#ff9800',
|
||||
};
|
||||
return colors[status] || '#757575';
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('Hash copied to clipboard!');
|
||||
});
|
||||
}
|
||||
|
||||
downloadDocument(doc: Document): void {
|
||||
// Create a temporary link and trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = doc.url;
|
||||
link.download = doc.name;
|
||||
link.click();
|
||||
}
|
||||
|
||||
downloadVersion(doc: Document, version: DocumentVersion): void {
|
||||
alert(`Downloading version ${version.version} of ${doc.name}`);
|
||||
// In real implementation, fetch version-specific URL and download
|
||||
}
|
||||
|
||||
previewDocument(doc: Document): void {
|
||||
// Open preview dialog or new window
|
||||
window.open(doc.url, '_blank');
|
||||
}
|
||||
|
||||
viewVersionHistory(doc: Document): void {
|
||||
alert(`Version History for ${doc.name}\n\nTotal versions: ${doc.versions?.length}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
@Component({
|
||||
selector: 'app-empty-state',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, MatButtonModule],
|
||||
template: `
|
||||
<div class="empty-state">
|
||||
<mat-icon>{{ icon }}</mat-icon>
|
||||
<h3>{{ title }}</h3>
|
||||
@if (message) {
|
||||
<p>{{ message }}</p>
|
||||
}
|
||||
<div class="empty-state-actions">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: rgba(0, 0, 0, 0.26);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 24px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class EmptyStateComponent {
|
||||
@Input() icon = 'inbox';
|
||||
@Input() title = 'No data';
|
||||
@Input() message?: string;
|
||||
}
|
||||
8
frontend/src/app/shared/components/index.ts
Normal file
8
frontend/src/app/shared/components/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './page-header/page-header.component';
|
||||
export * from './status-badge/status-badge.component';
|
||||
export * from './confirm-dialog/confirm-dialog.component';
|
||||
export * from './loading-spinner/loading-spinner.component';
|
||||
export * from './empty-state/empty-state.component';
|
||||
export * from './blockchain-info/blockchain-info.component';
|
||||
export * from './verification-badge/verification-badge.component';
|
||||
export * from './blockchain-explorer-mini/blockchain-explorer-mini.component';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading-spinner',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatProgressSpinnerModule],
|
||||
template: `
|
||||
<div class="loading-container" [class.overlay]="overlay">
|
||||
<mat-spinner [diameter]="diameter"></mat-spinner>
|
||||
@if (message) {
|
||||
<p class="loading-message">{{ message }}</p>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
|
||||
&.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
margin: 16px 0 0;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class LoadingSpinnerComponent {
|
||||
@Input() diameter = 48;
|
||||
@Input() message?: string;
|
||||
@Input() overlay = false;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="page-header">
|
||||
<div class="page-header-content">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="page-subtitle">{{ subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header-content {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PageHeaderComponent {
|
||||
@Input({ required: true }) title!: string;
|
||||
@Input() subtitle?: string;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Component, Input, computed, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-status-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<span [class]="badgeClass()">
|
||||
{{ displayText() }}
|
||||
</span>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background-color: #e0e0e0;
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.status-submitted {
|
||||
background-color: #bbdefb;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.status-in_review,
|
||||
.status-in-review,
|
||||
.status-pending {
|
||||
background-color: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
background-color: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
background-color: #ffcdd2;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.status-cancelled,
|
||||
.status-revoked {
|
||||
background-color: #f5f5f5;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.status-changes_requested,
|
||||
.status-pending_resubmission {
|
||||
background-color: #ffe0b2;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.status-active,
|
||||
.status-healthy,
|
||||
.status-up {
|
||||
background-color: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-inactive,
|
||||
.status-down {
|
||||
background-color: #ffcdd2;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.status-degraded {
|
||||
background-color: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class StatusBadgeComponent {
|
||||
status = input.required<string>();
|
||||
label = input<string>();
|
||||
|
||||
readonly displayText = computed(() => {
|
||||
return this.label() || this.status().replace(/_/g, ' ');
|
||||
});
|
||||
|
||||
readonly badgeClass = computed(() => {
|
||||
const normalizedStatus = this.status().toLowerCase().replace(/_/g, '_');
|
||||
return `status-${normalizedStatus}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
export type VerificationStatus = 'verified' | 'pending' | 'unverified' | 'failed';
|
||||
|
||||
@Component({
|
||||
selector: 'app-verification-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, MatTooltipModule],
|
||||
template: `
|
||||
<span
|
||||
class="verification-badge"
|
||||
[class]="status"
|
||||
[matTooltip]="getTooltip()"
|
||||
>
|
||||
<mat-icon>{{ getIcon() }}</mat-icon>
|
||||
@if (!iconOnly) {
|
||||
<span class="label">{{ getLabel() }}</span>
|
||||
}
|
||||
</span>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.verification-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.verified {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.pending {
|
||||
background-color: #fff3e0;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.unverified {
|
||||
background-color: #eceff1;
|
||||
color: #546e7a;
|
||||
}
|
||||
|
||||
.failed {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class VerificationBadgeComponent {
|
||||
@Input() status: VerificationStatus = 'unverified';
|
||||
@Input() iconOnly = false;
|
||||
@Input() customTooltip?: string;
|
||||
|
||||
getIcon(): string {
|
||||
switch (this.status) {
|
||||
case 'verified':
|
||||
return 'verified';
|
||||
case 'pending':
|
||||
return 'schedule';
|
||||
case 'failed':
|
||||
return 'error_outline';
|
||||
default:
|
||||
return 'help_outline';
|
||||
}
|
||||
}
|
||||
|
||||
getLabel(): string {
|
||||
switch (this.status) {
|
||||
case 'verified':
|
||||
return 'Blockchain Verified';
|
||||
case 'pending':
|
||||
return 'Verification Pending';
|
||||
case 'failed':
|
||||
return 'Verification Failed';
|
||||
default:
|
||||
return 'Not Verified';
|
||||
}
|
||||
}
|
||||
|
||||
getTooltip(): string {
|
||||
if (this.customTooltip) return this.customTooltip;
|
||||
|
||||
switch (this.status) {
|
||||
case 'verified':
|
||||
return 'Document hash verified on blockchain';
|
||||
case 'pending':
|
||||
return 'Blockchain verification in progress';
|
||||
case 'failed':
|
||||
return 'Document hash does not match blockchain record';
|
||||
default:
|
||||
return 'Document not yet recorded on blockchain';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user