diff --git a/package-lock.json b/package-lock.json index 00b265c9b..f135664cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "html2canvas": "^1.4.1", "jspdf": "^3.0.1", "jspdf-autotable": "^5.0.2", + "lightgallery": "^2.9.0", "lodash": "4.17.21", "moment": "^2.29.4", "ngx-mat-select-search": "^7.0.10", @@ -16285,6 +16286,15 @@ "immediate": "~3.0.5" } }, + "node_modules/lightgallery": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/lightgallery/-/lightgallery-2.9.0.tgz", + "integrity": "sha512-58Ud1DyhD2ao58t+kPEqSZrjFxg23tGd5ZKr75erm7q31g5xhUtWUJH3sTUkhHzlyJAKHj5eTrJ37HQRXG4Wbg==", + "license": "GPLv3", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/package.json b/package.json index 2274e9ca2..931360dd8 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "html2canvas": "^1.4.1", "jspdf": "^3.0.1", "jspdf-autotable": "^5.0.2", + "lightgallery": "^2.9.0", "lodash": "4.17.21", "moment": "^2.29.4", "ngx-mat-select-search": "^7.0.10", diff --git a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html index 60514f16b..931faf695 100644 --- a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html +++ b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html @@ -1,8 +1,56 @@

- {{ 'labels.heading.Upload' | translate }} {{ entityType }} {{ 'labels.heading.Documents' | translate }} + + {{ 'labels.heading.Add Client Identifier' | translate }} + + + {{ 'labels.heading.Upload' | translate }} {{ entityType }} {{ 'labels.heading.Documents' | translate }} +

