feat(shares): add Share Account Closure maker-checker workflow
Clerk submits a closure request only once a share account holds zero shares; manager validates (shares still zero, account active, no active loan/guarantee, KYC/AML standing, maker!=checker) and approves, which executes the real Fineract "close" command. The direct native "Close" action is removed from the account menu — it silently force-redeemed any remaining shares, bypassing the withdrawal approval flow entirely — and replaced with an "Eligible for Closure" banner. When a withdrawal approval brings a share account's holding to zero, a closure request is now auto-submitted (still requires manager approval) so the clerk doesn't have to separately open "New Share Closure Request". Also fixes an unrelated build break introduced by a merged commit: a missing `suppressMonthlyAnnualLink` property declaration, and two files (branch-scope.service.ts, gl-reports-data.ts) that existed on `demo` but were never brought into `dev` alongside the new TDS report components that depend on them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -384,6 +384,14 @@ export class AuthenticationService {
|
||||
return JSON.parse(this.storage.getItem(this.credentialsStorageKey));
|
||||
}
|
||||
|
||||
/** Returns true when the logged-in user is at Head Office rather than a branch login. */
|
||||
isHeadOfficeUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
if (!creds) return false;
|
||||
if (creds.officeId === 1) return true;
|
||||
return (creds.officeName || '').toLowerCase().includes('head office');
|
||||
}
|
||||
|
||||
/** Returns true when the logged-in user has the "Clerk" role. */
|
||||
isClerkUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 { AuthenticationService } from './authentication/authentication.service';
|
||||
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
|
||||
|
||||
/**
|
||||
* Resolves what branch data the logged-in user is allowed to see.
|
||||
*
|
||||
* Head Office sees every branch and can drill into any one of them; a branch login is
|
||||
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
|
||||
* Summary and the NPA report with slightly different branch code lists; this service is
|
||||
* the single place it lives so new reports cannot drift.
|
||||
*
|
||||
* The logged-in office is matched to a branch by name, since session credentials carry
|
||||
* only officeId and officeName. Office names are seeded to match the GL branch names
|
||||
* exactly (see Liquibase 0442).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BranchScopeService {
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
/** Sentinel used when a branch login cannot be matched to a known branch. */
|
||||
static readonly UNRESOLVED = '__unresolved__';
|
||||
|
||||
get isHeadOffice(): boolean {
|
||||
return this.auth.isHeadOfficeUser();
|
||||
}
|
||||
|
||||
/** The office name on the session, for labelling the scope in report headers. */
|
||||
get officeName(): string {
|
||||
return this.auth.getCredentials()?.officeName || 'Branch Office';
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch code the user is confined to, or null for Head Office.
|
||||
*
|
||||
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
|
||||
* branch when the office cannot be matched: showing an unrecognised branch someone
|
||||
* else's figures would be worse than showing them nothing.
|
||||
*/
|
||||
get ownBranchCode(): string | null {
|
||||
if (this.isHeadOffice) {
|
||||
return null;
|
||||
}
|
||||
const name = this.officeName.toLowerCase().trim();
|
||||
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
|
||||
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
|
||||
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
|
||||
return loose?.code ?? BranchScopeService.UNRESOLVED;
|
||||
}
|
||||
|
||||
/** True when the user may choose which branch to view. */
|
||||
get canSelectBranch(): boolean {
|
||||
return this.isHeadOffice;
|
||||
}
|
||||
|
||||
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
|
||||
get selectableBranches(): GlBranch[] {
|
||||
return this.isHeadOffice ? GL_BRANCHES : [];
|
||||
}
|
||||
|
||||
/** Branch code a report should start on. '' means all branches consolidated. */
|
||||
get defaultBranchCode(): string {
|
||||
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a requested branch code into what the user is actually allowed to see.
|
||||
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
|
||||
* selection cannot widen the user's scope.
|
||||
*/
|
||||
enforce(requested: string): string {
|
||||
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
|
||||
}
|
||||
|
||||
/** True when a branch login's office matched no known branch, so it sees no data. */
|
||||
get isUnresolved(): boolean {
|
||||
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
|
||||
}
|
||||
|
||||
/** Human label for the current scope, for report headers. */
|
||||
scopeLabel(selectedCode: string): string {
|
||||
if (!this.isHeadOffice) {
|
||||
return this.officeName;
|
||||
}
|
||||
if (!selectedCode) {
|
||||
return 'All Branches (Consolidated)';
|
||||
}
|
||||
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,17 @@
|
||||
</svg>
|
||||
My Share Requests
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-closure-request')"
|
||||
[routerLink]="['/clerk/share-closure-requests']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="11" width="16" height="9" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</svg>
|
||||
My Share Closures
|
||||
</a>
|
||||
@if (clerkAccountLoaded) {
|
||||
<div class="cashier-nav__item-wrap">
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user