feat: Runtime configuration and Docker deployment improvements

Frontend:
- Add runtime configuration service for deployment-time API URL injection
- Create docker-entrypoint.sh to generate config.json from environment variables
- Update ApiService, ApprovalService, and DocumentViewer to use RuntimeConfigService
- Add APP_INITIALIZER to load runtime config before app starts

Backend:
- Fix init-blockchain.js to properly quote mnemonic phrases in .env file
- Improve docker-entrypoint.sh with health checks and better error handling

Docker:
- Add API_BASE_URL environment variable to frontend container
- Update docker-compose.yml with clear documentation for remote deployment
- Reorganize .env.example with clear categories (REQUIRED FOR REMOTE, PRODUCTION, AUTO-GENERATED)

Workflow fixes:
- Fix DepartmentApproval interface to match backend schema
- Fix stage transformation for 0-indexed stageOrder
- Fix workflow list to show correct stage count from definition.stages

Cleanup:
- Move development artifacts to .trash directory
- Remove root-level package.json (was only for utility scripts)
- Add .trash/ to .gitignore
This commit is contained in:
Mahi
2026-02-08 18:44:05 -04:00
parent 2c10cd5662
commit d9de183e51
171 changed files with 10236 additions and 8386 deletions

View File

@@ -0,0 +1,280 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatDividerModule } from '@angular/material/divider';
import { Clipboard } from '@angular/cdk/clipboard';
import { BlockDto } from '../../../api/models';
@Component({
selector: 'app-block-detail-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatDividerModule,
],
template: `
<div class="dialog-container">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>view_module</mat-icon>
</div>
<div class="header-text">
<h2>Block #{{ data.blockNumber | number }}</h2>
<p class="subtitle">Block Details</p>
</div>
<button mat-icon-button (click)="close()" class="close-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
<div class="dialog-content">
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Block Number</div>
<div class="detail-value highlight">{{ data.blockNumber | number }}</div>
</div>
<div class="detail-item full-width">
<div class="detail-label">Block Hash</div>
<div class="detail-value hash" (click)="copyToClipboard(data.hash)">
<code>{{ data.hash }}</code>
<mat-icon class="copy-icon">content_copy</mat-icon>
</div>
</div>
<div class="detail-item full-width">
<div class="detail-label">Parent Hash</div>
<div class="detail-value hash" (click)="copyToClipboard(data.parentHash)">
<code>{{ data.parentHash }}</code>
<mat-icon class="copy-icon">content_copy</mat-icon>
</div>
</div>
<div class="detail-item">
<div class="detail-label">Timestamp</div>
<div class="detail-value">{{ data.timestamp | date:'medium' }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Transactions</div>
<div class="detail-value">
<span class="tx-count">{{ data.transactionCount }}</span>
</div>
</div>
<div class="detail-item">
<div class="detail-label">Gas Used</div>
<div class="detail-value">{{ data.gasUsed | number }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Gas Limit</div>
<div class="detail-value">{{ data.gasLimit | number }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Size</div>
<div class="detail-value">{{ formatSize(data.size) }}</div>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div class="dialog-actions">
<button mat-button (click)="close()">Close</button>
<button mat-flat-button color="primary" (click)="copyAllDetails()">
<mat-icon>content_copy</mat-icon>
Copy Details
</button>
</div>
</div>
`,
styles: [`
.dialog-container {
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
color: white;
}
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
}
}
.header-text {
flex: 1;
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
margin: 4px 0 0;
font-size: 0.85rem;
opacity: 0.9;
}
}
.close-btn {
color: white;
}
.dialog-content {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.detail-item {
&.full-width {
grid-column: 1 / -1;
}
}
.detail-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
font-weight: 500;
}
.detail-value {
font-size: 0.95rem;
color: #1f2937;
font-weight: 500;
&.highlight {
color: #2563EB;
font-size: 1.25rem;
font-weight: 700;
}
&.hash {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 8px;
transition: background 0.2s;
&:hover {
background: #eee;
}
code {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.8rem;
word-break: break-all;
flex: 1;
color: #1f2937;
}
.copy-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #6b7280;
}
}
.tx-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
padding: 4px 12px;
background: rgba(37, 99, 235, 0.1);
color: #2563EB;
border-radius: 16px;
font-weight: 600;
}
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
background: #fafafa;
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
`],
})
export class BlockDetailDialogComponent {
readonly data = inject<BlockDto>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<BlockDetailDialogComponent>);
private readonly clipboard = inject(Clipboard);
close(): void {
this.dialogRef.close();
}
copyToClipboard(text: string): void {
this.clipboard.copy(text);
}
copyAllDetails(): void {
const details = `Block #${this.data.blockNumber}
Hash: ${this.data.hash}
Parent Hash: ${this.data.parentHash}
Timestamp: ${this.data.timestamp}
Transactions: ${this.data.transactionCount}
Gas Used: ${this.data.gasUsed}
Gas Limit: ${this.data.gasLimit}
Size: ${this.data.size} bytes`;
this.clipboard.copy(details);
}
formatSize(bytes: number): string {
if (!bytes) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
}

