feat: add database export, welcome kit dialog, and DIP/sanctions updates
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<fa-icon icon="database" class="header-icon"></fa-icon>
|
||||
<h2>Database Export</h2>
|
||||
</div>
|
||||
|
||||
<!-- The export carries every client's data, so the consequence is stated before the form, not after. -->
|
||||
<div class="warning-banner">
|
||||
<fa-icon icon="exclamation-triangle" class="warning-banner__icon"></fa-icon>
|
||||
<div>
|
||||
<strong>This export contains the complete database</strong>
|
||||
— every client's personal details, KYC documents, accounts and transactions. A request must be approved by an
|
||||
administrator before the archive can be downloaded, and the approver cannot be the person who raised it. The
|
||||
archive can only be downloaded by the user who raised the request, not by the approver, and an approval is valid
|
||||
for a single download — taking another copy means raising a new request. Every request, approval and download is
|
||||
recorded.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raise a request -->
|
||||
<div class="request-card" [formGroup]="requestForm">
|
||||
<h3 class="request-card__title">Request a Full Export</h3>
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Reason for this export</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="reason"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
placeholder="e.g. Statutory audit for FY 2025-26 requested by the RBI inspection team"
|
||||
required
|
||||
></textarea>
|
||||
<mat-hint>Recorded against the request and written into the archive. Minimum 10 characters.</mat-hint>
|
||||
<mat-error *ngIf="requestForm.get('reason')?.hasError('required')">A reason is required</mat-error>
|
||||
<mat-error *ngIf="requestForm.get('reason')?.hasError('minlength')">
|
||||
Please give at least 10 characters of context
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="request-card__actions">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="requestForm.invalid || submitting"
|
||||
(click)="submitRequest()"
|
||||
>
|
||||
<fa-icon icon="paper-plane" class="chip-icon"></fa-icon>
|
||||
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request history -->
|
||||
<div class="filters-row">
|
||||
<div class="filter-chips">
|
||||
<button mat-stroked-button [class.active-filter]="!activeStatus" (click)="filterByStatus(null)">
|
||||
<fa-icon icon="list" class="chip-icon"></fa-icon> All
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-pending"
|
||||
[class.active-filter]="activeStatus === 'PENDING_APPROVAL'"
|
||||
(click)="filterByStatus('PENDING_APPROVAL')"
|
||||
>
|
||||
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Approval
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-approved"
|
||||
[class.active-filter]="activeStatus === 'APPROVED'"
|
||||
(click)="filterByStatus('APPROVED')"
|
||||
>
|
||||
<fa-icon icon="check-circle" class="chip-icon"></fa-icon> Approved
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-rejected"
|
||||
[class.active-filter]="activeStatus === 'REJECTED'"
|
||||
(click)="filterByStatus('REJECTED')"
|
||||
>
|
||||
<fa-icon icon="times" class="chip-icon"></fa-icon> Rejected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
|
||||
}
|
||||
|
||||
<div class="mat-elevation-z8">
|
||||
<table mat-table [dataSource]="dataSource" matSort>
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Request</th>
|
||||
<td mat-cell *matCellDef="let row">#{{ row.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="result-badge" [ngClass]="statusClass(row)">{{ statusLabel(row) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="reason">
|
||||
<th mat-header-cell *matHeaderCellDef>Reason</th>
|
||||
<td mat-cell *matCellDef="let row" class="reason-cell">
|
||||
<span [matTooltip]="row.reason">{{ row.reason }}</span>
|
||||
@if (row.rejectionReason) {
|
||||
<span class="rejection-note">Rejected: {{ row.rejectionReason }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="requestedBy">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Requested By</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="stacked">
|
||||
<span>{{ row.requestedByUsername || '—' }}</span>
|
||||
<span class="muted">{{ row.requestedOn | date: 'medium' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="approvedBy">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved By</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
@if (row.approvedByUsername) {
|
||||
<div class="stacked">
|
||||
<span>{{ row.approvedByUsername }}</span>
|
||||
<span class="muted">{{ row.approvedOn | date: 'medium' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="generatedOn">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Downloaded</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
@if (row.generatedOn) {
|
||||
<div class="stacked">
|
||||
<span>{{ row.generatedOn | date: 'medium' }}</span>
|
||||
<span class="muted">{{ formatSize(row.fileSizeBytes) }} · {{ row.rowCount || 0 }} rows</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="muted">Not yet</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Action</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="row-actions">
|
||||
@if (isPending(row)) {
|
||||
<!-- Only shown to users holding the approval permission; the backend enforces it regardless. -->
|
||||
<ng-container *mifosxHasPermission="'APPROVE_DATABASEEXPORT'">
|
||||
<button mat-stroked-button color="primary" (click)="approve(row)">
|
||||
<fa-icon icon="check"></fa-icon> Approve
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" (click)="reject(row)">
|
||||
<fa-icon icon="times"></fa-icon> Reject
|
||||
</button>
|
||||
</ng-container>
|
||||
}
|
||||
@if (isDownloadable(row)) {
|
||||
<button mat-flat-button color="primary" [disabled]="downloadingId === row.id" (click)="download(row)">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
{{ downloadingId === row.id ? 'Preparing…' : 'Download' }}
|
||||
</button>
|
||||
}
|
||||
@if (isWithheld(row)) {
|
||||
<span
|
||||
class="muted withheld-note"
|
||||
matTooltip="Approving an export authorises it. Only {{
|
||||
row.requestedByUsername || 'the requester'
|
||||
}} can download the archive."
|
||||
>
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
Requester only
|
||||
</span>
|
||||
}
|
||||
@if (isSpent(row)) {
|
||||
<span
|
||||
class="muted withheld-note"
|
||||
matTooltip="An approval is valid for a single download. Raise a new request to take another copy."
|
||||
>
|
||||
<fa-icon icon="check"></fa-icon>
|
||||
Download used
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
@if (!loading && dataSource.data.length === 0) {
|
||||
<div class="no-data">
|
||||
<fa-icon icon="folder-open" class="no-data-icon"></fa-icon>
|
||||
<p>No export requests yet.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: #1074b9;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The consequence of this screen is stated up front, not buried in a tooltip. */
|
||||
.warning-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
background-color: rgb(180 83 9 / 7%);
|
||||
border: 1px solid rgb(180 83 9 / 30%);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #7c2d12;
|
||||
|
||||
&__icon {
|
||||
color: #b45309;
|
||||
font-size: 18px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.request-card {
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 22px;
|
||||
border-radius: 10px;
|
||||
background-color: #f7f9fc;
|
||||
border: 1px solid #e3e7ee;
|
||||
|
||||
&__title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.chip-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
font-size: 13px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.active-filter {
|
||||
background-color: #1074b9;
|
||||
color: #fff;
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
.chip-pending.active-filter {
|
||||
background-color: #b45309;
|
||||
border-color: #b45309;
|
||||
}
|
||||
|
||||
.chip-approved.active-filter {
|
||||
background-color: #388e3c;
|
||||
border-color: #388e3c;
|
||||
}
|
||||
|
||||
.chip-rejected.active-filter {
|
||||
background-color: #d32f2f;
|
||||
border-color: #d32f2f;
|
||||
}
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&.badge-pending {
|
||||
background-color: rgb(180 83 9 / 12%);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.badge-approved {
|
||||
background-color: rgb(56 142 60 / 14%);
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.badge-rejected {
|
||||
background-color: rgb(211 47 47 / 12%);
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
&.badge-generated {
|
||||
background-color: rgb(16 116 185 / 12%);
|
||||
color: #1074b9;
|
||||
}
|
||||
}
|
||||
|
||||
.reason-cell {
|
||||
max-width: 320px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rejection-note {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
fa-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stands in for the download button on rows the viewer did not raise. */
|
||||
.withheld-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
|
||||
&-icon {
|
||||
font-size: 30px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortModule } from '@angular/material/sort';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { NgClass, DatePipe } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import { DatabaseExportService } from './database-export.service';
|
||||
|
||||
/**
|
||||
* Full database export, gated behind an approval. The requester states why the export is needed; an
|
||||
* administrator with APPROVE_DATABASEEXPORT approves or rejects it, and only then can the archive be
|
||||
* downloaded. The backend refuses self-approval, so the two roles are always two different people.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-database-export',
|
||||
templateUrl: './database-export.component.html',
|
||||
styleUrls: ['./database-export.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatProgressBarModule,
|
||||
MatTooltip,
|
||||
NgClass,
|
||||
DatePipe,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class DatabaseExportComponent implements OnInit {
|
||||
private service = inject(DatabaseExportService);
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
/** Used to keep the download to the requester; the backend enforces the same rule regardless. */
|
||||
private currentUserId: number | null = this.authenticationService.getCredentials()?.userId ?? null;
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'status',
|
||||
'reason',
|
||||
'requestedBy',
|
||||
'approvedBy',
|
||||
'generatedOn',
|
||||
'actions'
|
||||
];
|
||||
|
||||
dataSource = new MatTableDataSource<any>();
|
||||
loading = false;
|
||||
submitting = false;
|
||||
downloadingId: number | null = null;
|
||||
activeStatus: string | null = null;
|
||||
|
||||
requestForm: FormGroup = this.formBuilder.group({
|
||||
reason: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(10),
|
||||
Validators.maxLength(1000)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
|
||||
ngOnInit() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.loading = true;
|
||||
this.service.getRequests(this.activeStatus || undefined).subscribe({
|
||||
next: (rows: any) => this.applyRows(rows || []),
|
||||
error: () => this.applyRows([])
|
||||
});
|
||||
}
|
||||
|
||||
private applyRows(rows: any[]) {
|
||||
this.dataSource = new MatTableDataSource(rows);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
filterByStatus(status: string | null) {
|
||||
this.activeStatus = this.activeStatus === status ? null : status;
|
||||
this.load();
|
||||
}
|
||||
|
||||
submitRequest() {
|
||||
if (this.requestForm.invalid) {
|
||||
this.requestForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.service.requestExport(this.requestForm.value.reason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.requestForm.reset({ reason: '' });
|
||||
this.snackBar.open('Export request submitted for approval', 'Close', { duration: 4000 });
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(this.errorMessage(err, 'Failed to submit the request'), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
approve(row: any) {
|
||||
this.service.approve(row.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(`Request #${row.id} approved`, 'Close', { duration: 4000 });
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Approval failed'), 'Close', { duration: 6000 })
|
||||
});
|
||||
}
|
||||
|
||||
reject(row: any) {
|
||||
const reason = window.prompt(`Reject export request #${row.id}. Reason:`);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
this.snackBar.open('A reason is required to reject a request', 'Close', { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
this.service.reject(row.id, reason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(`Request #${row.id} rejected`, 'Close', { duration: 4000 });
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Rejection failed'), 'Close', { duration: 6000 })
|
||||
});
|
||||
}
|
||||
|
||||
download(row: any) {
|
||||
this.downloadingId = row.id;
|
||||
this.service.download(row.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
this.downloadingId = null;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = row.fileName || `cbsnexora-database-export-${row.id}.zip`;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
// The status moves to GENERATED once the archive leaves the server, so reflect that.
|
||||
this.load();
|
||||
},
|
||||
error: async (err: any) => {
|
||||
this.downloadingId = null;
|
||||
// The request asked for a Blob, so an error body arrives as a Blob too and has to be read back
|
||||
// as text before the platform's message can be pulled out of it.
|
||||
let message = 'Download failed';
|
||||
if (err?.error instanceof Blob) {
|
||||
try {
|
||||
const parsed = JSON.parse(await err.error.text());
|
||||
message = parsed?.defaultUserMessage || parsed?.errors?.[0]?.defaultUserMessage || message;
|
||||
} catch {
|
||||
// Body was not JSON; keep the generic message.
|
||||
}
|
||||
} else {
|
||||
message = this.errorMessage(err, message);
|
||||
}
|
||||
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isPending(row: any): boolean {
|
||||
return row?.status === 'PENDING_APPROVAL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Approving an export authorises it; it does not entitle the approver to a copy. Only the user who raised
|
||||
* the request — and stated the reason it is recorded against — can take the archive, and only once.
|
||||
*/
|
||||
isDownloadable(row: any): boolean {
|
||||
return row?.status === 'APPROVED' && this.isRequester(row);
|
||||
}
|
||||
|
||||
isRequester(row: any): boolean {
|
||||
return this.currentUserId != null && Number(row?.requestedBy) === Number(this.currentUserId);
|
||||
}
|
||||
|
||||
/** Shown in place of the button so an approver sees the archive is withheld by design, not by a fault. */
|
||||
isWithheld(row: any): boolean {
|
||||
return row?.status === 'APPROVED' && !this.isRequester(row);
|
||||
}
|
||||
|
||||
/** The approval has been spent; another copy means another request. */
|
||||
isSpent(row: any): boolean {
|
||||
return row?.status === 'GENERATED';
|
||||
}
|
||||
|
||||
statusLabel(row: any): string {
|
||||
switch (row?.status) {
|
||||
case 'PENDING_APPROVAL':
|
||||
return 'Awaiting Approval';
|
||||
case 'APPROVED':
|
||||
return 'Approved';
|
||||
case 'REJECTED':
|
||||
return 'Rejected';
|
||||
case 'GENERATED':
|
||||
return 'Downloaded';
|
||||
default:
|
||||
return row?.status || '—';
|
||||
}
|
||||
}
|
||||
|
||||
statusClass(row: any): string {
|
||||
switch (row?.status) {
|
||||
case 'APPROVED':
|
||||
return 'badge-approved';
|
||||
case 'REJECTED':
|
||||
return 'badge-rejected';
|
||||
case 'GENERATED':
|
||||
return 'badge-generated';
|
||||
default:
|
||||
return 'badge-pending';
|
||||
}
|
||||
}
|
||||
|
||||
formatSize(bytes: number): string {
|
||||
if (!bytes) {
|
||||
return '—';
|
||||
}
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
private errorMessage(err: any, fallback: string): string {
|
||||
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Approval-gated full export of the tenant database. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DatabaseExportService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getRequests(status?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (status) {
|
||||
params = params.set('status', status);
|
||||
}
|
||||
return this.http.get('/databaseexport', { params });
|
||||
}
|
||||
|
||||
/** @param reason why the export is needed — recorded against the request and inside the archive. */
|
||||
requestExport(reason: string): Observable<any> {
|
||||
return this.http.post('/databaseexport', { reason });
|
||||
}
|
||||
|
||||
approve(requestId: number): Observable<any> {
|
||||
return this.http.post(`/databaseexport/${requestId}/approve`, {});
|
||||
}
|
||||
|
||||
reject(requestId: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post(`/databaseexport/${requestId}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
/** The archive is binary, so it bypasses the JSON interceptors and comes back as a Blob. */
|
||||
download(requestId: number): Observable<Blob> {
|
||||
return this.http.get(`/databaseexport/${requestId}/download`, {
|
||||
responseType: 'blob' as 'json'
|
||||
}) as Observable<Blob>;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Form60Component } from './certificates/form-60/form-60.component';
|
||||
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
|
||||
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
|
||||
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
||||
import { DatabaseExportComponent } from './database-export/database-export.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -56,6 +57,11 @@ const routes: Routes = [
|
||||
component: HypothecationCertificateComponent,
|
||||
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/database-export',
|
||||
component: DatabaseExportComponent,
|
||||
data: { title: 'Database Export', breadcrumb: 'Database Export' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/noc',
|
||||
component: NocCertificateComponent,
|
||||
|
||||
Reference in New Issue
Block a user