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:
@@ -0,0 +1,280 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { BlockDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-detail-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDividerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<mat-icon>view_module</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Block #{{ data.blockNumber | number }}</h2>
|
||||
<p class="subtitle">Block Details</p>
|
||||
</div>
|
||||
<button mat-icon-button (click)="close()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-content">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Block Number</div>
|
||||
<div class="detail-value highlight">{{ data.blockNumber | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Block Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.hash)">
|
||||
<code>{{ data.hash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Parent Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.parentHash)">
|
||||
<code>{{ data.parentHash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Timestamp</div>
|
||||
<div class="detail-value">{{ data.timestamp | date:'medium' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Transactions</div>
|
||||
<div class="detail-value">
|
||||
<span class="tx-count">{{ data.transactionCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Used</div>
|
||||
<div class="detail-value">{{ data.gasUsed | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Limit</div>
|
||||
<div class="detail-value">{{ data.gasLimit | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Size</div>
|
||||
<div class="detail-value">{{ formatSize(data.size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button mat-button (click)="close()">Close</button>
|
||||
<button mat-flat-button color="primary" (click)="copyAllDetails()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.dialog-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.95rem;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
|
||||
&.highlight {
|
||||
color: #2563EB;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.hash {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
padding: 4px 12px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563EB;
|
||||
border-radius: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #fafafa;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class BlockDetailDialogComponent {
|
||||
readonly data = inject<BlockDto>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<BlockDetailDialogComponent>);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
this.clipboard.copy(text);
|
||||
}
|
||||
|
||||
copyAllDetails(): void {
|
||||
const details = `Block #${this.data.blockNumber}
|
||||
Hash: ${this.data.hash}
|
||||
Parent Hash: ${this.data.parentHash}
|
||||
Timestamp: ${this.data.timestamp}
|
||||
Transactions: ${this.data.transactionCount}
|
||||
Gas Used: ${this.data.gasUsed}
|
||||
Gas Limit: ${this.data.gasLimit}
|
||||
Size: ${this.data.size} bytes`;
|
||||
|
||||
this.clipboard.copy(details);
|
||||
}
|
||||
|
||||
formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
import { BlockDetailDialogComponent } from './block-detail-dialog.component';
|
||||
import { TransactionDetailDialogComponent } from './transaction-detail-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blockchain-explorer-mini',
|
||||
@@ -25,6 +28,7 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
MatProgressSpinnerModule,
|
||||
MatTabsModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
RouterModule,
|
||||
],
|
||||
template: `
|
||||
@@ -240,18 +244,17 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-text h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.header-text .subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -598,6 +601,7 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private refreshSubscription?: Subscription;
|
||||
|
||||
@Input() showViewAll = true;
|
||||
@@ -655,11 +659,12 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
{ limit: 5 }
|
||||
).toPromise();
|
||||
|
||||
if (blocksResponse?.data) {
|
||||
if (blocksResponse?.data && blocksResponse.data.length > 0) {
|
||||
this.blocks.set(blocksResponse.data);
|
||||
if (blocksResponse.data.length > 0) {
|
||||
this.latestBlock.set(blocksResponse.data[0].blockNumber);
|
||||
}
|
||||
this.latestBlock.set(blocksResponse.data[0].blockNumber);
|
||||
} else {
|
||||
// No real blocks - use mock data for demo
|
||||
this.loadMockBlocks();
|
||||
}
|
||||
|
||||
// Fetch transactions
|
||||
@@ -687,18 +692,24 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
// Mock blocks
|
||||
private loadMockBlocks(): void {
|
||||
const mockBlocks: BlockDto[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
blockNumber: 12345 - i,
|
||||
blockNumber: 1628260 - i,
|
||||
hash: `0x${this.generateRandomHash()}`,
|
||||
parentHash: `0x${this.generateRandomHash()}`,
|
||||
timestamp: new Date(Date.now() - i * 15000).toISOString(),
|
||||
transactionCount: Math.floor(Math.random() * 20) + 1,
|
||||
gasUsed: Math.floor(Math.random() * 8000000),
|
||||
transactionCount: Math.floor(Math.random() * 5) + 1,
|
||||
gasUsed: Math.floor(Math.random() * 500000) + 100000,
|
||||
gasLimit: 15000000,
|
||||
size: Math.floor(Math.random() * 50000) + 10000,
|
||||
}));
|
||||
this.blocks.set(mockBlocks);
|
||||
this.latestBlock.set(mockBlocks[0].blockNumber);
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
// Mock blocks
|
||||
this.loadMockBlocks();
|
||||
|
||||
const mockTx: BlockchainTransactionDto[] = [
|
||||
{
|
||||
@@ -746,9 +757,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
];
|
||||
|
||||
this.blocks.set(mockBlocks);
|
||||
this.transactions.set(mockTx);
|
||||
this.latestBlock.set(mockBlocks[0].blockNumber);
|
||||
this.totalTransactions.set(1234);
|
||||
this.pendingTransactions.set(3);
|
||||
this.networkStatus.set('HEALTHY');
|
||||
@@ -761,11 +770,12 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
truncateHash(hash: string): string {
|
||||
if (!hash || hash.length <= 18) return hash;
|
||||
if (!hash || hash.length <= 18) return hash || '';
|
||||
return `${hash.substring(0, 10)}...${hash.substring(hash.length - 6)}`;
|
||||
}
|
||||
|
||||
getRelativeTime(timestamp: string): string {
|
||||
if (!timestamp) return '';
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffSeconds = Math.floor((now.getTime() - time.getTime()) / 1000);
|
||||
@@ -785,6 +795,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
getTxTypeIcon(type: string): string {
|
||||
if (!type) return 'receipt_long';
|
||||
const icons: Record<string, string> = {
|
||||
LICENSE_MINT: 'verified',
|
||||
DOCUMENT_HASH: 'fingerprint',
|
||||
@@ -796,6 +807,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
formatTxType(type: string): string {
|
||||
if (!type) return 'Unknown';
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -805,12 +817,20 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
viewBlock(block: BlockDto): void {
|
||||
// Could open a dialog or navigate
|
||||
console.log('View block:', block);
|
||||
this.dialog.open(BlockDetailDialogComponent, {
|
||||
data: block,
|
||||
width: '600px',
|
||||
maxHeight: '90vh',
|
||||
panelClass: 'blockchain-detail-dialog',
|
||||
});
|
||||
}
|
||||
|
||||
viewTransaction(tx: BlockchainTransactionDto): void {
|
||||
// Could open a dialog or navigate
|
||||
console.log('View transaction:', tx);
|
||||
this.dialog.open(TransactionDetailDialogComponent, {
|
||||
data: tx,
|
||||
width: '600px',
|
||||
maxHeight: '90vh',
|
||||
panelClass: 'blockchain-detail-dialog',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { BlockchainTransactionDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-transaction-detail-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDividerModule,
|
||||
MatChipsModule,
|
||||
],
|
||||
template: `
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header" [class]="'status-' + data.status.toLowerCase()">
|
||||
<div class="header-icon">
|
||||
<mat-icon>{{ getStatusIcon() }}</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Transaction Details</h2>
|
||||
<p class="subtitle">{{ formatTxType(data.type) }}</p>
|
||||
</div>
|
||||
<button mat-icon-button (click)="close()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-content">
|
||||
<div class="status-banner" [class]="data.status.toLowerCase()">
|
||||
<mat-icon>{{ getStatusIcon() }}</mat-icon>
|
||||
<span>{{ data.status }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Transaction Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.txHash)">
|
||||
<code>{{ data.txHash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Type</div>
|
||||
<div class="detail-value">
|
||||
<mat-icon class="type-icon">{{ getTxTypeIcon() }}</mat-icon>
|
||||
{{ formatTxType(data.type) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Status</div>
|
||||
<div class="detail-value">
|
||||
<mat-chip [class]="data.status.toLowerCase()">
|
||||
{{ data.status }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (data.blockNumber) {
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Block Number</div>
|
||||
<div class="detail-value highlight">{{ data.blockNumber | number }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Timestamp</div>
|
||||
<div class="detail-value">{{ data.timestamp | date:'medium' }}</div>
|
||||
</div>
|
||||
|
||||
@if (data.gasUsed) {
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Used</div>
|
||||
<div class="detail-value">{{ data.gasUsed | number }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (data.data && hasDataKeys()) {
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Additional Data</div>
|
||||
<div class="detail-value">
|
||||
<pre class="data-json">{{ data.data | json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button mat-button (click)="close()">Close</button>
|
||||
<button mat-flat-button color="primary" (click)="copyAllDetails()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.dialog-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
color: white;
|
||||
|
||||
&.status-confirmed {
|
||||
background: linear-gradient(135deg, #198754 0%, #28a745 100%);
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
background: linear-gradient(135deg, #DC3545 0%, #e74c3c 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&.confirmed {
|
||||
background: rgba(25, 135, 84, 0.1);
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: #DC3545;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.95rem;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&.highlight {
|
||||
color: #2563EB;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.hash {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
color: #2563EB;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #1D0A69;
|
||||
}
|
||||
|
||||
mat-chip {
|
||||
font-size: 0.75rem;
|
||||
|
||||
&.confirmed {
|
||||
background: rgba(25, 135, 84, 0.1) !important;
|
||||
color: #198754 !important;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(37, 99, 235, 0.1) !important;
|
||||
color: #2563EB !important;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: rgba(220, 53, 69, 0.1) !important;
|
||||
color: #DC3545 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #fafafa;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-json {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
background: #f5f5f5;
|
||||
color: #1f2937;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class TransactionDetailDialogComponent {
|
||||
readonly data = inject<BlockchainTransactionDto>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<TransactionDetailDialogComponent>);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
this.clipboard.copy(text);
|
||||
}
|
||||
|
||||
copyAllDetails(): void {
|
||||
const details = `Transaction Hash: ${this.data.txHash}
|
||||
Type: ${this.formatTxType(this.data.type)}
|
||||
Status: ${this.data.status}
|
||||
Block Number: ${this.data.blockNumber || 'Pending'}
|
||||
Timestamp: ${this.data.timestamp}
|
||||
${this.data.gasUsed ? `Gas Used: ${this.data.gasUsed}` : ''}`;
|
||||
|
||||
this.clipboard.copy(details);
|
||||
}
|
||||
|
||||
hasDataKeys(): boolean {
|
||||
return this.data.data && Object.keys(this.data.data).length > 0;
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
switch (this.data.status) {
|
||||
case 'CONFIRMED': return 'check_circle';
|
||||
case 'PENDING': return 'schedule';
|
||||
case 'FAILED': return 'error';
|
||||
default: return 'receipt_long';
|
||||
}
|
||||
}
|
||||
|
||||
getTxTypeIcon(): string {
|
||||
const icons: Record<string, string> = {
|
||||
LICENSE_MINT: 'verified',
|
||||
DOCUMENT_HASH: 'fingerprint',
|
||||
APPROVAL_RECORD: 'approval',
|
||||
LICENSE_TRANSFER: 'swap_horiz',
|
||||
REVOCATION: 'block',
|
||||
};
|
||||
return icons[this.data.type] || 'receipt_long';
|
||||
}
|
||||
|
||||
formatTxType(type: string): string {
|
||||
if (!type) return 'Blockchain Transaction';
|
||||
const typeMap: Record<string, string> = {
|
||||
'LICENSE_MINT': 'License Minting',
|
||||
'DOCUMENT_HASH': 'Document Hash',
|
||||
'APPROVAL_RECORD': 'Approval Record',
|
||||
'LICENSE_TRANSFER': 'License Transfer',
|
||||
'REVOCATION': 'License Revocation',
|
||||
'TRANSACTION': 'Blockchain Transaction',
|
||||
};
|
||||
return typeMap[type] || type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input, OnInit, OnDestroy, OnChanges, SimpleChanges, AfterViewInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -9,6 +9,10 @@ import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Subject } from 'rxjs';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { StorageService } from '../../../core/services/storage.service';
|
||||
import { RuntimeConfigService } from '../../../core/services/runtime-config.service';
|
||||
|
||||
interface DocumentVersion {
|
||||
id: string;
|
||||
@@ -37,6 +41,10 @@ interface Document {
|
||||
ipfsHash?: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
previewDataUrl?: string; // Generated preview data URL
|
||||
previewLoading?: boolean;
|
||||
previewError?: boolean;
|
||||
mimeType?: string;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
currentVersion: number;
|
||||
@@ -69,13 +77,27 @@ interface Document {
|
||||
<div class="document-viewer">
|
||||
<div class="documents-grid" *ngIf="documents && documents.length > 0">
|
||||
<mat-card *ngFor="let doc of documents" class="document-card">
|
||||
<!-- Document Thumbnail/Icon -->
|
||||
<div class="document-thumbnail" [class.has-thumbnail]="doc.thumbnailUrl" (click)="previewDocument(doc)">
|
||||
<img *ngIf="doc.thumbnailUrl" [src]="doc.thumbnailUrl" [alt]="doc.name" />
|
||||
<div *ngIf="!doc.thumbnailUrl" class="document-icon">
|
||||
<!-- Document Thumbnail/Preview -->
|
||||
<div class="document-thumbnail"
|
||||
[class.has-thumbnail]="doc.previewDataUrl || doc.thumbnailUrl"
|
||||
[class.is-loading]="doc.previewLoading"
|
||||
(click)="previewDocument(doc)">
|
||||
<!-- Loading spinner -->
|
||||
<div *ngIf="doc.previewLoading" class="preview-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>Loading preview...</span>
|
||||
</div>
|
||||
<!-- Actual preview image -->
|
||||
<img *ngIf="(doc.previewDataUrl || doc.thumbnailUrl) && !doc.previewLoading"
|
||||
[src]="doc.previewDataUrl || doc.thumbnailUrl"
|
||||
[alt]="doc.name"
|
||||
class="preview-image" />
|
||||
<!-- Fallback icon when no preview available -->
|
||||
<div *ngIf="!doc.previewDataUrl && !doc.thumbnailUrl && !doc.previewLoading" class="document-icon">
|
||||
<mat-icon>{{ getFileIcon(doc.type) }}</mat-icon>
|
||||
<span class="file-extension">{{ getFileExtension(doc.name) }}</span>
|
||||
</div>
|
||||
<!-- Hover overlay -->
|
||||
<div class="preview-overlay">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>Preview</span>
|
||||
@@ -252,22 +274,41 @@ interface Document {
|
||||
.document-thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
&.has-thumbnail {
|
||||
background: #f5f5f5;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
img {
|
||||
&.is-loading {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.document-icon {
|
||||
@@ -502,17 +543,194 @@ interface Document {
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentViewerComponent implements OnInit {
|
||||
export class DocumentViewerComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges {
|
||||
@Input() documents: Document[] = [];
|
||||
@Input() showVersionHistory = true;
|
||||
@Input() showDepartmentReviews = true;
|
||||
@Input() apiBaseUrl?: string;
|
||||
|
||||
versionColumns = ['version', 'uploadedAt', 'uploadedBy', 'fileHash', 'actions'];
|
||||
|
||||
constructor(private dialog: MatDialog) {}
|
||||
private destroy$ = new Subject<void>();
|
||||
private storage = inject(StorageService);
|
||||
private configService = inject(RuntimeConfigService);
|
||||
|
||||
/**
|
||||
* Get effective API base URL (input override or runtime config)
|
||||
*/
|
||||
private get effectiveApiBaseUrl(): string {
|
||||
return this.apiBaseUrl || this.configService.apiBaseUrl;
|
||||
}
|
||||
|
||||
constructor(private dialog: MatDialog) {
|
||||
// Configure PDF.js worker - use local asset
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/assets/pdf.worker.min.js';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize component
|
||||
// Load previews after documents are set
|
||||
this.loadDocumentPreviews();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Ensure previews are loaded after view is ready
|
||||
setTimeout(() => this.loadDocumentPreviews(), 100);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private loadDocumentPreviews(): void {
|
||||
if (!this.documents?.length) return;
|
||||
|
||||
this.documents.forEach(doc => {
|
||||
if (!doc.previewDataUrl && !doc.previewLoading) {
|
||||
this.generatePreview(doc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private generatePreview(doc: Document): void {
|
||||
const mimeType = doc.mimeType || doc.metadata?.mimeType || this.getMimeTypeFromFilename(doc.name);
|
||||
|
||||
if (this.isImage(mimeType)) {
|
||||
this.loadImagePreview(doc);
|
||||
} else if (this.isPdf(mimeType)) {
|
||||
this.loadPdfPreview(doc);
|
||||
}
|
||||
// Other file types will show the default icon
|
||||
}
|
||||
|
||||
private getMimeTypeFromFilename(filename: string): string {
|
||||
const ext = filename?.split('.').pop()?.toLowerCase() || '';
|
||||
const mimeMap: { [key: string]: string } = {
|
||||
'pdf': 'application/pdf',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'svg': 'image/svg+xml',
|
||||
};
|
||||
return mimeMap[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
private isImage(mimeType: string): boolean {
|
||||
return mimeType?.startsWith('image/') || false;
|
||||
}
|
||||
|
||||
private isPdf(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf';
|
||||
}
|
||||
|
||||
private async loadImagePreview(doc: Document): Promise<void> {
|
||||
doc.previewLoading = true;
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?inline=true`;
|
||||
|
||||
try {
|
||||
// Get authorization token
|
||||
const token = this.storage.getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Use fetch with authorization header
|
||||
const response = await fetch(downloadUrl, {
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Convert blob to data URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
doc.previewDataUrl = reader.result as string;
|
||||
doc.previewLoading = false;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.warn('Failed to read image blob:', reader.error);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (err) {
|
||||
console.warn('Image preview failed, using fallback icon:', err);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPdfPreview(doc: Document): Promise<void> {
|
||||
doc.previewLoading = true;
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download`;
|
||||
|
||||
try {
|
||||
// Get authorization token
|
||||
const token = this.storage.getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Fetch the PDF as an array buffer
|
||||
const response = await fetch(downloadUrl, {
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// Load PDF and render first page
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
|
||||
// Create a canvas for the thumbnail - scale to fit 320x200
|
||||
const originalViewport = page.getViewport({ scale: 1 });
|
||||
const scale = Math.min(320 / originalViewport.width, 200 / originalViewport.height);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
// Fill white background
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Render the page
|
||||
await page.render({
|
||||
canvasContext: ctx,
|
||||
viewport
|
||||
}).promise;
|
||||
|
||||
// Convert to data URL
|
||||
doc.previewDataUrl = canvas.toDataURL('image/png');
|
||||
doc.previewLoading = false;
|
||||
} catch (err) {
|
||||
// If PDF.js fails, still mark as done but use fallback icon
|
||||
console.warn('PDF preview failed, using fallback icon:', err);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
// Don't set previewDataUrl - will show default icon
|
||||
}
|
||||
}
|
||||
|
||||
getFileIcon(type: string): string {
|
||||
@@ -529,7 +747,8 @@ export class DocumentViewerComponent implements OnInit {
|
||||
return iconMap[type] || 'insert_drive_file';
|
||||
}
|
||||
|
||||
getFileExtension(filename: string): string {
|
||||
getFileExtension(filename: string | undefined | null): string {
|
||||
if (!filename) return 'FILE';
|
||||
const parts = filename.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE';
|
||||
}
|
||||
@@ -558,24 +777,37 @@ export class DocumentViewerComponent implements OnInit {
|
||||
}
|
||||
|
||||
downloadDocument(doc: Document): void {
|
||||
// Create a temporary link and trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = doc.url;
|
||||
link.download = doc.name;
|
||||
link.click();
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download`;
|
||||
// Open in new tab to trigger download
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
|
||||
downloadVersion(doc: Document, version: DocumentVersion): void {
|
||||
alert(`Downloading version ${version.version} of ${doc.name}`);
|
||||
// In real implementation, fetch version-specific URL and download
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?version=${version.version}`;
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
|
||||
previewDocument(doc: Document): void {
|
||||
// Open preview dialog or new window
|
||||
window.open(doc.url, '_blank');
|
||||
const mimeType = doc.mimeType || doc.metadata?.mimeType || this.getMimeTypeFromFilename(doc.name);
|
||||
const previewUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?inline=true`;
|
||||
|
||||
if (this.isImage(mimeType) || this.isPdf(mimeType)) {
|
||||
// Open in new window with inline display
|
||||
window.open(previewUrl, '_blank', 'width=900,height=700,scrollbars=yes');
|
||||
} else {
|
||||
// For other types, trigger download
|
||||
this.downloadDocument(doc);
|
||||
}
|
||||
}
|
||||
|
||||
viewVersionHistory(doc: Document): void {
|
||||
alert(`Version History for ${doc.name}\n\nTotal versions: ${doc.versions?.length}`);
|
||||
}
|
||||
|
||||
// Called when documents input changes
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['documents'] && !changes['documents'].firstChange) {
|
||||
this.loadDocumentPreviews();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, MatIconModule],
|
||||
template: `
|
||||
<div class="page-header">
|
||||
<header class="page-header" role="banner">
|
||||
<div class="page-header-content">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="page-subtitle">{{ subtitle }}</p>
|
||||
@if (icon) {
|
||||
<div class="page-header-icon">
|
||||
<mat-icon>{{ icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" [id]="titleId">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="page-subtitle" [attr.aria-describedby]="titleId">{{ subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<div class="page-actions" role="group" aria-label="Page actions">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@@ -30,21 +38,44 @@ import { CommonModule } from '@angular/common';
|
||||
}
|
||||
|
||||
.page-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.page-header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
@@ -58,6 +89,10 @@ import { CommonModule } from '@angular/common';
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
@@ -65,4 +100,8 @@ import { CommonModule } from '@angular/common';
|
||||
export class PageHeaderComponent {
|
||||
@Input({ required: true }) title!: string;
|
||||
@Input() subtitle?: string;
|
||||
@Input() icon?: string;
|
||||
|
||||
// Generate unique ID for accessibility
|
||||
readonly titleId = `page-title-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ import { CommonModule } from '@angular/common';
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
|
||||
Reference in New Issue
Block a user