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: `
cloud_upload

Upload Document

Add a document to your request

@if (uploadState() === 'complete' && uploadedDocument()) {
check_circle

Document Uploaded Successfully

Your document has been securely uploaded and hashed on the blockchain.

@if (isImageFile()) { Document preview } @else {
{{ getFileIcon() }}
}
{{ uploadedDocument()!.originalFilename }}
folder {{ formatDocType(uploadedDocument()!.docType) }} schedule Just now
fingerprint Document Hash (SHA-256)
verified Blockchain Verified
{{ uploadedDocument()!.currentHash }}

This cryptographic hash uniquely identifies your document and is permanently recorded on the blockchain.

} @else {
Document Type category @for (type of documentTypes; track type.value) {
{{ type.icon }} {{ type.label }}
}
@if (form.controls.docType.hasError('required')) { Please select a document type }
Description (optional) notes @if (form.controls.description.hasError('maxlength')) { Maximum {{ limits.DESCRIPTION_MAX }} characters allowed } @if (form.controls.description.hasError('dangerousContent')) { Invalid characters detected } {{ form.controls.description.value?.length || 0 }}/{{ limits.DESCRIPTION_MAX }}
@if (uploadState() === 'uploading' || uploadState() === 'processing') {
@if (uploadState() === 'processing') { sync } @else { {{ uploadProgress() }}% }
@if (uploadState() === 'processing') { Generating blockchain hash... } @else { Uploading {{ selectedFile()?.name }} {{ formatBytes(uploadedBytes()) }} / {{ formatBytes(totalBytes()) }} }
} @else if (selectedFile()) {
@if (isImageFile()) { Preview } @else {
{{ getFileIcon() }}
}
{{ selectedFile()!.name }} {{ formatFileSize(selectedFile()!.size) }}
} @else {
cloud_upload
Drag & drop a file here or click to browse
PDF JPG PNG DOC
Maximum file size: 10MB
}
@if (uploadState() === 'error') {
error {{ errorMessage() }}
} @if (uploadState() === 'uploading') { }
}
@if (uploadState() === 'complete') { } @else { }
`, 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); 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('idle'); readonly selectedFile = signal(null); readonly isDragOver = signal(false); readonly uploadProgress = signal(0); readonly uploadedBytes = signal(0); readonly totalBytes = signal(0); readonly uploadedDocument = signal(null); readonly errorMessage = signal(''); readonly previewUrl = signal(null); readonly hashCopied = signal(false); readonly documentTypes: { value: DocumentType; label: string; icon: string }[] = [ { value: 'FIRE_SAFETY_CERTIFICATE', label: 'Fire Safety Certificate', icon: 'local_fire_department' }, { value: 'BUILDING_PLAN', label: 'Building Plan', icon: 'apartment' }, { value: 'PROPERTY_OWNERSHIP', label: 'Property Ownership', icon: 'home' }, { value: 'INSPECTION_REPORT', label: 'Inspection Report', icon: 'fact_check' }, { value: 'POLLUTION_CERTIFICATE', label: 'Pollution Certificate', icon: 'eco' }, { value: 'ELECTRICAL_SAFETY_CERTIFICATE', label: 'Electrical Safety Certificate', icon: 'electrical_services' }, { value: 'STRUCTURAL_STABILITY_CERTIFICATE', label: 'Structural Stability Certificate', icon: 'foundation' }, { value: 'IDENTITY_PROOF', label: 'Identity Proof', icon: 'badge' }, { value: 'ADDRESS_PROOF', label: 'Address Proof', icon: 'location_on' }, { 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()); } }