Files
Goa-gel-fullstack/frontend/src/app/features/documents/document-upload/document-upload.component.ts
Mahi d9de183e51 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
2026-02-08 18:45:01 -04:00

1059 lines
30 KiB
TypeScript

import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatChipsModule } from '@angular/material/chips';
import { Clipboard } from '@angular/cdk/clipboard';
import { DocumentService } from '../services/document.service';
import { NotificationService } from '../../../core/services/notification.service';
import { DocumentType, DocumentResponseDto } from '../../../api/models';
import {
INPUT_LIMITS,
noScriptValidator,
noNullBytesValidator,
normalizeWhitespace,
} from '../../../shared/utils/form-validators';
import { createSubmitDebounce } from '../../../shared/utils/form-utils';
export interface DocumentUploadDialogData {
requestId: string;
}
type UploadState = 'idle' | 'uploading' | 'processing' | 'complete' | 'error';
@Component({
selector: 'app-document-upload',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatButtonModule,
MatIconModule,
MatProgressBarModule,
MatProgressSpinnerModule,
MatTooltipModule,
MatChipsModule,
],
template: `
<div class="upload-dialog">
<!-- Header -->
<div class="dialog-header">
<div class="header-icon">
<mat-icon>cloud_upload</mat-icon>
</div>
<div class="header-text">
<h2>Upload Document</h2>
<p>Add a document to your request</p>
</div>
</div>
<mat-dialog-content>
@if (uploadState() === 'complete' && uploadedDocument()) {
<!-- Success State -->
<div class="success-state">
<div class="success-icon">
<mat-icon>check_circle</mat-icon>
</div>
<h3>Document Uploaded Successfully</h3>
<p class="success-message">Your document has been securely uploaded and hashed on the blockchain.</p>
<!-- Document Info Card -->
<div class="document-card">
<div class="doc-preview">
@if (isImageFile()) {
<img [src]="previewUrl()" alt="Document preview" class="preview-image" />
} @else {
<div class="file-icon-large">
<mat-icon>{{ getFileIcon() }}</mat-icon>
</div>
}
</div>
<div class="doc-info">
<div class="doc-name">{{ uploadedDocument()!.originalFilename }}</div>
<div class="doc-meta">
<span class="meta-item">
<mat-icon>folder</mat-icon>
{{ formatDocType(uploadedDocument()!.docType) }}
</span>
<span class="meta-item">
<mat-icon>schedule</mat-icon>
Just now
</span>
</div>
</div>
</div>
<!-- Blockchain Hash Display -->
<div class="hash-section">
<div class="hash-header">
<mat-icon>fingerprint</mat-icon>
<span>Document Hash (SHA-256)</span>
<div class="verified-badge">
<mat-icon>verified</mat-icon>
Blockchain Verified
</div>
</div>
<div class="hash-display">
<code class="hash-value">{{ uploadedDocument()!.currentHash }}</code>
<button
mat-icon-button
(click)="copyHash()"
matTooltip="Copy hash to clipboard"
class="copy-btn"
>
<mat-icon>{{ hashCopied() ? 'check' : 'content_copy' }}</mat-icon>
</button>
</div>
<p class="hash-hint">
This cryptographic hash uniquely identifies your document and is permanently recorded on the blockchain.
</p>
</div>
</div>
} @else {
<!-- Upload Form -->
<form [formGroup]="form" class="upload-form">
<!-- Document Type -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Document Type</mat-label>
<mat-icon matPrefix>category</mat-icon>
<mat-select formControlName="docType">
@for (type of documentTypes; track type.value) {
<mat-option [value]="type.value">
<div class="type-option">
<mat-icon>{{ type.icon }}</mat-icon>
{{ type.label }}
</div>
</mat-option>
}
</mat-select>
@if (form.controls.docType.hasError('required')) {
<mat-error>Please select a document type</mat-error>
}
</mat-form-field>
<!-- Description -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description (optional)</mat-label>
<mat-icon matPrefix>notes</mat-icon>
<textarea
matInput
formControlName="description"
rows="2"
placeholder="Add any additional notes about this document"
[maxlength]="limits.DESCRIPTION_MAX"
></textarea>
@if (form.controls.description.hasError('maxlength')) {
<mat-error>Maximum {{ limits.DESCRIPTION_MAX }} characters allowed</mat-error>
}
@if (form.controls.description.hasError('dangerousContent')) {
<mat-error>Invalid characters detected</mat-error>
}
<mat-hint align="end">{{ form.controls.description.value?.length || 0 }}/{{ limits.DESCRIPTION_MAX }}</mat-hint>
</mat-form-field>
<!-- Drop Zone -->
<div
class="drop-zone"
[class.has-file]="selectedFile()"
[class.drag-over]="isDragOver()"
[class.uploading]="uploadState() === 'uploading'"
[class.error]="uploadState() === 'error'"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
(click)="uploadState() !== 'uploading' && fileInput.click()"
>
<input
#fileInput
type="file"
hidden
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx"
(change)="onFileSelected($event)"
/>
@if (uploadState() === 'uploading' || uploadState() === 'processing') {
<!-- Uploading State -->
<div class="upload-progress-container">
<div class="progress-circle">
<svg viewBox="0 0 100 100">
<circle class="progress-bg" cx="50" cy="50" r="45" />
<circle
class="progress-fill"
cx="50"
cy="50"
r="45"
[style.stroke-dashoffset]="getProgressOffset()"
/>
</svg>
<div class="progress-text">
@if (uploadState() === 'processing') {
<mat-icon class="processing-icon">sync</mat-icon>
} @else {
<span class="percentage">{{ uploadProgress() }}%</span>
}
</div>
</div>
<div class="upload-status">
@if (uploadState() === 'processing') {
<span class="status-text">Generating blockchain hash...</span>
} @else {
<span class="status-text">Uploading {{ selectedFile()?.name }}</span>
<span class="status-detail">{{ formatBytes(uploadedBytes()) }} / {{ formatBytes(totalBytes()) }}</span>
}
</div>
</div>
} @else if (selectedFile()) {
<!-- File Selected State -->
<div class="file-selected">
@if (isImageFile()) {
<img [src]="previewUrl()" alt="Preview" class="preview-thumb" />
} @else {
<div class="file-icon">
<mat-icon>{{ getFileIcon() }}</mat-icon>
</div>
}
<div class="file-details">
<span class="file-name">{{ selectedFile()!.name }}</span>
<span class="file-size">{{ formatFileSize(selectedFile()!.size) }}</span>
</div>
<button
mat-icon-button
(click)="clearFile($event)"
matTooltip="Remove file"
class="remove-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<!-- Empty State -->
<div class="empty-state">
<div class="upload-icon-wrapper">
<mat-icon>cloud_upload</mat-icon>
</div>
<span class="primary-text">Drag & drop a file here</span>
<span class="secondary-text">or click to browse</span>
<div class="file-types">
<mat-chip-set>
<mat-chip>PDF</mat-chip>
<mat-chip>JPG</mat-chip>
<mat-chip>PNG</mat-chip>
<mat-chip>DOC</mat-chip>
</mat-chip-set>
</div>
<span class="size-limit">Maximum file size: 10MB</span>
</div>
}
</div>
@if (uploadState() === 'error') {
<div class="error-message">
<mat-icon>error</mat-icon>
<span>{{ errorMessage() }}</span>
<button mat-button color="primary" (click)="resetUpload()">Try Again</button>
</div>
}
<!-- Progress Bar (linear backup) -->
@if (uploadState() === 'uploading') {
<mat-progress-bar
mode="determinate"
[value]="uploadProgress()"
class="linear-progress"
></mat-progress-bar>
}
</form>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (uploadState() === 'complete') {
<button mat-flat-button color="primary" (click)="onDone()">
<mat-icon>check</mat-icon>
Done
</button>
} @else {
<button mat-button (click)="onCancel()" [disabled]="uploadState() === 'uploading'">
Cancel
</button>
<button
mat-flat-button
color="primary"
(click)="onUpload()"
[disabled]="!canUpload() || uploadState() === 'uploading'"
>
@if (uploadState() === 'uploading') {
<mat-spinner diameter="20" class="btn-spinner"></mat-spinner>
Uploading...
} @else {
<ng-container>
<mat-icon>upload</mat-icon>
Upload Document
</ng-container>
}
</button>
}
</mat-dialog-actions>
</div>
`,
styles: [`
.upload-dialog {
min-width: 480px;
max-width: 560px;
}
.dialog-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
margin: -24px -24px 24px -24px;
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 {
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
p {
margin: 4px 0 0;
font-size: 0.85rem;
opacity: 0.85;
}
}
}
.upload-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.full-width {
width: 100%;
}
.type-option {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--dbim-grey-2, #8E8E8E);
}
}
/* Drop Zone */
.drop-zone {
border: 2px dashed var(--dbim-grey-1, #C6C6C6);
border-radius: 12px;
padding: 32px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: var(--dbim-linen, #EBEAEA);
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(.uploading) {
border-color: var(--dbim-blue-mid, #2563EB);
background: rgba(37, 99, 235, 0.05);
}
&.drag-over {
border-color: var(--dbim-blue-mid, #2563EB);
background: rgba(37, 99, 235, 0.1);
transform: scale(1.01);
}
&.has-file {
border-style: solid;
border-color: var(--dbim-success, #198754);
background: rgba(25, 135, 84, 0.05);
}
&.uploading {
cursor: default;
border-color: var(--dbim-blue-mid, #2563EB);
background: rgba(37, 99, 235, 0.05);
}
&.error {
border-color: var(--dbim-error, #DC3545);
background: rgba(220, 53, 69, 0.05);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
.upload-icon-wrapper {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69), var(--dbim-blue-mid, #2563EB));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: white;
}
}
.primary-text {
font-size: 1rem;
font-weight: 500;
color: var(--dbim-brown, #150202);
}
.secondary-text {
font-size: 0.9rem;
color: var(--dbim-grey-2, #8E8E8E);
}
.file-types {
margin-top: 8px;
mat-chip {
font-size: 0.7rem;
min-height: 24px;
}
}
.size-limit {
font-size: 0.75rem;
color: var(--dbim-grey-2, #8E8E8E);
margin-top: 4px;
}
}
.file-selected {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
.preview-thumb {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--dbim-grey-1, #C6C6C6);
}
.file-icon {
width: 64px;
height: 64px;
border-radius: 8px;
background: var(--dbim-blue-subtle, #DBEAFE);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--dbim-blue-mid, #2563EB);
}
}
.file-details {
flex: 1;
text-align: left;
.file-name {
display: block;
font-weight: 500;
color: var(--dbim-brown, #150202);
word-break: break-word;
}
.file-size {
display: block;
font-size: 0.85rem;
color: var(--dbim-grey-2, #8E8E8E);
margin-top: 4px;
}
}
.remove-btn {
color: var(--dbim-grey-2, #8E8E8E);
&:hover {
color: var(--dbim-error, #DC3545);
}
}
}
/* Upload Progress */
.upload-progress-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
.progress-circle {
position: relative;
width: 100px;
height: 100px;
svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.progress-bg {
fill: none;
stroke: var(--dbim-grey-1, #C6C6C6);
stroke-width: 8;
}
.progress-fill {
fill: none;
stroke: var(--dbim-blue-mid, #2563EB);
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 283;
transition: stroke-dashoffset 0.3s ease;
}
.progress-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
.percentage {
font-size: 1.25rem;
font-weight: 600;
color: var(--dbim-blue-mid, #2563EB);
}
.processing-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--dbim-blue-mid, #2563EB);
animation: spin 1s linear infinite;
}
}
}
.upload-status {
text-align: center;
.status-text {
display: block;
font-weight: 500;
color: var(--dbim-brown, #150202);
}
.status-detail {
display: block;
font-size: 0.85rem;
color: var(--dbim-grey-2, #8E8E8E);
margin-top: 4px;
}
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.linear-progress {
margin-top: 8px;
border-radius: 4px;
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(220, 53, 69, 0.1);
border-radius: 8px;
color: var(--dbim-error, #DC3545);
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
span {
flex: 1;
font-size: 0.9rem;
}
}
/* Success State */
.success-state {
text-align: center;
.success-icon {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(25, 135, 84, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
color: var(--dbim-success, #198754);
}
}
h3 {
margin: 0 0 8px;
font-size: 1.25rem;
color: var(--dbim-brown, #150202);
}
.success-message {
margin: 0 0 24px;
color: var(--dbim-grey-2, #8E8E8E);
font-size: 0.9rem;
}
}
.document-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--dbim-linen, #EBEAEA);
border-radius: 12px;
margin-bottom: 20px;
text-align: left;
.doc-preview {
.preview-image {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 8px;
}
.file-icon-large {
width: 64px;
height: 64px;
border-radius: 8px;
background: var(--dbim-blue-subtle, #DBEAFE);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--dbim-blue-mid, #2563EB);
}
}
}
.doc-info {
flex: 1;
.doc-name {
font-weight: 500;
color: var(--dbim-brown, #150202);
word-break: break-word;
}
.doc-meta {
display: flex;
gap: 16px;
margin-top: 8px;
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.8rem;
color: var(--dbim-grey-2, #8E8E8E);
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
}
}
.hash-section {
background: linear-gradient(135deg, rgba(29, 10, 105, 0.05), rgba(37, 99, 235, 0.05));
border: 1px solid rgba(37, 99, 235, 0.2);
border-radius: 12px;
padding: 16px;
text-align: left;
.hash-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--dbim-blue-mid, #2563EB);
}
span {
font-weight: 500;
color: var(--dbim-brown, #150202);
}
.verified-badge {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(25, 135, 84, 0.1);
border-radius: 16px;
font-size: 0.75rem;
color: var(--dbim-success, #198754);
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: var(--dbim-success, #198754);
}
}
}
.hash-display {
display: flex;
align-items: center;
gap: 8px;
background: white;
border-radius: 8px;
padding: 12px;
border: 1px solid var(--dbim-grey-1, #C6C6C6);
.hash-value {
flex: 1;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.75rem;
word-break: break-all;
color: var(--dbim-blue-dark, #1D0A69);
}
.copy-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
color: var(--dbim-blue-mid, #2563EB);
}
}
.hash-hint {
margin: 12px 0 0;
font-size: 0.75rem;
color: var(--dbim-grey-2, #8E8E8E);
line-height: 1.5;
}
}
.btn-spinner {
display: inline-block;
margin-right: 8px;
}
mat-dialog-actions {
padding: 16px 24px !important;
margin: 0 -24px -24px !important;
border-top: 1px solid var(--dbim-linen, #EBEAEA);
}
`],
})
export class DocumentUploadComponent {
private readonly fb = inject(FormBuilder);
private readonly documentService = inject(DocumentService);
private readonly notification = inject(NotificationService);
private readonly dialogRef = inject(MatDialogRef<DocumentUploadComponent>);
private readonly data: DocumentUploadDialogData = inject(MAT_DIALOG_DATA);
private readonly clipboard = inject(Clipboard);
/** Debounce handler to prevent double-click submissions */
private readonly submitDebounce = createSubmitDebounce(500);
/** Input limits exposed for template binding */
readonly limits = INPUT_LIMITS;
// State signals
readonly uploadState = signal<UploadState>('idle');
readonly selectedFile = signal<File | null>(null);
readonly isDragOver = signal(false);
readonly uploadProgress = signal(0);
readonly uploadedBytes = signal(0);
readonly totalBytes = signal(0);
readonly uploadedDocument = signal<DocumentResponseDto | null>(null);
readonly errorMessage = signal('');
readonly previewUrl = signal<string | null>(null);
readonly hashCopied = signal(false);
readonly documentTypes: { value: DocumentType; label: string; icon: string }[] = [
{ value: 'ID_PROOF', label: 'Identity Proof', icon: 'badge' },
{ value: 'ADDRESS_PROOF', label: 'Address Proof', icon: 'location_on' },
{ value: 'FIRE_SAFETY', label: 'Fire Safety Certificate', icon: 'local_fire_department' },
{ value: 'FLOOR_PLAN', label: 'Floor Plan', icon: 'apartment' },
{ value: 'SITE_PLAN', label: 'Site Plan', icon: 'map' },
{ value: 'BUILDING_PERMIT', label: 'Building Permit', icon: 'home_work' },
{ value: 'BUSINESS_LICENSE', label: 'Business License', icon: 'storefront' },
{ value: 'PHOTOGRAPH', label: 'Photograph', icon: 'photo_camera' },
{ value: 'NOC', label: 'No Objection Certificate', icon: 'verified' },
{ value: 'LICENSE_COPY', label: 'License Copy', icon: 'file_copy' },
{ value: 'HEALTH_CERT', label: 'Health Certificate', icon: 'health_and_safety' },
{ value: 'TAX_CLEARANCE', label: 'Tax Clearance', icon: 'receipt_long' },
{ value: 'OTHER', label: 'Other Document', icon: 'description' },
];
readonly form = this.fb.nonNullable.group({
docType: ['' as DocumentType, [Validators.required]],
description: ['', [
Validators.maxLength(INPUT_LIMITS.DESCRIPTION_MAX),
noScriptValidator(),
noNullBytesValidator(),
]],
});
canUpload(): boolean {
return this.form.valid && this.selectedFile() !== null;
}
isImageFile(): boolean {
const file = this.selectedFile();
if (!file) return false;
return file.type.startsWith('image/');
}
getFileIcon(): string {
const file = this.selectedFile() || this.uploadedDocument();
if (!file) return 'description';
const filename = 'name' in file ? file.name : file.originalFilename;
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'pdf': return 'picture_as_pdf';
case 'doc':
case 'docx': return 'article';
case 'xls':
case 'xlsx': return 'table_chart';
case 'jpg':
case 'jpeg':
case 'png':
case 'gif': return 'image';
default: return 'description';
}
}
getProgressOffset(): number {
const circumference = 283; // 2 * PI * 45 (radius)
return circumference - (this.uploadProgress() / 100) * circumference;
}
formatDocType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
// Drag & Drop handlers
onDragOver(event: DragEvent): void {
event.preventDefault();
this.isDragOver.set(true);
}
onDragLeave(event: DragEvent): void {
event.preventDefault();
this.isDragOver.set(false);
}
onDrop(event: DragEvent): void {
event.preventDefault();
this.isDragOver.set(false);
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
this.selectFile(files[0]);
}
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectFile(input.files[0]);
}
}
private selectFile(file: File): void {
// Validate file size
if (file.size > 10 * 1024 * 1024) {
this.notification.error('File size must be less than 10MB');
return;
}
// Validate file type
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
if (!allowedTypes.includes(file.type)) {
this.notification.error('Invalid file type. Please upload PDF, JPG, PNG, or DOC files.');
return;
}
this.selectedFile.set(file);
this.uploadState.set('idle');
this.errorMessage.set('');
// Generate preview for images
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
this.previewUrl.set(e.target?.result as string);
};
reader.readAsDataURL(file);
} else {
this.previewUrl.set(null);
}
}
clearFile(event: Event): void {
event.stopPropagation();
this.selectedFile.set(null);
this.previewUrl.set(null);
this.uploadState.set('idle');
}
resetUpload(): void {
this.uploadState.set('idle');
this.uploadProgress.set(0);
this.errorMessage.set('');
}
formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
formatBytes(bytes: number): string {
return this.formatFileSize(bytes);
}
copyHash(): void {
const doc = this.uploadedDocument();
if (doc?.currentHash) {
this.clipboard.copy(doc.currentHash);
this.hashCopied.set(true);
this.notification.success('Hash copied to clipboard');
setTimeout(() => this.hashCopied.set(false), 2000);
}
}
onUpload(): void {
// Debounce to prevent double-click submissions
this.submitDebounce(() => this.performUpload());
}
private performUpload(): void {
const file = this.selectedFile();
if (!file || this.form.invalid || this.uploadState() === 'uploading') return;
this.uploadState.set('uploading');
this.uploadProgress.set(0);
const rawValues = this.form.getRawValue();
// Normalize description
const docType = rawValues.docType;
const description = normalizeWhitespace(rawValues.description);
this.documentService.uploadDocumentWithProgress(this.data.requestId, file, docType, description).subscribe({
next: (progress) => {
if (progress.complete && progress.response) {
this.uploadState.set('processing');
// Simulate brief processing time for blockchain hash generation
setTimeout(() => {
this.uploadedDocument.set(progress.response!);
this.uploadState.set('complete');
this.notification.success('Document uploaded and hashed successfully');
}, 800);
} else {
this.uploadProgress.set(progress.progress);
this.uploadedBytes.set(progress.loaded);
this.totalBytes.set(progress.total);
}
},
error: (err) => {
this.uploadState.set('error');
this.errorMessage.set(err.message || 'Failed to upload document. Please try again.');
},
});
}
onCancel(): void {
this.dialogRef.close();
}
onDone(): void {
this.dialogRef.close(this.uploadedDocument());
}
}