WEB-442-feat: implement LightGallery document preview across all document tabs (#2847)

* feat: implement LightGallery document preview across all document tabs

feat(documents): add LightGallery preview with accessibility and error handling

* fix: add accessibility and performance improvements for document preview

fix(styles): replace undefined  variable with -river

Delete button title translated
This commit is contained in:
Shubham Kumar
2025-12-03 05:18:02 -06:00
committed by GitHub
parent 83f3a0b0fe
commit 9432ad3b96
32 changed files with 1031 additions and 323 deletions
+10
View File
@@ -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",
+1
View File
@@ -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",
@@ -1,8 +1,56 @@
<h1 mat-dialog-title>
{{ 'labels.heading.Upload' | translate }} {{ entityType }} {{ 'labels.heading.Documents' | translate }}
<ng-container *ngIf="documentIdentifier; else defaultTitle">
{{ 'labels.heading.Add Client Identifier' | translate }}
</ng-container>
<ng-template #defaultTitle>
{{ 'labels.heading.Upload' | translate }} {{ entityType }} {{ 'labels.heading.Documents' | translate }}
</ng-template>
</h1>
<div class="m-l-10 m-r-10">
<form [formGroup]="uploadDocumentForm" class="layout-column">
<!-- Identity-specific fields -->
<ng-container *ngIf="documentIdentifier">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Document Type' | translate }}</mat-label>
<mat-select formControlName="documentTypeId" required>
<mat-option *ngFor="let docType of allowedDocumentTypes" [value]="docType.id">
{{ docType.name }}
</mat-option>
</mat-select>
<mat-error *ngIf="uploadDocumentForm.controls.documentTypeId?.hasError('required')">
{{ 'labels.inputs.Document Type' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Status' | translate }}</mat-label>
<mat-select formControlName="status" required>
<mat-option *ngFor="let status of statusOptions" [value]="status.value">
{{ status.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="uploadDocumentForm.controls.status?.hasError('required')">
{{ 'labels.inputs.Status' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Document Key' | translate }}</mat-label>
<input formControlName="documentKey" required matInput />
<mat-error *ngIf="uploadDocumentForm.controls.documentKey?.hasError('required')">
{{ 'labels.inputs.Document Key' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<input formControlName="description" matInput />
</mat-form-field>
</ng-container>
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.File Name' | translate }}</mat-label>
<input formControlName="fileName" required matInput />
@@ -27,7 +75,12 @@
[disabled]="!uploadDocumentForm.valid"
[mat-dialog-close]="uploadDocumentForm.value"
>
{{ 'labels.buttons.Upload' | translate }}
<ng-container *ngIf="documentIdentifier; else uploadLabel">
{{ 'labels.buttons.Add' | translate }}
</ng-container>
<ng-template #uploadLabel>
{{ 'labels.buttons.Upload' | translate }}
</ng-template>
</button>
</mat-dialog-actions>
</form>
@@ -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: ['']
});
}
}
/**
@@ -3,7 +3,6 @@
[entityId]="entityId"
[entityDocuments]="entityDocuments"
[callbackUpload]="uploadDocument"
[callbackDownload]="downloadDocument"
[callbackDelete]="deleteDocument"
>
</mifosx-entity-documents-tab>
@@ -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) => {});
}
@@ -37,14 +37,45 @@
<ng-container matColumnDef="documents">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Identity Documents' | translate }}</th>
<td mat-cell *matCellDef="let identity">
<div *ngFor="let document of identity.documents">
{{ document.name }}
<fa-icon
icon="cloud-download-alt"
(click)="download(document.parentEntityId, document.id)"
*mifosxHasPermission="'READ_DOCUMENT'"
></fa-icon>
<div class="document-grid" *ngIf="identity.documents?.length; else noDocs">
<div class="document-card" *ngFor="let document of identity.documents; trackBy: trackByDocumentId">
<div
class="thumb"
[class.clickable]="isPreviewable(document)"
role="button"
tabindex="0"
[attr.aria-label]="('labels.buttons.Preview' | translate) + ': ' + document.name"
(keydown.enter)="isPreviewable(document) && openDocumentPreview(identity, document)"
(keydown.space)="isPreviewable(document) && openDocumentPreview(identity, document)"
(click)="isPreviewable(document) && openDocumentPreview(identity, document)"
>
<ng-container *ngIf="previewThumbnails[document.id]; else placeholder">
<img
[src]="previewThumbnails[document.id]"
[alt]="document.name"
[title]="document.name"
loading="lazy"
/>
</ng-container>
<ng-template #placeholder>
<div class="placeholder">
<fa-icon icon="file"></fa-icon>
<span>{{ document.fileName || document.name }}</span>
</div>
</ng-template>
<div class="preview-overlay" *ngIf="isPreviewable(document)">
<fa-icon icon="eye"></fa-icon>
</div>
</div>
<div class="card-body">
<div class="title">{{ document.name }}</div>
<div class="meta" *ngIf="document.fileName">{{ document.fileName }}</div>
</div>
</div>
</div>
<ng-template #noDocs>
<span class="muted">{{ 'labels.text.NoDocuments' | translate }}</span>
</ng-template>
</td>
</ng-container>
@@ -58,19 +89,12 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let identity; let i = index">
<button
class="identity-action-button"
mat-raised-button
color="primary"
(click)="uploadDocument(i, identity.id)"
*mifosxHasPermission="'CREATE_DOCUMENT'"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
class="identity-action-button"
mat-raised-button
color="warn"
[title]="'labels.buttons.Delete' | translate"
[attr.aria-label]="'labels.buttons.Delete' | translate"
(click)="deleteIdentifier(identity.clientId, identity.id, i)"
*mifosxHasPermission="'DELETE_CLIENTIDENTIFIER'"
>
@@ -83,3 +107,5 @@
<tr mat-row *matRowDef="let row; columns: identitiesColumns"></tr>
</table>
</div>
<div #identityLightbox class="document-lightbox-host"></div>
@@ -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;
}
@@ -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<Element>;
/** LightGallery host */
@ViewChild('identityLightbox', { static: true }) identityLightbox: ElementRef<HTMLElement>;
/** Cached thumbnails for previewable docs */
previewThumbnails: Record<string, string> = {};
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<void> {
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
? `<p class="lg-caption-text">${this.escapeHtml(document.description)}</p>`
: '';
const identityLabel = identity?.documentKey
? `<p class="lg-meta">${this.escapeHtml(identity.documentKey)}</p>`
: '';
return `<div class="lg-caption"><h4>${this.escapeHtml(document.name || 'Document')}</h4>${caption}${identityLabel}</div>`;
}
private escapeHtml(value: string): string {
return value
? value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
: '';
}
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));
});
}
}
@@ -3,7 +3,6 @@
[entityId]="entityId"
[entityDocuments]="entityDocuments"
[callbackUpload]="uploadDocument"
[callbackDownload]="downloadDocument"
[callbackDelete]="deleteDocument"
>
</mifosx-entity-documents-tab>
@@ -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);
}
@@ -3,7 +3,6 @@
[entityId]="entityId"
[entityDocuments]="entityDocuments"
[callbackUpload]="uploadDocument"
[callbackDownload]="downloadDocument"
[callbackDelete]="deleteDocument"
>
</mifosx-entity-documents-tab>
@@ -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);
}
+2
View File
@@ -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,
@@ -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<string, CachedPreview>();
/**
* 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<Blob>
): 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;
}
}
@@ -1,5 +1,5 @@
<div class="tab-container mat-typography">
<div class="layout-row align-start-center">
<div class="layout-row align-start-center header-row">
<h3 class="flex-90">{{ 'labels.heading.Documents' | translate }}</h3>
<div class="flex-10" *mifosxHasPermission="'CREATE_DOCUMENT'">
@@ -9,51 +9,59 @@
</div>
</div>
<table mat-table #documentsTable [dataSource]="dataSource" matSort>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.name' | translate }}</th>
<td mat-cell *matCellDef="let document">{{ document.name }}</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Description' | translate }}</th>
<td mat-cell *matCellDef="let document">{{ document.description }}</td>
</ng-container>
<ng-container matColumnDef="filename">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.File Name' | translate }}</th>
<td mat-cell *matCellDef="let document">{{ document.fileName }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let document; let index">
<button
class="document-action-button"
mat-raised-button
color="primary"
(click)="downloadDocument(document.id)"
<div *ngIf="entityDocuments?.length; else empty">
<div class="document-grid">
<div class="document-card" *ngFor="let document of entityDocuments; trackBy: trackByDocumentId">
<div
class="thumb"
[class.clickable]="isPreviewable(document)"
role="button"
tabindex="0"
[attr.aria-label]="('labels.buttons.Preview' | translate) + ': ' + document.name"
(keydown.enter)="isPreviewable(document) && openPreview(document)"
(keydown.space)="isPreviewable(document) && openPreview(document)"
(click)="isPreviewable(document) && openPreview(document)"
>
<fa-icon icon="cloud-download-alt"></fa-icon>
</button>
<button
class="document-action-button"
mat-raised-button
color="warn"
(click)="deleteDocument(document.id, document.name)"
>
<fa-icon icon="times"></fa-icon>
</button>
</td>
</ng-container>
<ng-container *ngIf="previewThumbnails[document.id]; else placeholder">
<img [src]="previewThumbnails[document.id]" [alt]="document.name" [title]="document.name" loading="lazy" />
</ng-container>
<ng-template #placeholder>
<div class="placeholder">
<fa-icon icon="file"></fa-icon>
<span>{{ document.fileName || document.name }}</span>
</div>
</ng-template>
<div class="preview-overlay" *ngIf="isPreviewable(document)">
<fa-icon icon="eye"></fa-icon>
</div>
</div>
<div class="card-body">
<div class="title">{{ document.name }}</div>
<div class="meta" *ngIf="document.fileName">{{ document.fileName }}</div>
<div class="description" *ngIf="document.description">{{ document.description }}</div>
<div class="actions">
<button
mat-icon-button
color="warn"
(click)="deleteDocument(document.id, document.name)"
title="Delete"
[attr.aria-label]="'labels.buttons.Delete' | translate"
*mifosxHasPermission="'DELETE_DOCUMENT'"
>
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
</div>
</div>
</div>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<mat-paginator
*ngIf="entityDocuments && entityDocuments.length > 10"
[pageSizeOptions]="[10, 25, 50]"
showFirstLastButtons
></mat-paginator>
<ng-template #empty>
<div class="empty-state">
<p>{{ 'labels.text.NoDocuments' | translate }}</p>
<p class="muted">{{ 'labels.text.UploadDocumentHint' | translate }}</p>
</div>
</ng-template>
</div>
<div #lightboxRoot class="document-lightbox-host"></div>
@@ -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;
}
@@ -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<Element>;
export class EntityDocumentsTabComponent implements OnInit, OnDestroy {
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
@Input() entityId: string;
@Input() entityType: string;
@Input() entityDocuments: any;
@Input() callbackUpload: (documentData: FormData) => Observable<any>;
@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<string, string> = {};
private lightboxInstance: LightGallery | null = null;
private readonly lightboxPlugins = [
lgZoom,
lgThumbnail,
lgFullscreen
];
/** Data source for loan documents table. */
dataSource: MatTableDataSource<any>;
/** 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<void> {
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<Blob> {
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
? `<p class="lg-caption-text">${this.escapeHtml(document.description)}</p>`
: '';
const filename = document.fileName ? `<p class="lg-meta">${this.escapeHtml(document.fileName)}</p>` : '';
return `<div class="lg-caption"><h4>${this.escapeHtml(document.name || 'Document')}</h4>${description}${filename}</div>`;
}
private escapeHtml(value: string): string {
return value
? value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
: '';
}
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));
}
}
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "계기반",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "ड्यासबोर्ड",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+5 -1
View File
@@ -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;