+ + + + {{ 'labels.inputs.Document Type' | translate }} + + + {{ docType.name }} + + + + {{ 'labels.inputs.Document Type' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Status' | translate }} + + + {{ status.label }} + + + + {{ 'labels.inputs.Status' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Document Key' | translate }} + + + {{ 'labels.inputs.Document Key' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Description' | translate }} + + + + {{ 'labels.inputs.File Name' | translate }} @@ -27,7 +75,12 @@ [disabled]="!uploadDocumentForm.valid" [mat-dialog-close]="uploadDocumentForm.value" > - {{ 'labels.buttons.Upload' | translate }} + + {{ 'labels.buttons.Add' | translate }} + + + {{ 'labels.buttons.Upload' | translate }} + diff --git a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts index 3eeba8a93..a5d65ddd5 100644 --- a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts @@ -27,10 +27,14 @@ export class UploadDocumentDialogComponent implements OnInit { uploadDocumentForm: UntypedFormGroup; /** Upload Document Data */ uploadDocumentData: any = []; - /** Triggers description field */ + /** Triggers identity fields (documentType, status, documentKey) */ documentIdentifier = false; /** Entity Type */ entityType: string; + /** Allowed Document Types for identifiers */ + allowedDocumentTypes: any[] = []; + /** Status options for identifiers */ + statusOptions: any[] = []; /** * @param {MatDialogRef} dialogRef Dialog reference element @@ -44,6 +48,8 @@ export class UploadDocumentDialogComponent implements OnInit { ) { this.documentIdentifier = data.documentIdentifier; this.entityType = data.entityType; + this.allowedDocumentTypes = data.allowedDocumentTypes || []; + this.statusOptions = data.statusOptions || []; } ngOnInit() { @@ -54,14 +60,39 @@ export class UploadDocumentDialogComponent implements OnInit { * Creates the upload Document form. */ createUploadDocumentForm() { - this.uploadDocumentForm = this.formBuilder.group({ - fileName: [ - '', - Validators.required - ], - description: [''], - file: [''] - }); + if (this.documentIdentifier) { + // Unified form for identity: identifier fields + document upload + this.uploadDocumentForm = this.formBuilder.group({ + documentTypeId: [ + '', + Validators.required + ], + status: [ + 'Active', + Validators.required + ], + documentKey: [ + '', + Validators.required + ], + description: [''], + fileName: [ + '', + Validators.required + ], + file: [''] + }); + } else { + // Standard document upload form + this.uploadDocumentForm = this.formBuilder.group({ + fileName: [ + '', + Validators.required + ], + description: [''], + file: [''] + }); + } } /** diff --git a/src/app/clients/clients-view/documents-tab/documents-tab.component.html b/src/app/clients/clients-view/documents-tab/documents-tab.component.html index 1be9a0313..4692cdf53 100644 --- a/src/app/clients/clients-view/documents-tab/documents-tab.component.html +++ b/src/app/clients/clients-view/documents-tab/documents-tab.component.html @@ -3,7 +3,6 @@ [entityId]="entityId" [entityDocuments]="entityDocuments" [callbackUpload]="uploadDocument" - [callbackDownload]="downloadDocument" [callbackDelete]="deleteDocument" > diff --git a/src/app/clients/clients-view/documents-tab/documents-tab.component.ts b/src/app/clients/clients-view/documents-tab/documents-tab.component.ts index dd6d954f3..94e2d0168 100644 --- a/src/app/clients/clients-view/documents-tab/documents-tab.component.ts +++ b/src/app/clients/clients-view/documents-tab/documents-tab.component.ts @@ -32,13 +32,6 @@ export class DocumentsTabComponent { this.entityId = this.route.parent.snapshot.paramMap.get('clientId'); } - downloadDocument(documentId: string) { - this.clientsService.downloadClientDocument(this.entityId, documentId).subscribe((res) => { - const url = window.URL.createObjectURL(res); - window.open(url); - }); - } - deleteDocument(documentId: string) { this.clientsService.deleteClientDocument(this.entityId, documentId).subscribe((res) => {}); } diff --git a/src/app/clients/clients-view/identities-tab/identities-tab.component.html b/src/app/clients/clients-view/identities-tab/identities-tab.component.html index 46c3e5b4a..23afb056c 100644 --- a/src/app/clients/clients-view/identities-tab/identities-tab.component.html +++ b/src/app/clients/clients-view/identities-tab/identities-tab.component.html @@ -37,14 +37,45 @@ {{ 'labels.inputs.Identity Documents' | translate }} -
- {{ document.name }} - +
+
+
+ + + + +
+ + {{ document.fileName || document.name }} +
+
+
+ +
+
+
+
{{ document.name }}
+
{{ document.fileName }}
+
+
+ + {{ 'labels.text.NoDocuments' | translate }} + @@ -58,19 +89,12 @@ {{ 'labels.inputs.Actions' | translate }} -
+ +
diff --git a/src/app/clients/clients-view/identities-tab/identities-tab.component.scss b/src/app/clients/clients-view/identities-tab/identities-tab.component.scss index d4b43ad60..1e88a6539 100644 --- a/src/app/clients/clients-view/identities-tab/identities-tab.component.scss +++ b/src/app/clients/clients-view/identities-tab/identities-tab.component.scss @@ -1,12 +1,8 @@ -.tab-container { - padding: 1%; - margin: 1%; +@use '../../../../assets/styles/colours' as *; - .header-row { - display: flex; - justify-content: space-between; - align-items: center; - } +.tab-container { + padding: 1rem; + margin: 1rem 0; h3 { margin: 0; @@ -23,3 +19,117 @@ } } } + +.document-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.document-card { + width: 200px; + background: $white; + border: 1px solid $silver; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 1px 4px rgba($dark-grey, 0.12); + display: flex; + flex-direction: column; + + &:hover { + box-shadow: 0 4px 12px rgba($dark-grey, 0.14); + } +} + +.thumb { + position: relative; + background: $clouds; + min-height: 7.5rem; + display: flex; + align-items: center; + justify-content: center; + cursor: default; + + &.clickable { + cursor: pointer; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.preview-overlay { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + background: rgba($white, 0.9); + border-radius: 50%; + padding: 0.4rem; + color: $peter-river; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + + .thumb:hover &, + .thumb:focus & { + opacity: 1; + } +} + +.placeholder { + color: $light-grey; + text-align: center; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: center; + justify-content: center; + + fa-icon { + font-size: 1.125rem; + } + + span { + font-size: 0.75rem; + line-height: 1.2; + } +} + +.preview-btn { + position: absolute; + right: 0.5rem; + bottom: 0.5rem; + background: rgba($black, 0.65); + color: $white; +} + +.card-body { + padding: 0.625rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.title { + font-weight: 700; + color: $dark-grey; +} + +.meta { + font-size: 0.75rem; + color: $asbestos; +} + +.muted { + color: $concrete; +} + +.document-lightbox-host { + display: none; +} diff --git a/src/app/clients/clients-view/identities-tab/identities-tab.component.ts b/src/app/clients/clients-view/identities-tab/identities-tab.component.ts index b4ae11604..11ed551af 100644 --- a/src/app/clients/clients-view/identities-tab/identities-tab.component.ts +++ b/src/app/clients/clients-view/identities-tab/identities-tab.component.ts @@ -1,5 +1,6 @@ /** Angular Imports */ -import { Component, ViewChild } from '@angular/core'; +import { Component, DestroyRef, ElementRef, inject, OnDestroy, ViewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; import { MatTable, @@ -15,21 +16,21 @@ import { } from '@angular/material/table'; import { ActivatedRoute } from '@angular/router'; -/** Custom Models */ -import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; -import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; -import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base'; - /** Custom Components */ -import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { DeleteDialogComponent } from '../../../shared/delete-dialog/delete-dialog.component'; import { UploadDocumentDialogComponent } from '../custom-dialogs/upload-document-dialog/upload-document-dialog.component'; /** Custom Services */ +import lightGallery from 'lightgallery'; +import lgFullscreen from 'lightgallery/plugins/fullscreen'; +import lgThumbnail from 'lightgallery/plugins/thumbnail'; +import lgZoom from 'lightgallery/plugins/zoom'; +import type { LightGallery } from 'lightgallery/lightgallery'; +import type { GalleryItem } from 'lightgallery/lg-utils'; +import { DocumentPreviewService } from 'app/shared/services/document-preview.service'; import { TranslateService } from '@ngx-translate/core'; import { ClientsService } from '../../clients.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { NgStyle, NgFor } from '@angular/common'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -43,7 +44,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ...STANDALONE_SHARED_IMPORTS, FaIconComponent, MatTable, - NgStyle, MatColumnDef, MatHeaderCellDef, MatHeaderCell, @@ -55,7 +55,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatRow ] }) -export class IdentitiesTabComponent { +export class IdentitiesTabComponent implements OnDestroy { + private destroyRef = inject(DestroyRef); + /** Client Identities */ clientIdentities: any; /** Client Identifier Template */ @@ -75,110 +77,146 @@ export class IdentitiesTabComponent { /** Identifiers Table */ @ViewChild('identifiersTable', { static: true }) identifiersTable: MatTable; + /** LightGallery host */ + @ViewChild('identityLightbox', { static: true }) identityLightbox: ElementRef; + + /** Cached thumbnails for previewable docs */ + previewThumbnails: Record = {}; + private lightboxInstance: LightGallery | null = null; + private readonly lightboxPlugins = [ + lgZoom, + lgThumbnail, + lgFullscreen + ]; /** * @param {ActivatedRoute} route Activated Route * @param {MatDialog} dialog Mat Dialog * @param {ClientsService} clientService Clients Service + * @param {TranslateService} translateService Translate Service + * @param {DocumentPreviewService} documentPreviewService Preview helper */ constructor( private route: ActivatedRoute, private dialog: MatDialog, private clientService: ClientsService, - private translateService: TranslateService + private translateService: TranslateService, + private documentPreviewService: DocumentPreviewService ) { this.clientId = this.route.parent.snapshot.paramMap.get('clientId'); - this.route.data.subscribe((data: { clientIdentities: any; clientIdentifierTemplate: any }) => { - this.clientIdentities = data.clientIdentities; - this.clientIdentifierTemplate = data.clientIdentifierTemplate; - }); + this.route.data + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((data: { clientIdentities: any; clientIdentifierTemplate: any }) => { + this.clientIdentities = data.clientIdentities; + this.clientIdentifierTemplate = data.clientIdentifierTemplate; + this.prefetchThumbnails(); + }); + } + + ngOnDestroy(): void { + this.destroyLightbox(); + if (Array.isArray(this.clientIdentities)) { + this.clientIdentities.forEach((identity: any) => { + identity.documents?.forEach((doc: any) => this.documentPreviewService.release(doc.id)); + }); + } + } + + /** TrackBy function for documents ngFor */ + trackByDocumentId(_: number, doc: any): any { + return doc?.id; } /** - * Download Client Document - * @param {string} parentEntityId Parent Entity Id - * @param {string} documentId Document ID - */ - download(parentEntityId: string, documentId: string) { - this.clientService.downloadClientIdentificationDocument(parentEntityId, documentId).subscribe((res) => { - const url = window.URL.createObjectURL(res); - window.open(url); - }); - } - - /** - * Add Client Identifier + * Add Client Identifier with unified form (identifier + document upload) */ addIdentifier() { - for (let index = 0; index < this.clientIdentifierTemplate.allowedDocumentTypes.length; index++) { - this.clientIdentifierTemplate.allowedDocumentTypes[index].name = this.translateService.instant( - `labels.catalogs.${this.clientIdentifierTemplate.allowedDocumentTypes[index].name}` - ); - } - const formfields: FormfieldBase[] = [ - new SelectBase({ - controlName: 'documentTypeId', - label: this.translateService.instant('labels.inputs.Document Type'), - value: '', - options: { label: 'name', value: 'id', data: this.clientIdentifierTemplate.allowedDocumentTypes }, - required: true, - order: 1 - }), - new SelectBase({ - controlName: 'status', - label: this.translateService.instant('labels.inputs.Status'), - value: '2', - options: { - label: 'label', - value: 'value', - data: [ - { label: this.translateService.instant('labels.catalogs.Active'), value: 'Active' }, - { label: this.translateService.instant('labels.catalogs.Inactive'), value: 'Inactive' } - ] - }, - required: true, - order: 2 - }), - new InputBase({ - controlName: 'documentKey', - label: this.translateService.instant('labels.inputs.Document Key'), - value: '', - type: 'text', - required: true, - order: 3 - }), - new InputBase({ - controlName: 'description', - label: this.translateService.instant('labels.inputs.Description'), - value: '', - type: 'text', - order: 4 - }) + // Translate document type names + const translatedDocTypes = this.clientIdentifierTemplate.allowedDocumentTypes.map((docType: any) => ({ + ...docType, + name: this.translateService.instant(`labels.catalogs.${docType.name}`) + })); + const statusOptions = [ + { label: this.translateService.instant('labels.catalogs.Active'), value: 'Active' }, + { label: this.translateService.instant('labels.catalogs.Inactive'), value: 'Inactive' } ]; - const data = { - title: this.translateService.instant('labels.heading.Add Client Identifier'), - formfields: formfields - }; - const addIdentifierDialogRef = this.dialog.open(FormDialogComponent, { data }); - addIdentifierDialogRef.afterClosed().subscribe((response: any) => { - if (response.data) { - this.clientService.addClientIdentifier(this.clientId, response.data.value).subscribe((res: any) => { - this.clientIdentities.push({ - id: res.resourceId, - description: response.data.value.description, - documentType: this.clientIdentifierTemplate.allowedDocumentTypes.filter( - (doc: any) => doc.id === response.data.value.documentTypeId - )[0], - documentKey: response.data.value.documentKey, - documents: [], - clientId: this.clientId, - status: - response.data.value.status === 'Active' - ? 'clientIdentifierStatusType.active' - : 'clientIdentifierStatusType.inactive' - }); - this.identifiersTable.renderRows(); + + const dialogRef = this.dialog.open(UploadDocumentDialogComponent, { + data: { + documentIdentifier: true, + allowedDocumentTypes: translatedDocTypes, + statusOptions: statusOptions + } + }); + + dialogRef.afterClosed().subscribe((response: any) => { + if (response) { + // Create identifier data + const identifierData = { + documentTypeId: response.documentTypeId, + status: response.status, + documentKey: response.documentKey, + description: response.description + }; + + // First create the identifier + this.clientService.addClientIdentifier(this.clientId, identifierData).subscribe({ + next: (res: any) => { + const newIdentifierId = res.resourceId; + const selectedDocType = this.clientIdentifierTemplate.allowedDocumentTypes.find( + (doc: any) => doc.id === response.documentTypeId + ); + + // Create new identity entry + const newIdentity: any = { + id: newIdentifierId, + description: response.description, + documentType: selectedDocType, + documentKey: response.documentKey, + documents: [] as any[], + clientId: this.clientId, + status: + response.status === 'Active' + ? 'clientIdentifierStatusType.active' + : 'clientIdentifierStatusType.inactive' + }; + + // If file was uploaded, attach document to the identifier + if (response.file) { + const formData: FormData = new FormData(); + formData.append('name', response.fileName); + formData.append('file', response.file); + this.clientService.uploadClientIdentifierDocument(newIdentifierId, formData).subscribe({ + next: (docRes: any) => { + const newDoc = { + id: docRes.resourceId, + parentEntityType: 'client_identifiers', + parentEntityId: newIdentifierId, + name: response.fileName, + fileName: response.file.name + }; + newIdentity.documents.push(newDoc); + this.clientIdentities.push(newIdentity); + this.identifiersTable.renderRows(); + this.setThumbnail(newDoc); + }, + error: (err: any) => { + console.error('Failed to upload document', err); + // Still add the identifier even if document upload fails + this.clientIdentities.push(newIdentity); + this.identifiersTable.renderRows(); + } + }); + } else { + // No file, just add the identifier + this.clientIdentities.push(newIdentity); + this.identifiersTable.renderRows(); + } + }, + error: (err: any) => { + console.error('Failed to create identifier', err); + } }); } }); @@ -204,31 +242,104 @@ export class IdentitiesTabComponent { }); } - /** - * Upload Document - * @param {number} index Index - * @param {string} identifierId Identifier Id - */ - uploadDocument(index: number, identifierId: string) { - const uploadDocumentDialogRef = this.dialog.open(UploadDocumentDialogComponent, { - data: { documentIdentifier: true } - }); - uploadDocumentDialogRef.afterClosed().subscribe((dialogResponse: any) => { - if (dialogResponse) { - const formData: FormData = new FormData(); - formData.append('name', dialogResponse.fileName); - formData.append('file', dialogResponse.file); - this.clientService.uploadClientIdentifierDocument(identifierId, formData).subscribe((res: any) => { - this.clientIdentities[index].documents.push({ - id: res.resourceId, - parentEntityType: 'client_identifiers', - parentEntityId: identifierId, - name: dialogResponse.fileName, - fileName: dialogResponse.file.name + isPreviewable(document: any): boolean { + return this.documentPreviewService.isPreviewable(document); + } + + async openDocumentPreview(identity: any, document: any): Promise { + if (!this.isPreviewable(document)) { + return; + } + try { + const previewableDocs = (identity.documents || []).filter((doc: any) => this.isPreviewable(doc)); + const items: GalleryItem[] = []; + for (const doc of previewableDocs) { + try { + const preview = await this.documentPreviewService.resolvePreviewUrl(doc, () => + this.clientService.downloadClientIdentificationDocument(doc.parentEntityId || identity.id, doc.id) + ); + if (preview.type === 'image') { + this.previewThumbnails[doc.id] = preview.url; + } + items.push({ + src: preview.url, + thumb: preview.type === 'image' ? preview.url : undefined, + subHtml: this.buildSubHtml(doc, identity), + iframe: preview.type === 'pdf' }); - this.identifiersTable.renderRows(); - }); + } catch (error) { + console.error('Preview failed for document', doc.id, error); + } } + if (!items.length) { + return; + } + const startIndex = Math.max( + 0, + previewableDocs.findIndex((doc: any) => doc.id === document.id) + ); + this.destroyLightbox(); + this.lightboxInstance = lightGallery(this.identityLightbox.nativeElement, { + dynamic: true, + dynamicEl: items, + plugins: this.lightboxPlugins, + licenseKey: '0000-0000-000-0000', + download: false, + escKey: true, + closable: true, + zoomFromOrigin: true + }); + this.lightboxInstance.openGallery(startIndex); + } catch (error) { + console.error('Unable to open preview', error); + } + } + + private buildSubHtml(document: any, identity: any): string { + const caption = document.description + ? `

${this.escapeHtml(document.description)}

` + : ''; + const identityLabel = identity?.documentKey + ? `

${this.escapeHtml(identity.documentKey)}

` + : ''; + return `

${this.escapeHtml(document.name || 'Document')}

${caption}${identityLabel}
`; + } + + private escapeHtml(value: string): string { + return value + ? value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + : ''; + } + + private destroyLightbox(): void { + if (this.lightboxInstance) { + this.lightboxInstance.destroy(); + this.lightboxInstance = null; + } + } + + private setThumbnail(document: any): void { + if (!this.documentPreviewService.isPreviewable(document)) { + return; + } + this.documentPreviewService + .resolvePreviewUrl(document, () => + this.clientService.downloadClientIdentificationDocument(document.parentEntityId || this.clientId, document.id) + ) + .then((preview) => { + if (preview.type === 'image') { + this.previewThumbnails[document.id] = preview.url; + } + }) + .catch((): void => undefined); + } + + private prefetchThumbnails(): void { + if (!Array.isArray(this.clientIdentities)) { + return; + } + this.clientIdentities.forEach((identity: any) => { + identity.documents?.forEach((doc: any) => this.setThumbnail(doc)); }); } } diff --git a/src/app/loans/loans-view/loan-documents-tab/loan-documents-tab.component.html b/src/app/loans/loans-view/loan-documents-tab/loan-documents-tab.component.html index 1be9a0313..4692cdf53 100644 --- a/src/app/loans/loans-view/loan-documents-tab/loan-documents-tab.component.html +++ b/src/app/loans/loans-view/loan-documents-tab/loan-documents-tab.component.html @@ -3,7 +3,6 @@ [entityId]="entityId" [entityDocuments]="entityDocuments" [callbackUpload]="uploadDocument" - [callbackDownload]="downloadDocument" [callbackDelete]="deleteDocument" > diff --git a/src/app/loans/loans-view/loan-documents-tab/loan-documents-tab.component.ts b/src/app/loans/loans-view/loan-documents-tab/loan-documents-tab.component.ts index b0119b086..333bb4233 100644 --- a/src/app/loans/loans-view/loan-documents-tab/loan-documents-tab.component.ts +++ b/src/app/loans/loans-view/loan-documents-tab/loan-documents-tab.component.ts @@ -78,13 +78,6 @@ export class LoanDocumentsTabComponent implements OnInit { this.entityDocuments = data; } - downloadDocument(documentId: string) { - this.loansService.downloadLoanDocument(this.entityId, documentId).subscribe((res) => { - const url = window.URL.createObjectURL(res); - window.open(url); - }); - } - uploadDocument(formData: FormData): any { return this.loansService.loadLoanDocument(this.entityId, formData); } diff --git a/src/app/savings/savings-account-view/savings-documents-tab/savings-documents-tab.component.html b/src/app/savings/savings-account-view/savings-documents-tab/savings-documents-tab.component.html index 1be9a0313..4692cdf53 100644 --- a/src/app/savings/savings-account-view/savings-documents-tab/savings-documents-tab.component.html +++ b/src/app/savings/savings-account-view/savings-documents-tab/savings-documents-tab.component.html @@ -3,7 +3,6 @@ [entityId]="entityId" [entityDocuments]="entityDocuments" [callbackUpload]="uploadDocument" - [callbackDownload]="downloadDocument" [callbackDelete]="deleteDocument" > diff --git a/src/app/savings/savings-account-view/savings-documents-tab/savings-documents-tab.component.ts b/src/app/savings/savings-account-view/savings-documents-tab/savings-documents-tab.component.ts index 45d1349b0..ac08e5f2b 100644 --- a/src/app/savings/savings-account-view/savings-documents-tab/savings-documents-tab.component.ts +++ b/src/app/savings/savings-account-view/savings-documents-tab/savings-documents-tab.component.ts @@ -67,13 +67,6 @@ export class SavingsDocumentsTabComponent { this.entityDocuments = data; } - downloadDocument(documentId: string) { - this.savingsService.downloadSavingsDocument(this.entityId, documentId).subscribe((res) => { - const url = window.URL.createObjectURL(res); - window.open(url); - }); - } - uploadDocument(formData: FormData): any { return this.savingsService.loadSavingsDocument(this.entityId, formData); } diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 3b0707432..936713e6e 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -39,6 +39,7 @@ import { faEyeSlash, faFile, faFileAlt, + faFilePdf, faFileUpload, faFileWord, faFillDrip, @@ -164,6 +165,7 @@ export class IconsModule { faEyeSlash, faFile, faFileAlt, + faFilePdf, faFileUpload, faFileWord, faFileExcel, diff --git a/src/app/shared/services/document-preview.service.ts b/src/app/shared/services/document-preview.service.ts new file mode 100644 index 000000000..1322ff360 --- /dev/null +++ b/src/app/shared/services/document-preview.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@angular/core'; +import { Observable, firstValueFrom } from 'rxjs'; + +export type DocumentPreviewType = 'image' | 'pdf' | 'other'; + +export interface DocumentDescriptor { + id: string; + name?: string; + description?: string; + fileName?: string; + fileData?: string; + mimeType?: string; +} + +interface CachedPreview { + url: string; + type: DocumentPreviewType; + isObjectUrl: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class DocumentPreviewService { + private readonly previewCache = new Map(); + + /** + * Determine whether the document can be previewed inline. + */ + isPreviewable(document: DocumentDescriptor): boolean { + const type = this.detectType(document.mimeType, document.fileName, document.fileData); + return type === 'image' || type === 'pdf'; + } + + /** + * Resolve a preview URL for a document, caching object URLs to avoid duplicate downloads. + */ + async resolvePreviewUrl( + document: DocumentDescriptor, + downloadFn: (descriptor: DocumentDescriptor) => Observable + ): Promise<{ url: string; type: DocumentPreviewType }> { + const cached = this.previewCache.get(document.id); + if (cached) { + return { url: cached.url, type: cached.type }; + } + + const inline = this.getInlineData(document); + if (inline) { + const type = this.detectType(inline.mimeType, document.fileName, document.fileData); + this.previewCache.set(document.id, { url: inline.url, type, isObjectUrl: inline.isObjectUrl }); + return { url: inline.url, type }; + } + + const blob = await firstValueFrom(downloadFn(document)); + const objectUrl = URL.createObjectURL(blob); + const type = this.detectType(blob.type || document.mimeType, document.fileName, document.fileData); + this.previewCache.set(document.id, { url: objectUrl, type, isObjectUrl: true }); + return { url: objectUrl, type }; + } + + /** + * Revoke an object URL if it was created by this service. + */ + release(documentId: string): void { + const cached = this.previewCache.get(documentId); + if (cached?.isObjectUrl) { + URL.revokeObjectURL(cached.url); + } + this.previewCache.delete(documentId); + } + + /** + * Clean up all cached URLs. + */ + clear(): void { + this.previewCache.forEach((cached) => { + if (cached.isObjectUrl) { + URL.revokeObjectURL(cached.url); + } + }); + this.previewCache.clear(); + } + + private getInlineData(document: DocumentDescriptor): { url: string; mimeType?: string; isObjectUrl: boolean } | null { + if (!document.fileData) { + return null; + } + const mimeMatch = document.fileData.match(/^data:(.*?);/); + return { + url: document.fileData, + mimeType: mimeMatch ? mimeMatch[1] : document.mimeType, + isObjectUrl: false + }; + } + + private detectType(mimeType?: string, fileName?: string, fileData?: string): DocumentPreviewType { + const normalizedMime = (mimeType || this.extractMimeFromData(fileData) || '').toLowerCase(); + if (normalizedMime.includes('pdf')) { + return 'pdf'; + } + if (normalizedMime.startsWith('image/')) { + return 'image'; + } + + const extension = (fileName || '').split('.').pop()?.toLowerCase(); + if (extension === 'pdf') { + return 'pdf'; + } + if (extension && [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'bmp', + 'webp', + 'svg' + ].includes(extension)) { + return 'image'; + } + + return 'other'; + } + + private extractMimeFromData(fileData?: string): string | undefined { + if (!fileData || !fileData.startsWith('data:')) { + return undefined; + } + const match = fileData.match(/^data:(.*?);/); + return match ? match[1] : undefined; + } +} diff --git a/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.html b/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.html index a42a3764b..ceffcd493 100644 --- a/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.html +++ b/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.html @@ -1,5 +1,5 @@
-
+

{{ 'labels.heading.Documents' | translate }}

@@ -9,51 +9,59 @@
- - - - - - - - - - - - - - - - - - - - + + + + +
+ + {{ document.fileName || document.name }} +
+
+
+ +
+ +
+
{{ document.name }}
+
{{ document.fileName }}
+
{{ document.description }}
+
+ +
+
+ + + - - -
{{ 'labels.inputs.name' | translate }}{{ document.name }}{{ 'labels.inputs.Description' | translate }}{{ document.description }}{{ 'labels.inputs.File Name' | translate }}{{ document.fileName }}{{ 'labels.inputs.Actions' | translate }} - - -
- - + +
+

{{ 'labels.text.NoDocuments' | translate }}

+

{{ 'labels.text.UploadDocumentHint' | translate }}

+
+
+ +
diff --git a/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.scss b/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.scss index 669274734..e1435b341 100644 --- a/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.scss +++ b/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.scss @@ -1,16 +1,159 @@ -table { - width: 100%; - margin-top: 3%; +@use '../../../../assets/styles/colours' as *; - .document-action-button { - min-width: 26px; - padding: 0 6px; - margin: 4px; - line-height: 25px; +.tab-container { + padding: 1rem; + margin: 1rem 0; +} + +.header-row { + display: flex; + align-items: center; +} + +.document-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.75rem; +} + +.document-card { + width: 240px; + background: $white; + border: 1px solid $silver; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 1px 4px rgba($dark-grey, 0.12); + display: flex; + flex-direction: column; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba($dark-grey, 0.14); } } -.tab-container { - padding: 1%; - margin: 1%; +.thumb { + position: relative; + background: $clouds; + min-height: 9.5rem; + display: flex; + align-items: center; + justify-content: center; + cursor: default; + + &.clickable { + cursor: pointer; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.preview-overlay { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + background: rgba($white, 0.9); + border-radius: 50%; + padding: 0.4rem; + color: $peter-river; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + + .thumb:hover &, + .thumb:focus & { + opacity: 1; + } +} + +.placeholder { + color: $light-grey; + text-align: center; + padding: 0.875rem; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + + fa-icon { + font-size: 22px; + } + + span { + font-size: 12px; + line-height: 1.2; + } +} + +.preview-btn { + position: absolute; + right: 0.5rem; + bottom: 0.5rem; + background: rgba($black, 0.65); + color: $white; +} + +.card-body { + padding: 0.75rem 0.875rem 0.875rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.title { + font-weight: 700; + color: $dark-grey; +} + +.meta { + font-size: 12px; + color: $asbestos; +} + +.description { + font-size: 13px; + color: $dark-grey; + min-height: 32px; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 0.375rem; + margin-top: 0.25rem; +} + +.empty-state { + margin-top: 0.75rem; + padding: 1.25rem; + text-align: center; + background: $light-grey; + border: 1px dashed $silver; + border-radius: 4px; + color: $dark-grey; + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: center; + + .muted { + color: $concrete; + margin: 0.25rem 0 0; + font-size: 0.8125rem; + } +} + +.document-lightbox-host { + display: none; } diff --git a/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.ts b/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.ts index ecff9021e..80d2fc744 100644 --- a/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.ts +++ b/src/app/shared/tabs/entity-documents-tab/entity-documents-tab.component.ts @@ -1,25 +1,17 @@ -import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { MatPaginator } from '@angular/material/paginator'; -import { MatSort, MatSortHeader } from '@angular/material/sort'; -import { - MatTable, - MatTableDataSource, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; +import lightGallery from 'lightgallery'; +import lgFullscreen from 'lightgallery/plugins/fullscreen'; +import lgThumbnail from 'lightgallery/plugins/thumbnail'; +import lgZoom from 'lightgallery/plugins/zoom'; +import type { LightGallery } from 'lightgallery/lightgallery'; +import type { GalleryItem } from 'lightgallery/lg-utils'; import { UploadDocumentDialogComponent } from 'app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component'; import { ClientsService } from 'app/clients/clients.service'; import { LoansService } from 'app/loans/loans.service'; import { SavingsService } from 'app/savings/savings.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; +import { DocumentPreviewService } from 'app/shared/services/document-preview.service'; import { Observable } from 'rxjs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -28,80 +20,55 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; selector: 'mifosx-entity-documents-tab', templateUrl: './entity-documents-tab.component.html', styleUrls: ['./entity-documents-tab.component.scss'], + standalone: true, imports: [ ...STANDALONE_SHARED_IMPORTS, - FaIconComponent, - MatTable, - MatSort, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatSortHeader, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, - MatPaginator + FaIconComponent ] }) -export class EntityDocumentsTabComponent implements OnInit { - @ViewChild('documentsTable', { static: true }) documentsTable: MatTable; +export class EntityDocumentsTabComponent implements OnInit, OnDestroy { + @ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef; @Input() entityId: string; @Input() entityType: string; @Input() entityDocuments: any; @Input() callbackUpload: (documentData: FormData) => Observable; - @Input() callbackDownload: (documentId: string) => void; @Input() callbackDelete: (documentId: string) => void; - /** Status of the loan account */ - status: any; - /** Choice */ - choice: boolean; - - /** Columns to be displayed in loan documents table. */ - displayedColumns: string[] = [ - 'name', - 'description', - 'filename', - 'actions' + previewThumbnails: Record = {}; + private lightboxInstance: LightGallery | null = null; + private readonly lightboxPlugins = [ + lgZoom, + lgThumbnail, + lgFullscreen ]; - /** Data source for loan documents table. */ - dataSource: MatTableDataSource; - /** Paginator for codes table. */ - @ViewChild(MatPaginator) paginator: MatPaginator; - /** Sorter for codes table. */ - @ViewChild(MatSort) sort: MatSort; - - /** - * - * @param {MatDialog} dialog Dialog for Inputs. - * @param {SavingsService} savingsService Savings Account services. - * @param {LoansService} loansService Loan Account services. - * @param {ClientsService} clientsService Client services. - */ constructor( public dialog: MatDialog, private savingsService: SavingsService, private loansService: LoansService, - private clientsService: ClientsService + private clientsService: ClientsService, + private documentPreviewService: DocumentPreviewService ) {} - ngOnInit() { - this.dataSource = new MatTableDataSource(this.entityDocuments); - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; + ngOnInit(): void { + this.prefetchThumbnails(); } - downloadDocument(documentId: string) { - this.callbackDownload(documentId); + ngOnDestroy(): void { + this.destroyLightbox(); + if (Array.isArray(this.entityDocuments)) { + this.entityDocuments.forEach((doc: any) => this.documentPreviewService.release(doc.id)); + } } - uploadDocument() { + /** TrackBy function for documents ngFor */ + trackByDocumentId(_: number, doc: any): any { + return doc?.id; + } + + uploadDocument(): void { const uploadDocumentDialogRef = this.dialog.open(UploadDocumentDialogComponent, { data: { documentIdentifier: false, entityType: '' }, width: '33rem' @@ -113,34 +80,143 @@ export class EntityDocumentsTabComponent implements OnInit { formData.append('file', dialogResponse.file); formData.append('description', dialogResponse.description); this.callbackUpload(formData).subscribe((res: any) => { - this.entityDocuments.push({ + const newDocument = { id: res.resourceId, parentEntityType: this.entityType, parentEntityId: this.entityId, name: dialogResponse.fileName, description: dialogResponse.description, fileName: dialogResponse.file.name - }); - this.documentsTable.renderRows(); + }; + this.entityDocuments.push(newDocument); + this.setThumbnail(newDocument); }); } }); } - deleteDocument(documentId: string, name: number) { + deleteDocument(documentId: string, name: string): void { const deleteDocumentDialogRef = this.dialog.open(DeleteDialogComponent, { data: { deleteContext: `Document: ${name}` } }); deleteDocumentDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { this.callbackDelete(documentId); - for (let i = 0; i < this.entityDocuments.length; i++) { - if (this.entityDocuments[i].id === documentId) { - this.entityDocuments.splice(i, 1); - } + const index = this.entityDocuments.findIndex((doc: any) => doc.id === documentId); + if (index !== -1) { + this.entityDocuments.splice(index, 1); } - this.documentsTable.renderRows(); + this.documentPreviewService.release(documentId); + delete this.previewThumbnails[documentId]; } }); } + + isPreviewable(document: any): boolean { + return this.documentPreviewService.isPreviewable(document); + } + + async openPreview(document: any): Promise { + if (!this.isPreviewable(document)) { + return; + } + try { + const previewables = this.entityDocuments.filter((doc: any) => this.isPreviewable(doc)); + const galleryItems: GalleryItem[] = []; + + for (const item of previewables) { + try { + const preview = await this.documentPreviewService.resolvePreviewUrl(item, (descriptor) => + this.getDownloadObservable(descriptor.id) + ); + if (preview.type === 'image') { + this.previewThumbnails[item.id] = preview.url; + } + galleryItems.push({ + src: preview.url, + thumb: preview.type === 'image' ? preview.url : undefined, + subHtml: this.buildSubHtml(item), + iframe: preview.type === 'pdf' + }); + } catch (error) { + console.error('Preview failed for document', item.id, error); + } + } + + if (!galleryItems.length) { + return; + } + + const startIndex = Math.max( + 0, + previewables.findIndex((item: any) => item.id === document.id) + ); + this.destroyLightbox(); + this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, { + dynamic: true, + dynamicEl: galleryItems, + plugins: this.lightboxPlugins, + download: false, + closable: true, + escKey: true, + zoomFromOrigin: true + }); + + this.lightboxInstance.openGallery(startIndex); + } catch (error) { + console.error('Unable to open preview', error); + } + } + + private destroyLightbox(): void { + if (this.lightboxInstance) { + this.lightboxInstance.destroy(); + this.lightboxInstance = null; + } + } + + private getDownloadObservable(documentId: string): Observable { + if (this.entityType === 'savings') { + return this.savingsService.downloadSavingsDocument(this.entityId, documentId); + } + if (this.entityType === 'loans') { + return this.loansService.downloadLoanDocument(this.entityId, documentId); + } + return this.clientsService.downloadClientDocument(this.entityId, documentId); + } + + private buildSubHtml(document: any): string { + const description = document.description + ? `

${this.escapeHtml(document.description)}

` + : ''; + const filename = document.fileName ? `

${this.escapeHtml(document.fileName)}

` : ''; + return `

${this.escapeHtml(document.name || 'Document')}

${description}${filename}
`; + } + + private escapeHtml(value: string): string { + return value + ? value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + : ''; + } + + private setThumbnail(document: any): void { + if (!this.documentPreviewService.isPreviewable(document)) { + return; + } + this.documentPreviewService + .resolvePreviewUrl(document, () => this.getDownloadObservable(document.id)) + .then((preview) => { + if (preview.type === 'image') { + this.previewThumbnails[document.id] = preview.url; + } + }) + .catch((): void => undefined); + } + + private prefetchThumbnails(): void { + if (!Array.isArray(this.entityDocuments)) { + return; + } + this.entityDocuments.forEach((doc: any) => this.setThumbnail(doc)); + } } diff --git a/src/assets/translations/cs-CS.json b/src/assets/translations/cs-CS.json index 829b7ce72..48609d943 100644 --- a/src/assets/translations/cs-CS.json +++ b/src/assets/translations/cs-CS.json @@ -3100,6 +3100,7 @@ "No checker inbox data available for this search": "Pro toto vyhledávání nejsou k dispozici žádná data doručené pošty Checker.", "No client was found": "Nebyl nalezen žádný klient", "No data found": "Nenalezena žádná data", + "NoDocuments": "Žádné dokumenty k dispozici.", "No loan locked available": "Žádná půjčka není k dispozici.", "No notifications": "Žádné oznámení", "No pending loan available for disbursal": "Není k dispozici žádná nevyřízená půjčka k vyplacení.", @@ -3342,7 +3343,8 @@ "Voluntary": "Dobrovolný", "Write a note": "Napište poznámku", "“Maker-Checker” principle requires every tasks": "Princip „Maker-Checker“ vyžaduje, aby každý úkol prováděli dva lidé, aby se snížila pravděpodobnost chyb a zneužití. Jedna osoba proces iniciuje a druhá jej dokončí.", - "Buy Down Fees": "Poplatky za odkup" + "Buy Down Fees": "Poplatky za odkup", + "UploadDocumentHint": "Nahrajte PDF nebo obrázek pro vygenerování náhledu." }, "titles": { "Dashboard": "Přístrojová deska", diff --git a/src/assets/translations/de-DE.json b/src/assets/translations/de-DE.json index c76640efb..a7ca9389d 100644 --- a/src/assets/translations/de-DE.json +++ b/src/assets/translations/de-DE.json @@ -3099,6 +3099,7 @@ "No checker inbox data available for this search": "Für diese Suche sind keine Checker-Posteingangsdaten verfügbar.", "No client was found": "Es wurde kein Kunde gefunden", "No data found": "Keine Daten gefunden", + "NoDocuments": "Keine Dokumente verfügbar.", "No loan locked available": "Kein gesperrter Kredit verfügbar.", "No notifications": "Keine benachrichtigungen", "No pending loan available for disbursal": "Kein ausstehendes Darlehen zur Auszahlung verfügbar.", @@ -3341,7 +3342,8 @@ "Voluntary": "Freiwillig", "Write a note": "Schreib eine Notitz", "“Maker-Checker” principle requires every tasks": "Das „Maker-Checker“-Prinzip erfordert, dass jede Aufgabe von zwei Personen erledigt wird, um das Risiko von Fehlern und Missbrauch zu verringern. Eine Person leitet den Prozess ein und die zweite Person schließt ihn ab.", - "Buy Down Fees": "Kaufgebühren" + "Buy Down Fees": "Kaufgebühren", + "UploadDocumentHint": "Laden Sie ein PDF oder Bild hoch, um eine Vorschau zu erstellen." }, "titles": { "Dashboard": "Armaturenbrett", diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 4762b1dc5..60aca9c82 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -3193,6 +3193,7 @@ "No checker inbox data available for this search": "No checker inbox data available for this search.", "No client was found": "No client was found", "No data found": "No data found", + "NoDocuments": "No documents available.", "No loan locked available": "No loan locked available.", "No notifications": "No notifications", "No pending loan available for disbursal": "No pending loan available for disbursal.", @@ -3449,7 +3450,8 @@ "Write a note": "Write a note", "“Maker-Checker” principle requires every tasks": "The “Maker-Checker” principle requires every tasks to be completed by two people to reduce the chance of errors and misuse. One person initiates the process and the second completes it.", "Buy Down Fees": "Buy Down Fees", - "Undo Write-off Description": "This action will reverse the write-off transaction and restore the loan to its previous active status. Please provide a note explaining the reason for this action." + "Undo Write-off Description": "This action will reverse the write-off transaction and restore the loan to its previous active status. Please provide a note explaining the reason for this action.", + "UploadDocumentHint": "Upload a PDF or image to generate a preview." }, "titles": { "Dashboard": "Dashboard", diff --git a/src/assets/translations/es-CL.json b/src/assets/translations/es-CL.json index a176eab9a..5bf224e09 100644 --- a/src/assets/translations/es-CL.json +++ b/src/assets/translations/es-CL.json @@ -3100,6 +3100,7 @@ "No checker inbox data available for this search": "No hay datos de la bandeja de entrada del verificador disponibles para esta búsqueda.", "No client was found": "No se encontró ningún cliente", "No data found": "Datos no encontrados", + "NoDocuments": "No hay documentos disponibles.", "No loan locked available": "No hay Crédito bloqueado disponible.", "No notifications": "Sin notificaciones", "No pending loan available for disbursal": "No hay Crédito pendiente disponible para curso.", @@ -3342,7 +3343,8 @@ "Voluntary": "Voluntario", "Write a note": "Escribe una nota", "“Maker-Checker” principle requires every tasks": "El principio \"Maker-Checker\" requiere que dos personas completen cada tarea para reducir la posibilidad de errores y uso indebido. Una persona inicia el proceso y la segunda lo completa.", - "Buy Down Fees": "Tarifas de compra" + "Buy Down Fees": "Tarifas de compra", + "UploadDocumentHint": "Sube un PDF o imagen para generar una vista previa." }, "titles": { "Dashboard": "Tableros", diff --git a/src/assets/translations/es-MX.json b/src/assets/translations/es-MX.json index 4caf09038..6042b8d85 100644 --- a/src/assets/translations/es-MX.json +++ b/src/assets/translations/es-MX.json @@ -3102,6 +3102,7 @@ "No checker inbox data available for this search": "No hay datos de la bandeja de entrada del verificador disponibles para esta búsqueda.", "No client was found": "No se encontró ningún cliente", "No data found": "Datos no encontrados", + "NoDocuments": "No hay documentos disponibles.", "No loan locked available": "No hay Crédito bloqueado disponible.", "No notifications": "Sin notificaciones", "No pending loan available for disbursal": "No hay Crédito pendiente disponible para desembolso.", @@ -3344,7 +3345,8 @@ "Voluntary": "Voluntario", "Write a note": "Escribe una nota", "“Maker-Checker” principle requires every tasks": "El principio \"Maker-Checker\" requiere que dos personas completen cada tarea para reducir la posibilidad de errores y uso indebido. Una persona inicia el proceso y la segunda lo completa.", - "Buy Down Fees": "Tarifas de compra" + "Buy Down Fees": "Tarifas de compra", + "UploadDocumentHint": "Sube un PDF o imagen para generar una vista previa." }, "titles": { "Dashboard": "Tableros", diff --git a/src/assets/translations/fr-FR.json b/src/assets/translations/fr-FR.json index 3c9a3306d..1e0b066d7 100644 --- a/src/assets/translations/fr-FR.json +++ b/src/assets/translations/fr-FR.json @@ -3099,6 +3099,7 @@ "No checker inbox data available for this search": "Aucune donnée de boîte de réception du vérificateur disponible pour cette recherche.", "No client was found": "Aucun client n'a été trouvé", "No data found": "Aucune donnée disponible", + "NoDocuments": "Aucun document disponible.", "No loan locked available": "Aucun prêt bloqué disponible.", "No notifications": "Pas de notifications", "No pending loan available for disbursal": "Aucun prêt en attente disponible pour décaissement.", @@ -3341,7 +3342,8 @@ "Voluntary": "Volontaire", "Write a note": "Écrire une note", "“Maker-Checker” principle requires every tasks": "Le principe « Maker-Checker » exige que chaque tâche soit effectuée par deux personnes afin de réduire les risques d'erreurs et de mauvaise utilisation. Une personne lance le processus et la seconde le termine.", - "Buy Down Fees": "Frais de rachat" + "Buy Down Fees": "Frais de rachat", + "UploadDocumentHint": "Téléchargez un PDF ou une image pour générer un aperçu." }, "titles": { "Dashboard": "Tableau de bord", diff --git a/src/assets/translations/it-IT.json b/src/assets/translations/it-IT.json index 2652aeece..16326cd6f 100644 --- a/src/assets/translations/it-IT.json +++ b/src/assets/translations/it-IT.json @@ -3100,6 +3100,7 @@ "No checker inbox data available for this search": "Nessun dato della casella di posta disponibile per questa ricerca.", "No client was found": "Nessun cliente trovato", "No data found": "Nessun dato trovato", + "NoDocuments": "Nessun documento disponibile.", "No loan locked available": "Nessun prestito bloccato disponibile.", "No pending loan available for disbursal": "Nessun prestito pendente disponibile per l'erogazione.", "No notifications": "Nessuna notifica", @@ -3342,7 +3343,8 @@ "Voluntary": "Volontario", "Write a note": "Scrivi una nota", "“Maker-Checker” principle requires every tasks": "Il principio “Maker-Checker” richiede che ogni attività venga completata da due persone per ridurre la possibilità di errori e usi impropri. Una persona avvia il processo e la seconda lo completa.", - "Buy Down Fees": "Commissioni di acquisto" + "Buy Down Fees": "Commissioni di acquisto", + "UploadDocumentHint": "Carica un PDF o un'immagine per generare un'anteprima." }, "titles": { "Dashboard": "Pannello di controllo", diff --git a/src/assets/translations/ko-KO.json b/src/assets/translations/ko-KO.json index d1d2b33a8..45991f598 100644 --- a/src/assets/translations/ko-KO.json +++ b/src/assets/translations/ko-KO.json @@ -3100,6 +3100,7 @@ "No checker inbox data available for this search": "이 검색에 사용할 수 있는 검사기 받은 편지함 데이터가 없습니다.", "No client was found": "클라이언트를 찾을 수 없습니다.", "No data found": "데이터가 없습니다", + "NoDocuments": "사용 가능한 문서가 없습니다.", "No loan locked available": "잠긴 대출이 없습니다.", "No notifications": "알림 없음", "No pending loan available for disbursal": "지급 가능한 보류 중인 대출이 없습니다.", @@ -3342,7 +3343,8 @@ "Voluntary": "자발적인", "Write a note": "메모 쓰기", "“Maker-Checker” principle requires every tasks": "\"Maker-Checker\" 원칙은 오류와 오용 가능성을 줄이기 위해 모든 작업을 두 사람이 완료하도록 요구합니다. 한 사람이 프로세스를 시작하고 두 번째 사람이 프로세스를 완료합니다.", - "Buy Down Fees": "매입 수수료" + "Buy Down Fees": "매입 수수료", + "UploadDocumentHint": "미리보기를 생성하려면 PDF 또는 이미지를 업로드하세요." }, "titles": { "Dashboard": "계기반", diff --git a/src/assets/translations/lt-LT.json b/src/assets/translations/lt-LT.json index f8562364d..c63d9124e 100644 --- a/src/assets/translations/lt-LT.json +++ b/src/assets/translations/lt-LT.json @@ -3100,6 +3100,7 @@ "No checker inbox data available for this search": "Nėra šios paieškos tikrinimo gautųjų duomenų.", "No client was found": "Klientas nerastas", "No data found": "Duomenų nerasta", + "NoDocuments": "Nėra dokumentų.", "No loan locked available": "Nėra užblokuotos paskolos.", "No notifications": "Nėra pranešimų", "No pending loan available for disbursal": "Nėra laukiamos paskolos, kurią būtų galima išmokėti.", @@ -3342,7 +3343,8 @@ "Voluntary": "Savanoriškas", "Write a note": "Parašykite pastabą", "“Maker-Checker” principle requires every tasks": "„Maker-Checker“ principas reikalauja, kad kiekvieną užduotį atliktų du žmonės, kad būtų sumažinta klaidų ir netinkamo naudojimo galimybė. Vienas asmuo pradeda procesą, o antrasis jį užbaigia.", - "Buy Down Fees": "Išpirkimo mokesčiai" + "Buy Down Fees": "Išpirkimo mokesčiai", + "UploadDocumentHint": "Įkelkite PDF arba vaizdą, kad sugeneruotumėte peržiūrą." }, "titles": { "Dashboard": "Prietaisų skydelis", diff --git a/src/assets/translations/lv-LV.json b/src/assets/translations/lv-LV.json index 8c40ee201..fcadbccc9 100644 --- a/src/assets/translations/lv-LV.json +++ b/src/assets/translations/lv-LV.json @@ -3100,6 +3100,7 @@ "No checker inbox data available for this search": "Šai meklēšanai nav pieejami pārbaudītāja iesūtnes dati.", "No client was found": "Klients netika atrasts", "No data found": "Dati nav atrasti", + "NoDocuments": "Nav pieejamu dokumentu.", "No loan locked available": "Nav pieejams bloķēts aizdevums.", "No notifications": "Nav paziņojumu", "No pending loan available for disbursal": "Nav pieejams neizmaksāts aizdevums.", @@ -3342,7 +3343,8 @@ "Voluntary": "Brīvprātīgi", "Write a note": "Uzrakstiet piezīmi", "“Maker-Checker” principle requires every tasks": "“Maker-Checker” princips paredz, ka katrs uzdevums ir jāveic diviem cilvēkiem, lai samazinātu kļūdu un nepareizas lietošanas iespējamību. Viens cilvēks uzsāk procesu, bet otrs to pabeidz.", - "Buy Down Fees": "Izpirkšanas maksas" + "Buy Down Fees": "Izpirkšanas maksas", + "UploadDocumentHint": "Augšupielādējiet PDF vai attēlu, lai izveidotu priekšskatījumu." }, "titles": { "Dashboard": "Mērinstrumentu panelis", diff --git a/src/assets/translations/ne-NE.json b/src/assets/translations/ne-NE.json index 20288e771..6b7ba2cd0 100644 --- a/src/assets/translations/ne-NE.json +++ b/src/assets/translations/ne-NE.json @@ -3100,6 +3100,7 @@ "No checker inbox data available for this search": "यस खोजको लागि कुनै परीक्षक इनबक्स डाटा उपलब्ध छैन।", "No client was found": "कुनै ग्राहक भेटिएन", "No data found": "कुनै डाटा फेला परेन", + "NoDocuments": "कुनै कागजात उपलब्ध छैन।", "No loan locked available": "कुनै ऋण उपलब्ध छैन।", "No notifications": "कुनै सूचना छैन", "No pending loan available for disbursal": "वितरणको लागि कुनै पेन्डिङ ऋण उपलब्ध छैन।", @@ -3342,7 +3343,8 @@ "Voluntary": "स्वैच्छिक", "Write a note": "नोट लेख्नुहोस्", "“Maker-Checker” principle requires every tasks": "\"मेकर-चेकर\" सिद्धान्तले त्रुटि र दुरुपयोगको सम्भावना कम गर्न प्रत्येक कार्यहरू दुई व्यक्तिद्वारा पूरा गर्न आवश्यक छ। एक व्यक्तिले प्रक्रिया सुरु गर्छ र दोस्रोले यसलाई पूरा गर्दछ।", - "Buy Down Fees": "खरीद शुल्क" + "Buy Down Fees": "खरीद शुल्क", + "UploadDocumentHint": "पूर्वावलोकन उत्पन्न गर्न PDF वा छवि अपलोड गर्नुहोस्।" }, "titles": { "Dashboard": "ड्यासबोर्ड", diff --git a/src/assets/translations/pt-PT.json b/src/assets/translations/pt-PT.json index 7f39e62bc..f6afd354b 100644 --- a/src/assets/translations/pt-PT.json +++ b/src/assets/translations/pt-PT.json @@ -3100,6 +3100,7 @@ "No checker inbox data available for this search": "Não há dados da caixa de entrada do verificador disponíveis para esta pesquisa.", "No client was found": "Nenhum cliente foi encontrado", "No data found": "Nenhum dado encontrado", + "NoDocuments": "Nenhum documento disponível.", "No loan locked available": "Nenhum empréstimo bloqueado disponível.", "No notifications": "Sem notificações", "No pending loan available for disbursal": "Nenhum empréstimo pendente disponível para desembolso.", @@ -3342,7 +3343,8 @@ "Voluntary": "Voluntário", "Write a note": "Escreva uma nota", "“Maker-Checker” principle requires every tasks": "O princípio “Maker-Checker” exige que todas as tarefas sejam concluídas por duas pessoas para reduzir a chance de erros e uso indevido. Uma pessoa inicia o processo e a segunda o conclui.", - "Buy Down Fees": "Taxas de compra" + "Buy Down Fees": "Taxas de compra", + "UploadDocumentHint": "Carregue um PDF ou imagem para gerar uma pré-visualização." }, "titles": { "Dashboard": "Painel", diff --git a/src/assets/translations/sw-SW.json b/src/assets/translations/sw-SW.json index 88efb5c47..0adc95270 100644 --- a/src/assets/translations/sw-SW.json +++ b/src/assets/translations/sw-SW.json @@ -3099,6 +3099,7 @@ "No checker inbox data available for this search": "Hakuna data ya kikasha kikagua inayopatikana kwa utafutaji huu.", "No client was found": "Hakuna mteja aliyepatikana", "No data found": "Hakuna data iliyopatikana", + "NoDocuments": "Hakuna hati zinazopatikana.", "No loan locked available": "Hakuna mkopo uliofungwa unaopatikana.", "No notifications": "Hakuna arifa", "No pending loan available for disbursal": "Hakuna mkopo unaosubiri kulipwa.", @@ -3341,7 +3342,8 @@ "Voluntary": "Kwa hiari", "Write a note": "Andika dokezo", "“Maker-Checker” principle requires every tasks": "Kanuni ya \"Mtengenezaji-Kikagua\" inahitaji kila kazi kukamilishwa na watu wawili ili kupunguza uwezekano wa makosa na matumizi mabaya. Mtu mmoja anaanzisha mchakato na wa pili anakamilisha.", - "Buy Down Fees": "Ada za Ununuzi" + "Buy Down Fees": "Ada za Ununuzi", + "UploadDocumentHint": "Pakia PDF au picha ili kutoa hakikisho." }, "titles": { "Dashboard": "Dashibodi", diff --git a/src/main.scss b/src/main.scss index 105090171..b6b7c9905 100644 --- a/src/main.scss +++ b/src/main.scss @@ -12,11 +12,15 @@ @use 'theme/pink-bluegrey' as *; @use 'theme/deeppurple-amber' as *; @use 'theme/purple-green.scss' as *; +@use 'assets/styles/helper'; +@use 'lightgallery/scss/lightgallery' as *; +@use 'lightgallery/scss/lg-thumbnail' as *; +@use 'lightgallery/scss/lg-zoom' as *; +@use 'lightgallery/scss/lg-fullscreen' as *; // Global Imports /* You can add global styles to this file, and also import other style files */ -@use 'assets/styles/helper'; .scrollbar-styling { scrollbar-width: thin;