View File

@@ -7,11 +7,14 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabsModule } from '@angular/material/tabs';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { Clipboard } from '@angular/cdk/clipboard';
import { interval, Subscription } from 'rxjs';
import { ApiService } from '../../../core/services/api.service';
import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
import { BlockDetailDialogComponent } from './block-detail-dialog.component';
import { TransactionDetailDialogComponent } from './transaction-detail-dialog.component';
@Component({
selector: 'app-blockchain-explorer-mini',
@@ -25,6 +28,7 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
MatProgressSpinnerModule,
MatTabsModule,
MatChipsModule,
MatDialogModule,
RouterModule,
],
template: `
@@ -240,18 +244,17 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
}
}
.header-text {
h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.header-text h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: white !important;
}
.subtitle {
margin: 4px 0 0;
font-size: 0.8rem;
opacity: 0.8;
}
.header-text .subtitle {
margin: 4px 0 0;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.9) !important;
}
.header-right {
@@ -598,6 +601,7 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
private readonly api = inject(ApiService);
private readonly clipboard = inject(Clipboard);
private readonly dialog = inject(MatDialog);
private refreshSubscription?: Subscription;
@Input() showViewAll = true;
@@ -655,11 +659,12 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
{ limit: 5 }
).toPromise();
if (blocksResponse?.data) {
if (blocksResponse?.data && blocksResponse.data.length > 0) {
this.blocks.set(blocksResponse.data);
if (blocksResponse.data.length > 0) {
this.latestBlock.set(blocksResponse.data[0].blockNumber);
}
this.latestBlock.set(blocksResponse.data[0].blockNumber);
} else {
// No real blocks - use mock data for demo
this.loadMockBlocks();
}
// Fetch transactions
@@ -687,18 +692,24 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
}
}
private loadMockData(): void {
// Mock blocks
private loadMockBlocks(): void {
const mockBlocks: BlockDto[] = Array.from({ length: 5 }, (_, i) => ({
blockNumber: 12345 - i,
blockNumber: 1628260 - i,
hash: `0x${this.generateRandomHash()}`,
parentHash: `0x${this.generateRandomHash()}`,
timestamp: new Date(Date.now() - i * 15000).toISOString(),
transactionCount: Math.floor(Math.random() * 20) + 1,
gasUsed: Math.floor(Math.random() * 8000000),
transactionCount: Math.floor(Math.random() * 5) + 1,
gasUsed: Math.floor(Math.random() * 500000) + 100000,
gasLimit: 15000000,
size: Math.floor(Math.random() * 50000) + 10000,
}));
this.blocks.set(mockBlocks);
this.latestBlock.set(mockBlocks[0].blockNumber);
}
private loadMockData(): void {
// Mock blocks
this.loadMockBlocks();
const mockTx: BlockchainTransactionDto[] = [
{
@@ -746,9 +757,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
},
];
this.blocks.set(mockBlocks);
this.transactions.set(mockTx);
this.latestBlock.set(mockBlocks[0].blockNumber);
this.totalTransactions.set(1234);
this.pendingTransactions.set(3);
this.networkStatus.set('HEALTHY');
@@ -761,11 +770,12 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
}
truncateHash(hash: string): string {
if (!hash || hash.length <= 18) return hash;
if (!hash || hash.length <= 18) return hash || '';
return `${hash.substring(0, 10)}...${hash.substring(hash.length - 6)}`;
}
getRelativeTime(timestamp: string): string {
if (!timestamp) return '';
const now = new Date();
const time = new Date(timestamp);
const diffSeconds = Math.floor((now.getTime() - time.getTime()) / 1000);
@@ -785,6 +795,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
}
getTxTypeIcon(type: string): string {
if (!type) return 'receipt_long';
const icons: Record<string, string> = {
LICENSE_MINT: 'verified',
DOCUMENT_HASH: 'fingerprint',
@@ -796,6 +807,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
}
formatTxType(type: string): string {
if (!type) return 'Unknown';
return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
@@ -805,12 +817,20 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
}
viewBlock(block: BlockDto): void {
// Could open a dialog or navigate
console.log('View block:', block);
this.dialog.open(BlockDetailDialogComponent, {
data: block,
width: '600px',
maxHeight: '90vh',
panelClass: 'blockchain-detail-dialog',
});
}
viewTransaction(tx: BlockchainTransactionDto): void {
// Could open a dialog or navigate
console.log('View transaction:', tx);
this.dialog.open(TransactionDetailDialogComponent, {
data: tx,
width: '600px',
maxHeight: '90vh',
panelClass: 'blockchain-detail-dialog',
});
}
}

View File

@@ -0,0 +1,399 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatDividerModule } from '@angular/material/divider';
import { MatChipsModule } from '@angular/material/chips';
import { Clipboard } from '@angular/cdk/clipboard';
import { BlockchainTransactionDto } from '../../../api/models';
@Component({
selector: 'app-transaction-detail-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatDividerModule,
MatChipsModule,
],
template: `
<div class="dialog-container">
<div class="dialog-header" [class]="'status-' + data.status.toLowerCase()">
<div class="header-icon">
<mat-icon>{{ getStatusIcon() }}</mat-icon>
</div>
<div class="header-text">
<h2>Transaction Details</h2>
<p class="subtitle">{{ formatTxType(data.type) }}</p>
</div>
<button mat-icon-button (click)="close()" class="close-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
<div class="dialog-content">
<div class="status-banner" [class]="data.status.toLowerCase()">
<mat-icon>{{ getStatusIcon() }}</mat-icon>
<span>{{ data.status }}</span>
</div>
<div class="detail-grid">
<div class="detail-item full-width">
<div class="detail-label">Transaction Hash</div>
<div class="detail-value hash" (click)="copyToClipboard(data.txHash)">
<code>{{ data.txHash }}</code>
<mat-icon class="copy-icon">content_copy</mat-icon>
</div>
</div>
<div class="detail-item">
<div class="detail-label">Type</div>
<div class="detail-value">
<mat-icon class="type-icon">{{ getTxTypeIcon() }}</mat-icon>
{{ formatTxType(data.type) }}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Status</div>
<div class="detail-value">
<mat-chip [class]="data.status.toLowerCase()">
{{ data.status }}
</mat-chip>
</div>
</div>
@if (data.blockNumber) {
<div class="detail-item">
<div class="detail-label">Block Number</div>
<div class="detail-value highlight">{{ data.blockNumber | number }}</div>
</div>
}
<div class="detail-item">
<div class="detail-label">Timestamp</div>
<div class="detail-value">{{ data.timestamp | date:'medium' }}</div>
</div>
@if (data.gasUsed) {
<div class="detail-item">
<div class="detail-label">Gas Used</div>
<div class="detail-value">{{ data.gasUsed | number }}</div>
</div>
}
@if (data.data && hasDataKeys()) {
<div class="detail-item full-width">
<div class="detail-label">Additional Data</div>
<div class="detail-value">
<pre class="data-json">{{ data.data | json }}</pre>
</div>
</div>
}
</div>
</div>
<mat-divider></mat-divider>
<div class="dialog-actions">
<button mat-button (click)="close()">Close</button>
<button mat-flat-button color="primary" (click)="copyAllDetails()">
<mat-icon>content_copy</mat-icon>
Copy Details
</button>
</div>
</div>
`,
styles: [`
.dialog-container {
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
color: white;
&.status-confirmed {
background: linear-gradient(135deg, #198754 0%, #28a745 100%);
}
&.status-pending {
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
}
&.status-failed {
background: linear-gradient(135deg, #DC3545 0%, #e74c3c 100%);
}
}
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
}
}
.header-text {
flex: 1;
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
margin: 4px 0 0;
font-size: 0.85rem;
opacity: 0.9;
}
}
.close-btn {
color: white;
}
.dialog-content {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
}
.status-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 8px;
margin-bottom: 24px;
font-weight: 600;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&.confirmed {
background: rgba(25, 135, 84, 0.1);
color: #198754;
}
&.pending {
background: rgba(37, 99, 235, 0.1);
color: #2563EB;
}
&.failed {
background: rgba(220, 53, 69, 0.1);
color: #DC3545;
}
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.detail-item {
&.full-width {
grid-column: 1 / -1;
}
}
.detail-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
font-weight: 500;
}
.detail-value {
font-size: 0.95rem;
color: #1f2937;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
&.highlight {
color: #2563EB;
font-size: 1.125rem;
font-weight: 700;
}
&.hash {
cursor: pointer;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 8px;
transition: background 0.2s;
&:hover {
background: #eee;
}
code {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.8rem;
word-break: break-all;
flex: 1;
color: #1f2937;
}
.copy-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #6b7280;
}
}
&.link {
color: #2563EB;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.type-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #1D0A69;
}
mat-chip {
font-size: 0.75rem;
&.confirmed {
background: rgba(25, 135, 84, 0.1) !important;
color: #198754 !important;
}
&.pending {
background: rgba(37, 99, 235, 0.1) !important;
color: #2563EB !important;
}
&.failed {
background: rgba(220, 53, 69, 0.1) !important;
color: #DC3545 !important;
}
}
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
background: #fafafa;
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
.data-json {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.8rem;
background: #f5f5f5;
color: #1f2937;
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
`],
})
export class TransactionDetailDialogComponent {
readonly data = inject<BlockchainTransactionDto>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<TransactionDetailDialogComponent>);
private readonly clipboard = inject(Clipboard);
close(): void {
this.dialogRef.close();
}
copyToClipboard(text: string): void {
this.clipboard.copy(text);
}
copyAllDetails(): void {
const details = `Transaction Hash: ${this.data.txHash}
Type: ${this.formatTxType(this.data.type)}
Status: ${this.data.status}
Block Number: ${this.data.blockNumber || 'Pending'}
Timestamp: ${this.data.timestamp}
${this.data.gasUsed ? `Gas Used: ${this.data.gasUsed}` : ''}`;
this.clipboard.copy(details);
}
hasDataKeys(): boolean {
return this.data.data && Object.keys(this.data.data).length > 0;
}
getStatusIcon(): string {
switch (this.data.status) {
case 'CONFIRMED': return 'check_circle';
case 'PENDING': return 'schedule';
case 'FAILED': return 'error';
default: return 'receipt_long';
}
}
getTxTypeIcon(): string {
const icons: Record<string, string> = {
LICENSE_MINT: 'verified',
DOCUMENT_HASH: 'fingerprint',
APPROVAL_RECORD: 'approval',
LICENSE_TRANSFER: 'swap_horiz',
REVOCATION: 'block',
};
return icons[this.data.type] || 'receipt_long';
}
formatTxType(type: string): string {
if (!type) return 'Blockchain Transaction';
const typeMap: Record<string, string> = {
'LICENSE_MINT': 'License Minting',
'DOCUMENT_HASH': 'Document Hash',
'APPROVAL_RECORD': 'Approval Record',
'LICENSE_TRANSFER': 'License Transfer',
'REVOCATION': 'License Revocation',
'TRANSACTION': 'Blockchain Transaction',
};
return typeMap[type] || type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
}

View File

@@ -1,4 +1,4 @@
import { Component, Input, OnInit } from '@angular/core';
import { Component, Input, OnInit, OnDestroy, OnChanges, SimpleChanges, AfterViewInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
@@ -9,6 +9,10 @@ import { MatExpansionModule } from '@angular/material/expansion';
import { MatTableModule } from '@angular/material/table';
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Subject } from 'rxjs';
import * as pdfjsLib from 'pdfjs-dist';
import { StorageService } from '../../../core/services/storage.service';
import { RuntimeConfigService } from '../../../core/services/runtime-config.service';
interface DocumentVersion {
id: string;
@@ -37,6 +41,10 @@ interface Document {
ipfsHash?: string;
url: string;
thumbnailUrl?: string;
previewDataUrl?: string; // Generated preview data URL
previewLoading?: boolean;
previewError?: boolean;
mimeType?: string;
uploadedAt: string;
uploadedBy: string;
currentVersion: number;
@@ -69,13 +77,27 @@ interface Document {
<div class="document-viewer">
<div class="documents-grid" *ngIf="documents && documents.length > 0">
<mat-card *ngFor="let doc of documents" class="document-card">
<!-- Document Thumbnail/Icon -->
<div class="document-thumbnail" [class.has-thumbnail]="doc.thumbnailUrl" (click)="previewDocument(doc)">
<img *ngIf="doc.thumbnailUrl" [src]="doc.thumbnailUrl" [alt]="doc.name" />
<div *ngIf="!doc.thumbnailUrl" class="document-icon">
<!-- Document Thumbnail/Preview -->
<div class="document-thumbnail"
[class.has-thumbnail]="doc.previewDataUrl || doc.thumbnailUrl"
[class.is-loading]="doc.previewLoading"
(click)="previewDocument(doc)">
<!-- Loading spinner -->
<div *ngIf="doc.previewLoading" class="preview-loading">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading preview...</span>
</div>
<!-- Actual preview image -->
<img *ngIf="(doc.previewDataUrl || doc.thumbnailUrl) && !doc.previewLoading"
[src]="doc.previewDataUrl || doc.thumbnailUrl"
[alt]="doc.name"
class="preview-image" />
<!-- Fallback icon when no preview available -->
<div *ngIf="!doc.previewDataUrl && !doc.thumbnailUrl && !doc.previewLoading" class="document-icon">
<mat-icon>{{ getFileIcon(doc.type) }}</mat-icon>
<span class="file-extension">{{ getFileExtension(doc.name) }}</span>
</div>
<!-- Hover overlay -->
<div class="preview-overlay">
<mat-icon>visibility</mat-icon>
<span>Preview</span>
@@ -252,22 +274,41 @@ interface Document {
.document-thumbnail {
position: relative;
width: 100%;
height: 180px;
height: 200px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
border-radius: 8px 8px 0 0;
&.has-thumbnail {
background: #f5f5f5;
background: #f8f9fa;
}
img {
&.is-loading {
background: #e9ecef;
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #666;
span {
font-size: 0.875rem;
}
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
background: white;
padding: 8px;
}
.document-icon {
@@ -502,17 +543,194 @@ interface Document {
`,
],
})
export class DocumentViewerComponent implements OnInit {
export class DocumentViewerComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges {
@Input() documents: Document[] = [];
@Input() showVersionHistory = true;
@Input() showDepartmentReviews = true;
@Input() apiBaseUrl?: string;
versionColumns = ['version', 'uploadedAt', 'uploadedBy', 'fileHash', 'actions'];
constructor(private dialog: MatDialog) {}
private destroy$ = new Subject<void>();
private storage = inject(StorageService);
private configService = inject(RuntimeConfigService);
/**
* Get effective API base URL (input override or runtime config)
*/
private get effectiveApiBaseUrl(): string {
return this.apiBaseUrl || this.configService.apiBaseUrl;
}
constructor(private dialog: MatDialog) {
// Configure PDF.js worker - use local asset
pdfjsLib.GlobalWorkerOptions.workerSrc = '/assets/pdf.worker.min.js';
}
ngOnInit(): void {
// Initialize component
// Load previews after documents are set
this.loadDocumentPreviews();
}
ngAfterViewInit(): void {
// Ensure previews are loaded after view is ready
setTimeout(() => this.loadDocumentPreviews(), 100);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadDocumentPreviews(): void {
if (!this.documents?.length) return;
this.documents.forEach(doc => {
if (!doc.previewDataUrl && !doc.previewLoading) {
this.generatePreview(doc);
}
});
}
private generatePreview(doc: Document): void {
const mimeType = doc.mimeType || doc.metadata?.mimeType || this.getMimeTypeFromFilename(doc.name);
if (this.isImage(mimeType)) {
this.loadImagePreview(doc);
} else if (this.isPdf(mimeType)) {
this.loadPdfPreview(doc);
}
// Other file types will show the default icon
}
private getMimeTypeFromFilename(filename: string): string {
const ext = filename?.split('.').pop()?.toLowerCase() || '';
const mimeMap: { [key: string]: string } = {
'pdf': 'application/pdf',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'svg': 'image/svg+xml',
};
return mimeMap[ext] || 'application/octet-stream';
}
private isImage(mimeType: string): boolean {
return mimeType?.startsWith('image/') || false;
}
private isPdf(mimeType: string): boolean {
return mimeType === 'application/pdf';
}
private async loadImagePreview(doc: Document): Promise<void> {
doc.previewLoading = true;
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?inline=true`;
try {
// Get authorization token
const token = this.storage.getToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Use fetch with authorization header
const response = await fetch(downloadUrl, {
credentials: 'include',
headers
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
// Convert blob to data URL
const reader = new FileReader();
reader.onload = () => {
doc.previewDataUrl = reader.result as string;
doc.previewLoading = false;
};
reader.onerror = () => {
console.warn('Failed to read image blob:', reader.error);
doc.previewLoading = false;
doc.previewError = true;
};
reader.readAsDataURL(blob);
} catch (err) {
console.warn('Image preview failed, using fallback icon:', err);
doc.previewLoading = false;
doc.previewError = true;
}
}
private async loadPdfPreview(doc: Document): Promise<void> {
doc.previewLoading = true;
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download`;
try {
// Get authorization token
const token = this.storage.getToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Fetch the PDF as an array buffer
const response = await fetch(downloadUrl, {
credentials: 'include',
headers
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
// Load PDF and render first page
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(1);
// Create a canvas for the thumbnail - scale to fit 320x200
const originalViewport = page.getViewport({ scale: 1 });
const scale = Math.min(320 / originalViewport.width, 200 / originalViewport.height);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get canvas context');
}
// Fill white background
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Render the page
await page.render({
canvasContext: ctx,
viewport
}).promise;
// Convert to data URL
doc.previewDataUrl = canvas.toDataURL('image/png');
doc.previewLoading = false;
} catch (err) {
// If PDF.js fails, still mark as done but use fallback icon
console.warn('PDF preview failed, using fallback icon:', err);
doc.previewLoading = false;
doc.previewError = true;
// Don't set previewDataUrl - will show default icon
}
}
getFileIcon(type: string): string {
@@ -529,7 +747,8 @@ export class DocumentViewerComponent implements OnInit {
return iconMap[type] || 'insert_drive_file';
}
getFileExtension(filename: string): string {
getFileExtension(filename: string | undefined | null): string {
if (!filename) return 'FILE';
const parts = filename.split('.');
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE';
}
@@ -558,24 +777,37 @@ export class DocumentViewerComponent implements OnInit {
}
downloadDocument(doc: Document): void {
// Create a temporary link and trigger download
const link = document.createElement('a');
link.href = doc.url;
link.download = doc.name;
link.click();
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download`;
// Open in new tab to trigger download
window.open(downloadUrl, '_blank');
}
downloadVersion(doc: Document, version: DocumentVersion): void {
alert(`Downloading version ${version.version} of ${doc.name}`);
// In real implementation, fetch version-specific URL and download
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?version=${version.version}`;
window.open(downloadUrl, '_blank');
}
previewDocument(doc: Document): void {
// Open preview dialog or new window
window.open(doc.url, '_blank');
const mimeType = doc.mimeType || doc.metadata?.mimeType || this.getMimeTypeFromFilename(doc.name);
const previewUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?inline=true`;
if (this.isImage(mimeType) || this.isPdf(mimeType)) {
// Open in new window with inline display
window.open(previewUrl, '_blank', 'width=900,height=700,scrollbars=yes');
} else {
// For other types, trigger download
this.downloadDocument(doc);
}
}
viewVersionHistory(doc: Document): void {
alert(`Version History for ${doc.name}\n\nTotal versions: ${doc.versions?.length}`);
}
// Called when documents input changes
ngOnChanges(changes: SimpleChanges): void {
if (changes['documents'] && !changes['documents'].firstChange) {
this.loadDocumentPreviews();
}
}
}

View File

@@ -1,22 +1,30 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-page-header',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, MatIconModule],
template: `
<div class="page-header">
<header class="page-header" role="banner">
<div class="page-header-content">
<h1 class="page-title">{{ title }}</h1>
@if (subtitle) {
<p class="page-subtitle">{{ subtitle }}</p>
@if (icon) {
<div class="page-header-icon">
<mat-icon>{{ icon }}</mat-icon>
</div>
}
<div class="page-header-text">
<h1 class="page-title" [id]="titleId">{{ title }}</h1>
@if (subtitle) {
<p class="page-subtitle" [attr.aria-describedby]="titleId">{{ subtitle }}</p>
}
</div>
</div>
<div class="page-actions">
<div class="page-actions" role="group" aria-label="Page actions">
<ng-content></ng-content>
</div>
</div>
</header>
`,
styles: [
`
@@ -30,21 +38,44 @@ import { CommonModule } from '@angular/common';
}
.page-header-content {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 200px;
}
.page-header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
mat-icon {
color: white;
font-size: 24px;
width: 24px;
height: 24px;
}
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
font-size: 1.75rem;
font-weight: 700;
color: var(--dbim-brown, #150202);
line-height: 1.2;
}
.page-subtitle {
margin: 4px 0 0;
margin: 8px 0 0;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.54);
color: var(--dbim-grey-2, #8E8E8E);
line-height: 1.5;
}
.page-actions {
@@ -58,6 +89,10 @@ import { CommonModule } from '@angular/common';
flex-direction: column;
align-items: flex-start;
}
.page-title {
font-size: 1.5rem;
}
}
`,
],
@@ -65,4 +100,8 @@ import { CommonModule } from '@angular/common';
export class PageHeaderComponent {
@Input({ required: true }) title!: string;
@Input() subtitle?: string;
@Input() icon?: string;
// Generate unique ID for accessibility
readonly titleId = `page-title-${Math.random().toString(36).substr(2, 9)}`;
}

View File

@@ -15,12 +15,15 @@ import { CommonModule } from '@angular/common';
span {
display: inline-flex;
align-items: center;
padding: 4px 12px;
padding: 4px 10px;
border-radius: 16px;
font-size: 0.75rem;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
white-space: nowrap;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
}
.status-draft {