Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves

Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.

Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.

KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).

State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.

Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.

Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-20 12:40:58 +05:30
co-authored by Claude Opus 4.7
parent 325228c514
commit 83ea57ccf0
84 changed files with 10281 additions and 1194 deletions
@@ -6,302 +6,273 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="table-header">
<div class="table-title-group">
<h2 class="table-title">
<i class="fas fa-building title-icon"></i>
{{ 'labels.heading.Manage Offices' | translate }}
</h2>
</div>
<div class="table-actions">
<div class="table-search">
<mat-form-field appearance="outline">
<i matPrefix class="fas fa-search"></i>
<mat-label>{{ 'Filter' | translate }}</mat-label>
<input #filter matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field>
</div>
<mat-button-toggle-group #buttonTreeView [formControl]="viewGroup">
<mat-button-toggle value="listView">
<fa-icon icon="list"></fa-icon>
</mat-button-toggle>
<mat-button-toggle value="treeView">
<fa-icon icon="sitemap"></fa-icon>
</mat-button-toggle>
</mat-button-toggle-group>
<div class="table-buttons">
<div #buttonImportOffices>
<button
mat-stroked-button
class="btn-import"
*mifosxHasPermission="'CREATE_OFFICE'"
[routerLink]="['/organization', 'bulk-import', 'Offices']"
>
<fa-icon icon="upload" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Import Offices' | translate }}
</button>
</div>
<div #buttonCreateOffice>
<button
mat-flat-button
color="primary"
class="btn-create"
[routerLink]="['create']"
*mifosxHasPermission="'CREATE_OFFICE'"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Create Office' | translate }}
</button>
</div>
<div class="off-container">
<!-- Hero header -->
<div class="off-header">
<div class="off-header__left">
<fa-icon icon="building" class="off-header__icon"></fa-icon>
<div>
<h2 class="off-header__title">Manage Offices</h2>
<div class="off-header__sub">Define and organise your branch hierarchy</div>
</div>
</div>
</div>
<!-- List View -->
<div [hidden]="viewGroup.value !== 'listView'">
<div #tableOffices class="table-container">
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ ' Office Name ' | translate }}</th>
<td mat-cell *matCellDef="let offices">{{ offices.name }}</td>
</ng-container>
<ng-container matColumnDef="externalId">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ ' External ID ' | translate }}</th>
<td mat-cell *matCellDef="let offices">{{ offices.externalId }}</td>
</ng-container>
<ng-container matColumnDef="parentName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ ' Parent Office ' | translate }}</th>
<td mat-cell *matCellDef="let offices">{{ offices.parentName }}</td>
</ng-container>
<ng-container matColumnDef="openingDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ ' Opened On ' | translate }}</th>
<td mat-cell *matCellDef="let offices">{{ offices.openingDate | dateFormat }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="table-row"
[routerLink]="[row.id, 'general']"
></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
<!-- Tree View -->
<div [hidden]="viewGroup.value !== 'treeView'">
<div class="m-b-20 layout-row align-start gap-20px">
<button mat-raised-button (click)="toggleExpandCollapse()">
{{ (isTreeExpanded ? 'labels.buttons.Collapse All' : 'labels.buttons.Expand All') | translate }}
<div class="off-header__right">
<button class="off-btn ghost" (click)="importOffices()" *mifosxHasPermission="'CREATE_OFFICE'">
<fa-icon icon="upload"></fa-icon>
Import Offices
</button>
<button class="off-btn primary" (click)="createOffice()" *mifosxHasPermission="'CREATE_OFFICE'">
<fa-icon icon="plus"></fa-icon>
Create Office
</button>
</div>
</div>
<div class="layout-row gap-4percent responsive-column">
<div class="flex-fill">
<mat-card>
<mat-card-content>
<mat-tree [dataSource]="nestedTreeDataSource" [treeControl]="nestedTreeControl" class="office-tree">
<mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle>
<li class="mat-tree-node">
<button mat-icon-button disabled></button>
<span (click)="viewOfficeNode(node)" class="m-r-10">
{{ node.name }}
</span>
</li>
</mat-tree-node>
<mat-nested-tree-node *matTreeNodeDef="let node; when: hasNestedChild">
<li>
<div class="mat-tree-node">
<button mat-icon-button matTreeNodeToggle [attr.aria-label]="'toggle ' + node.name">
<fa-icon
class="mat-icon-rtl-mirror"
icon="{{ nestedTreeControl.isExpanded(node) ? 'chevron-down' : 'chevron-right' }}"
></fa-icon>
</button>
<span (click)="viewOfficeNode(node)" class="m-r-10">
{{ node.name }}
</span>
</div>
<ul [class.office-tree-invisible]="!nestedTreeControl.isExpanded(node)">
<ng-container matTreeNodeOutlet></ng-container>
</ul>
</li>
</mat-nested-tree-node>
</mat-tree>
</mat-card-content>
</mat-card>
<!-- KPI strip -->
<div class="off-stats">
<div class="off-stat total">
<fa-icon icon="building" class="off-stat__ic"></fa-icon>
<div class="off-stat__body">
<div class="off-stat__val">{{ totalOffices }}</div>
<div class="off-stat__lbl">Total Offices</div>
</div>
</div>
<div class="off-stat head">
<fa-icon icon="university" class="off-stat__ic"></fa-icon>
<div class="off-stat__body">
<div class="off-stat__val">{{ headOfficeCount }}</div>
<div class="off-stat__lbl">Head Office</div>
</div>
</div>
<div class="off-stat regional">
<fa-icon icon="sitemap" class="off-stat__ic"></fa-icon>
<div class="off-stat__body">
<div class="off-stat__val">{{ regionalCount }}</div>
<div class="off-stat__lbl">Regional / Zonal</div>
</div>
</div>
<div class="off-stat branch">
<fa-icon icon="map-marker-alt" class="off-stat__ic"></fa-icon>
<div class="off-stat__body">
<div class="off-stat__val">{{ branchCount }}</div>
<div class="off-stat__lbl">Branches</div>
</div>
@if (office) {
<div class="flex-48">
<mat-card>
<mat-card-content>
<div class="layout-row">
<mat-card-title>
{{ office.name }}
</mat-card-title>
<div class="flex align-end">
<button mat-icon-button (click)="closeOffice()">
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
<mat-tab-group>
<mat-tab label="General">
<div class="layout-row-wrap">
@if (office.parentId) {
<div class="flex-50 mat-body-strong">
{{ 'Parent Office' | translate }}
</div>
}
@if (office.parentId) {
<div class="flex-50">
{{ office.parentName }}
</div>
}
<div class="flex-50 mat-body-strong">
{{ ' Opened On' | translate }}
</div>
<div class="flex-50">
{{ office.openingDate | date }}
</div>
@if (office.externalId) {
<div class="flex-50 mat-body-strong">
{{ 'External Id' | translate }}
</div>
}
@if (office.externalId) {
<div class="flex-50">
<mifosx-external-identifier externalId="{{ office.externalId }}"></mifosx-external-identifier>
</div>
}
</div>
</mat-tab>
@for (dataTable of dataTablesData; track dataTable) {
<mat-tab label="{{ dataTable.registeredTableName }}"></mat-tab>
}
</mat-tab-group>
</mat-card-content>
</mat-card>
</div>
}
</div>
</div>
<!-- Toolbar (search + view toggle) -->
<div class="off-toolbar">
<div class="off-toolbar__search">
<fa-icon icon="search" class="off-toolbar__search-ic"></fa-icon>
<input type="text" placeholder="Search by name, external ID, parent office…" [(ngModel)]="search" />
@if (search) {
<button class="off-toolbar__clear" (click)="clearSearch()" matTooltip="Clear">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<div class="off-view-toggle">
<button
class="off-view-toggle__btn"
[class.active]="viewMode === 'list'"
(click)="viewMode = 'list'"
matTooltip="List View"
>
<fa-icon icon="list"></fa-icon>
<span>List</span>
</button>
<button
class="off-view-toggle__btn"
[class.active]="viewMode === 'tree'"
(click)="viewMode = 'tree'"
matTooltip="Hierarchy / Tree View"
>
<fa-icon icon="sitemap"></fa-icon>
<span>Tree</span>
</button>
</div>
</div>
<!-- ───── List View ───── -->
@if (viewMode === 'list') {
<div class="off-card">
<div class="off-card__head">
<div class="off-card__title">
<fa-icon icon="list" class="off-card__title-ic"></fa-icon>
Offices
<span class="off-card__count">{{ filteredRows.length }} of {{ rows.length }}</span>
</div>
</div>
<div class="off-table-wrap">
<table class="off-table">
<thead>
<tr>
<th class="num">#</th>
<th>Office Name</th>
<th>External ID</th>
<th>Parent Office</th>
<th>Opened On</th>
<th>Sub-Offices</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track trackRow($index, row); let i = $index) {
<tr>
<td class="num">{{ i + 1 }}</td>
<td>
<div class="off-name">
@if (!row.parentId) {
<span class="off-pill head">HO</span>
} @else if (row.childCount > 0) {
<span class="off-pill regional">Reg</span>
} @else {
<span class="off-pill branch">Br</span>
}
<span>{{ row.name }}</span>
</div>
</td>
<td class="mono">{{ row.externalId || '—' }}</td>
<td>{{ row.parentName || '—' }}</td>
<td>{{ row.openingDate || '—' }}</td>
<td class="num">
@if (row.childCount > 0) {
<span class="off-chip">{{ row.childCount }}</span>
} @else {
—
}
</td>
<td class="actions">
<button class="off-act view" (click)="viewOffice(row)" matTooltip="View office">
<fa-icon icon="eye"></fa-icon>
</button>
<button
class="off-act edit"
(click)="editOffice(row)"
matTooltip="Edit office"
*mifosxHasPermission="'UPDATE_OFFICE'"
>
<fa-icon icon="pencil-alt"></fa-icon>
</button>
<button class="off-act more" [matMenuTriggerFor]="rowMenu" matTooltip="More actions">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
<mat-menu #rowMenu="matMenu" xPosition="before" class="off-menu">
<button mat-menu-item (click)="viewOffice(row)">
<fa-icon icon="eye" class="off-menu__ic"></fa-icon>
View Details
</button>
<button mat-menu-item (click)="editOffice(row)" *mifosxHasPermission="'UPDATE_OFFICE'">
<fa-icon icon="pencil-alt" class="off-menu__ic"></fa-icon>
Edit Office
</button>
<button
mat-menu-item
(click)="deleteOffice(row)"
[disabled]="row.childCount > 0"
*mifosxHasPermission="'DELETE_OFFICE'"
>
<fa-icon icon="trash" class="off-menu__ic warn"></fa-icon>
Delete Office
</button>
</mat-menu>
</td>
</tr>
}
@if (filteredRows.length === 0) {
<tr>
<td colspan="7" class="off-empty">No offices match the current filter.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ───── Tree View ───── -->
@if (viewMode === 'tree') {
<div class="off-card">
<div class="off-card__head">
<div class="off-card__title">
<fa-icon icon="sitemap" class="off-card__title-ic"></fa-icon>
Hierarchy
</div>
<button class="off-btn ghost small" (click)="toggleExpandCollapse()">
<fa-icon [icon]="isTreeExpanded ? 'compress-alt' : 'expand-alt'"></fa-icon>
{{ isTreeExpanded ? 'Collapse All' : 'Expand All' }}
</button>
</div>
<div class="off-tree-wrap">
<mat-tree [dataSource]="nestedTreeDataSource" [treeControl]="nestedTreeControl" class="off-tree">
<mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle>
<div class="off-tree-row leaf">
<span class="off-tree-row__indent"></span>
<fa-icon icon="map-marker-alt" class="off-tree-row__ic branch"></fa-icon>
<span class="off-tree-row__name">{{ node.name }}</span>
<ng-container *ngTemplateOutlet="rowActions; context: { node }"></ng-container>
</div>
</mat-tree-node>
<mat-nested-tree-node *matTreeNodeDef="let node; when: hasNestedChild">
<div class="off-tree-row">
<button class="off-tree-row__toggle" matTreeNodeToggle [attr.aria-label]="'toggle ' + node.name">
<fa-icon [icon]="nestedTreeControl.isExpanded(node) ? 'chevron-down' : 'chevron-right'"></fa-icon>
</button>
<fa-icon
[icon]="node.parentId ? 'sitemap' : 'university'"
class="off-tree-row__ic"
[class.head]="!node.parentId"
[class.regional]="!!node.parentId"
></fa-icon>
<span class="off-tree-row__name">{{ node.name }}</span>
@if (rowForNode(node); as r) {
<span class="off-chip tree">{{ r.childCount }} sub</span>
}
<ng-container *ngTemplateOutlet="rowActions; context: { node }"></ng-container>
</div>
<ul [class.off-tree__hidden]="!nestedTreeControl.isExpanded(node)">
<ng-container matTreeNodeOutlet></ng-container>
</ul>
</mat-nested-tree-node>
</mat-tree>
</div>
</div>
}
</div>
<ng-template #templateButtonTreeView let-popover="popover">
<h2>{{ 'labels.heading.Tree View' | translate }}</h2>
<p class="mw300">{{ 'labels.text.Launch an alternative view for office hierarchy' | translate }}</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStep()">
{{ 'labels.buttons.Back' | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateButtonCreateOffice, buttonCreateOffice, 'bottom', true)"
>
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
<ng-template #templateButtonCreateOffice let-popover="popover">
<h2>{{ 'labels.heading.Create Office' | translate }}</h2>
<p class="mw300">{{ 'labels.text.Create new offices' | translate }}</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateButtonTreeView, buttonTreeView, 'bottom', true)"
>
{{ 'labels.buttons.Back' | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateButtonImportOffices, buttonImportOffices, 'bottom', true)"
>
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
<ng-template #templateButtonImportOffices let-popover="popover">
<h2>{{ 'labels.heading.Import Offices' | translate }}</h2>
<p class="mw300">{{ 'labels.text.Launch the Bulk import/Offices' | translate }}</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateButtonCreateOffice, buttonCreateOffice, 'bottom', true)"
>
{{ 'labels.buttons.Back' | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateFilter, filter, 'bottom', true)"
>
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
<ng-template #templateFilter let-popover="popover">
<h4>{{ 'labels.heading.Search bar to filter offices.' | translate }}</h4>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateButtonImportOffices, buttonImportOffices, 'bottom', true)"
>
{{ 'labels.buttons.Back' | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateTableOffices, tableOffices, 'top', true)"
>
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
<ng-template #templateTableOffices let-popover="popover">
<h4>{{ 'labels.heading.List of offices in the organization.' | translate }}</h4>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateFilter, filter, 'bottom', true)"
>
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStep()">
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
<!-- Per-node action cluster — reused across leaf and nested nodes -->
<ng-template #rowActions let-node="node">
@if (rowForNode(node); as r) {
<div class="off-tree-row__acts">
<button class="off-act view" (click)="viewOffice(r)" matTooltip="View">
<fa-icon icon="eye"></fa-icon>
</button>
<button class="off-act edit" (click)="editOffice(r)" matTooltip="Edit" *mifosxHasPermission="'UPDATE_OFFICE'">
<fa-icon icon="pencil-alt"></fa-icon>
</button>
<button class="off-act more" [matMenuTriggerFor]="nodeMenu" matTooltip="More">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
<mat-menu #nodeMenu="matMenu" xPosition="before" class="off-menu">
<button mat-menu-item (click)="viewOffice(r)">
<fa-icon icon="eye" class="off-menu__ic"></fa-icon>
View Details
</button>
<button mat-menu-item (click)="editOffice(r)" *mifosxHasPermission="'UPDATE_OFFICE'">
<fa-icon icon="pencil-alt" class="off-menu__ic"></fa-icon>
Edit Office
</button>
<button
mat-menu-item
(click)="deleteOffice(r)"
[disabled]="r.childCount > 0"
*mifosxHasPermission="'DELETE_OFFICE'"
>
<fa-icon icon="trash" class="off-menu__ic warn"></fa-icon>
Delete Office
</button>
</mat-menu>
</div>
}
</ng-template>
@@ -6,38 +6,644 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../../shared/styles/list-layout' as *;
@use 'sass:color';
table {
width: 100%;
$navy: #0d1b2a;
$blue: #1e88e5;
$blue-d: #1e40af;
$green: #10b981;
$amber: #f59e0b;
$red: #ef4444;
$purple: #7c3aed;
.select-row:hover {
cursor: pointer;
.off-container {
padding: 20px 24px;
max-width: 1440px;
margin: 0 auto;
background: #f0f2f5;
min-height: 100vh;
}
// ─── Header ───
.off-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, $blue 0%, $purple 100%);
color: #fff;
font-size: 18px;
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
}
&__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: $navy;
line-height: 1.1;
}
&__sub {
margin-top: 2px;
font-size: 12.5px;
color: #64748b;
}
&__right {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
}
.content {
div {
margin: 1rem 0;
word-wrap: break-word;
}
}
.off-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 9px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
border: 1px solid transparent;
.office-tree {
ul,
li {
margin-top: 0;
margin-bottom: 0;
list-style-type: none;
fa-icon {
font-size: 12px;
}
li {
span {
cursor: pointer;
&.ghost {
background: #fff;
border-color: #d0d5dd;
color: #475569;
&:hover {
background: #f8fafc;
border-color: #94a3b8;
color: $navy;
}
}
.office-tree-invisible {
&.primary {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
border-color: $blue-d;
color: #fff;
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
&:hover {
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
transform: translateY(-1px);
}
}
&.small {
padding: 6px 12px;
font-size: 12px;
}
}
// ─── KPI strip ───
.off-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.off-stat {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 16px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
&__ic {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
font-size: 15px;
color: #fff;
flex-shrink: 0;
}
&__val {
font-size: 22px;
font-weight: 800;
color: $navy;
line-height: 1;
}
&__lbl {
margin-top: 4px;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
font-weight: 600;
}
&.total .off-stat__ic {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
}
&.head .off-stat__ic {
background: linear-gradient(135deg, $purple 0%, #4338ca 100%);
}
&.regional .off-stat__ic {
background: linear-gradient(135deg, $amber 0%, #c2410c 100%);
}
&.branch .off-stat__ic {
background: linear-gradient(135deg, $green 0%, #047857 100%);
}
}
// ─── Toolbar ───
.off-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
&__search {
position: relative;
flex: 1;
min-width: 240px;
max-width: 480px;
input {
width: 100%;
padding: 9px 36px;
border: 1px solid #d0d5dd;
border-radius: 9px;
font-size: 13px;
background: #fff;
color: $navy;
transition: all 0.15s;
&:focus {
outline: none;
border-color: $blue;
box-shadow: 0 0 0 3px rgb(30 136 229 / 12%);
}
}
&-ic {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 12px;
pointer-events: none;
}
}
&__clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid #e2e8f0;
background: #fff;
color: #94a3b8;
font-size: 10px;
cursor: pointer;
&:hover {
background: #fef2f2;
color: $red;
border-color: #fecaca;
}
}
}
.off-view-toggle {
display: inline-flex;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 9px;
overflow: hidden;
padding: 3px;
gap: 2px;
&__btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
background: transparent;
color: #64748b;
font-size: 12.5px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
fa-icon {
font-size: 11px;
}
&:hover:not(.active) {
background: #f1f5f9;
color: $navy;
}
&.active {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
color: #fff;
box-shadow: 0 1px 3px rgb(30 64 175 / 25%);
}
}
}
// ─── Card wrapper ───
.off-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
overflow: hidden;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
}
&__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: $navy;
&-ic {
color: $blue;
}
}
&__count {
margin-left: 8px;
padding: 2px 10px;
background: #eff6ff;
color: $blue-d;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
}
// ─── List view table ───
.off-table-wrap {
overflow: auto;
max-height: 600px;
}
.off-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead th {
position: sticky;
top: 0;
z-index: 1;
background: #f1f5f9;
color: #475569;
text-align: left;
padding: 11px 14px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #e2e8f0;
&.num {
width: 48px;
text-align: center;
}
&.actions {
width: 150px;
text-align: center;
}
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
color: $navy;
&.num {
text-align: center;
color: #94a3b8;
font-weight: 600;
}
&.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
color: #475569;
}
&.actions {
text-align: center;
white-space: nowrap;
}
}
tbody tr {
transition: background 0.12s;
&:hover {
background: #f8fafc;
}
}
}
.off-name {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.off-pill {
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
&.head {
background: #ede9fe;
color: $purple;
}
&.regional {
background: #fef3c7;
color: #b45309;
}
&.branch {
background: #ecfdf5;
color: $green;
}
}
.off-chip {
display: inline-block;
padding: 2px 9px;
background: #eff6ff;
color: $blue-d;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&.tree {
margin-left: 6px;
font-size: 10px;
}
}
.off-act {
width: 30px;
height: 30px;
margin: 0 2px;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 7px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 12px;
transition: all 0.12s;
&:hover:not(:disabled) {
transform: translateY(-1px);
}
&.view:hover:not(:disabled) {
background: #f0f9ff;
border-color: #bae6fd;
color: $blue;
}
&.edit:hover:not(:disabled) {
background: #eff6ff;
border-color: #bfdbfe;
color: $blue-d;
}
&.more:hover:not(:disabled) {
background: #f5f3ff;
border-color: #ddd6fe;
color: $purple;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.off-menu__ic {
width: 18px;
margin-right: 10px;
color: #64748b;
font-size: 13px;
text-align: center;
&.warn {
color: $red;
}
}
.off-empty {
text-align: center;
padding: 32px 16px;
color: #94a3b8;
font-size: 13px;
}
// ─── Tree view ───
.off-tree-wrap {
padding: 14px 18px 18px;
}
.off-tree {
ul {
list-style: none;
padding-left: 26px;
margin: 0;
}
&__hidden {
display: none;
}
}
.off-tree-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin: 4px 0;
border-radius: 8px;
background: #fff;
border: 1px solid #e2e8f0;
transition: all 0.12s;
&:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
&__toggle {
width: 26px;
height: 26px;
border: none;
background: transparent;
color: #64748b;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
transition: all 0.12s;
&:hover {
background: #f1f5f9;
color: $navy;
}
}
&__indent {
width: 26px;
display: inline-block;
}
&__ic {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 11px;
color: #fff;
flex-shrink: 0;
&.head {
background: $purple;
}
&.regional {
background: $amber;
}
&.branch {
background: $green;
}
}
&__name {
font-weight: 600;
color: $navy;
flex: 1;
font-size: 13px;
}
&__acts {
display: inline-flex;
align-items: center;
gap: 0;
margin-left: auto;
flex-shrink: 0;
}
&.leaf {
background: #fafbff;
}
}
// ─── Responsive ───
@media (width <= 900px) {
.off-stats {
grid-template-columns: repeat(2, 1fr);
}
.off-header {
flex-direction: column;
align-items: flex-start;
}
.off-toolbar {
flex-direction: column;
align-items: stretch;
&__search {
max-width: none;
}
}
.off-view-toggle {
align-self: flex-end;
}
}
@media (width <= 600px) {
.off-stats {
grid-template-columns: 1fr;
}
.off-table thead th {
font-size: 9.5px;
padding: 8px 10px;
}
.off-table tbody td {
padding: 8px 10px;
}
}
+245 -208
View File
@@ -6,33 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** rxjs Imports */
import { of } from 'rxjs';
/** Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { UntypedFormControl, ReactiveFormsModule } from '@angular/forms';
import { OfficeNode } from './office-node.model';
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule, UntypedFormControl, ReactiveFormsModule } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { NestedTreeControl } from '@angular/cdk/tree';
import {
MatTreeNestedDataSource,
@@ -43,20 +22,35 @@ import {
MatNestedTreeNode,
MatTreeNodeOutlet
} from '@angular/material/tree';
import { OfficeTreeService } from './office-tree-service.service';
import { TreeControlService } from 'app/shared/common-logic/tree-control.service';
import { MatButtonToggleGroup, MatButtonToggle } from '@angular/material/button-toggle';
import { of } from 'rxjs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCard, MatCardContent, MatCardTitle } from '@angular/material/card';
import { DatePipe } from '@angular/common';
import { MatTabGroup, MatTab } from '@angular/material/tabs';
import { ExternalIdentifierComponent } from '../../shared/external-identifier/external-identifier.component';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OfficeNode } from './office-node.model';
import { OfficeTreeService } from './office-tree-service.service';
import { TreeControlService } from 'app/shared/common-logic/tree-control.service';
import { OrganizationService } from '../organization.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
interface OfficeRow {
id: string;
name: string;
externalId: string;
parentName: string;
parentId: string;
openingDate: string;
hierarchy: string;
childCount: number;
}
/**
* Offices component.
* Manage Offices — Nexora redesign.
*
* Replaces the legacy mat-table / mat-tree layout with a hero header, KPI
* strip, and per-row / per-node edit + delete actions. Delete is guarded by a
* staff-count check so a branch with employees can't be removed.
*/
@Component({
selector: 'mifosx-offices',
@@ -64,218 +58,261 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./offices.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatButtonToggleGroup,
MatButtonToggle,
FormsModule,
ReactiveFormsModule,
FaIconComponent,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltip,
MatMenuModule,
MatTree,
MatTreeNodeDef,
MatTreeNode,
MatTreeNodeToggle,
MatIconButton,
MatNestedTreeNode,
MatTreeNodeOutlet,
MatCardTitle,
MatTabGroup,
MatTab,
ExternalIdentifierComponent,
DatePipe,
DateFormatPipe
MatTreeNodeOutlet
]
})
export class OfficesComponent implements OnInit, AfterViewInit {
export class OfficesComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private organizationService = inject(OrganizationService);
private officeTreeService = inject(OfficeTreeService);
private treeControlService = inject(TreeControlService);
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
/** Button toggle group form control for type of view. (list/tree) */
/** 'list' | 'tree' view toggle. */
viewMode: 'list' | 'tree' = 'list';
/** Search box value (filters both list + tree). */
search = '';
/** Form control kept around for tree-control compatibility. */
viewGroup = new UntypedFormControl('listView');
/** Offices data. */
officesData: any;
/** Columns to be displayed in offices table. */
displayedColumns: string[] = [
'name',
'externalId',
'parentName',
'openingDate'
];
/** Data source for offices table. */
dataSource: MatTableDataSource<any>;
/** Nested tree control for offices tree. */
/** Raw offices payload from the resolver. */
officesData: any[] = [];
/** Flattened rows used in the list view. */
rows: OfficeRow[] = [];
/** Tree state. */
nestedTreeControl: NestedTreeControl<OfficeNode>;
/** Nested tree data source for offices tree. */
nestedTreeDataSource: MatTreeNestedDataSource<OfficeNode>;
/** Selected Office. */
office: OfficeNode;
/** Office data tables. */
dataTablesData: any;
/** Flag to check if tree is expanded or collapsed. */
isTreeExpanded = true;
/** Paginator for offices table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for offices table. */
@ViewChild(MatSort, { static: true }) sort: MatSort;
/** Per-office staff count (lazy-fetched on first delete attempt). */
private staffCountCache = new Map<string, number>();
/* Reference of tree view button */
@ViewChild('buttonTreeView') buttonTreeView: ElementRef<any>;
/* Template for popover on tree view button */
@ViewChild('templateButtonTreeView') templateButtonTreeView: TemplateRef<any>;
/* Reference of offices table */
@ViewChild('tableOffices') tableOffices: ElementRef<any>;
/* Template for popover on offices table */
@ViewChild('templateTableOffices') templateTableOffices: TemplateRef<any>;
/**
* Retrieves the offices data from `resolve`.
* @param {ActivatedRoute} route Activated Route.
* @param {Router} router Router.
* @param {OfficeTreeService} officeTreeService office tree service.
* @param {ConfigurationWizardService} configurationWizardService ConfigurationWizard Service.
* @param {PopoverService} popoverService PopoverService.
*/
constructor() {
const officeTreeService = this.officeTreeService;
this.route.data.subscribe((data: { offices: any; officeDataTables: any }) => {
this.officesData = data.offices;
officeTreeService.initialize(this.officesData);
this.dataTablesData = data.officeDataTables;
this.route.data.subscribe((data: { offices: any }) => {
this.officesData = data.offices || [];
this.officeTreeService.initialize(this.officesData);
this.buildRows();
});
this.nestedTreeControl = new NestedTreeControl<OfficeNode>(this.getChildren);
this.nestedTreeControl = new NestedTreeControl<OfficeNode>((node: OfficeNode) => of(node.children));
this.nestedTreeDataSource = new MatTreeNestedDataSource<OfficeNode>();
}
/**
* Filters data in offices table based on passed value.
* @param {string} filterValue Value to filter data.
*/
applyFilter(filterValue: string) {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/**
* Sets the offices table.
*/
ngOnInit() {
this.setOffices();
this.officeTreeService.treeDataChange.subscribe((officeTreeData: OfficeNode[]) => {
this.nestedTreeDataSource.data = officeTreeData;
this.nestedTreeControl.expand(this.nestedTreeDataSource.data[0]);
this.nestedTreeControl.dataNodes = officeTreeData;
ngOnInit(): void {
this.officeTreeService.treeDataChange.subscribe((tree: OfficeNode[]) => {
this.nestedTreeDataSource.data = tree;
if (tree[0]) this.nestedTreeControl.expand(tree[0]);
this.nestedTreeControl.dataNodes = tree;
});
}
/**
* Initializes the data source, paginator and sorter for offices table.
*/
setOffices() {
this.dataSource = new MatTableDataSource(this.officesData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
/**
* View selected office.
* @param {OfficeNode} office Office to be viewed.
*/
viewOfficeNode(office: OfficeNode) {
if (office.id) {
this.office = office;
} else {
delete this.office;
/** Build the flat row list with cached child counts (used to gate deletes). */
private buildRows(): void {
const childByParent = new Map<string, number>();
for (const o of this.officesData) {
if (o.parentId != null) {
const k = String(o.parentId);
childByParent.set(k, (childByParent.get(k) || 0) + 1);
}
}
this.rows = this.officesData.map((o) => ({
id: String(o.id),
name: o.name || '',
externalId: o.externalId || '',
parentName: o.parentName || '',
parentId: o.parentId != null ? String(o.parentId) : '',
openingDate: this.formatDate(o.openingDate),
hierarchy: o.hierarchy || '',
childCount: childByParent.get(String(o.id)) || 0
}));
}
/**
* Closes the current view for office
*/
closeOffice() {
delete this.office;
// ───── KPI helpers ─────
get totalOffices(): number {
return this.rows.length;
}
/**
* Checks if selected node in tree has children.
*/
hasNestedChild = (_: number, node: OfficeNode) => node.children.length;
/**
* Gets the children of selected node in tree.
*/
private getChildren = (node: OfficeNode) => of(node.children);
/**
* Popover function
* @param template TemplateRef<any>.
* @param target HTMLElement | ElementRef<any>.
* @param position String.
* @param backdrop Boolean.
*/
showPopover(
template: TemplateRef<any>,
target: HTMLElement | ElementRef<any>,
position: string,
backdrop: boolean
): void {
setTimeout(() => this.popoverService.open(template, target, position, backdrop, {}), 200);
/** Branch = office with no children. */
get branchCount(): number {
return this.rows.filter((r) => r.childCount === 0).length;
}
/**
* To show popover.
*/
ngAfterViewInit() {
if (this.configurationWizardService.showOfficeList) {
setTimeout(() => {
this.showPopover(this.templateButtonTreeView, this.buttonTreeView.nativeElement, 'bottom', true);
});
}
if (this.configurationWizardService.showOfficeTable) {
setTimeout(() => {
this.showPopover(this.templateTableOffices, this.tableOffices.nativeElement, 'top', true);
});
}
/** Regional = office with children but a parent. */
get regionalCount(): number {
return this.rows.filter((r) => r.childCount > 0 && !!r.parentId).length;
}
/**
* Next Step (Create Office Form) Configuration Wizard.
*/
nextStep() {
this.configurationWizardService.showOfficeList = false;
this.configurationWizardService.showOfficeTable = false;
this.configurationWizardService.showOfficeForm = true;
this.router.navigate(['/organization/offices/create']);
get headOfficeCount(): number {
return this.rows.filter((r) => !r.parentId).length;
}
/**
* Previous Step ( Manage Offices Page) Configuration Wizard.
*/
previousStep() {
this.configurationWizardService.showOfficeList = false;
this.configurationWizardService.showOfficeTable = false;
this.configurationWizardService.showCreateOffice = true;
this.router.navigate(['/organization']);
// ───── Filter ─────
get filteredRows(): OfficeRow[] {
const q = this.search.trim().toLowerCase();
if (!q) return this.rows;
return this.rows.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.externalId.toLowerCase().includes(q) ||
r.parentName.toLowerCase().includes(q)
);
}
/**
* Expand and Collapse the tree
*/
toggleExpandCollapse() {
clearSearch(): void {
this.search = '';
}
// ───── Tree helpers ─────
hasNestedChild = (_: number, node: OfficeNode) => node.children.length > 0;
toggleExpandCollapse(): void {
this.isTreeExpanded = this.treeControlService.toggleExpandCollapse(this.nestedTreeControl, this.isTreeExpanded);
}
// ───── Row / node actions ─────
viewOffice(row: { id: string }): void {
this.router.navigate(
[
row.id,
'general'
],
{ relativeTo: this.route }
);
}
editOffice(row: { id: string }): void {
this.router.navigate(
[
row.id,
'edit'
],
{ relativeTo: this.route }
);
}
createOffice(): void {
this.router.navigate(['create'], { relativeTo: this.route });
}
importOffices(): void {
this.router.navigate([
'/organization',
'bulk-import',
'Offices'
]);
}
/**
* Pre-checks then deletes the office.
*
* Blocks early when:
* - the office has child offices (cascading delete unsafe),
* - the office has any staff assigned (per requirement).
*/
deleteOffice(row: OfficeRow): void {
if (row.childCount > 0) {
this.showError(
`"${row.name}" has ${row.childCount} sub-office${row.childCount === 1 ? '' : 's'} attached. Please move or remove its sub-offices before deleting.`
);
return;
}
// Staff check goes to the server (cached per office for the session).
if (this.staffCountCache.has(row.id)) {
this.confirmAndDelete(row, this.staffCountCache.get(row.id) as number);
return;
}
this.organizationService.getStaff(row.id).subscribe({
next: (staff: any[]) => {
const count = (staff || []).length;
this.staffCountCache.set(row.id, count);
this.confirmAndDelete(row, count);
},
error: () => {
// If we can't reach the staff endpoint, still let the server enforce
// referential integrity — show the standard delete dialog.
this.confirmAndDelete(row, 0);
}
});
}
private confirmAndDelete(row: OfficeRow, staffCount: number): void {
if (staffCount > 0) {
this.showError(
`"${row.name}" has ${staffCount} employee${staffCount === 1 ? '' : 's'} assigned. Reassign them before deleting this office.`
);
return;
}
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `office "${row.name}"` }
});
ref.afterClosed().subscribe((response: any) => {
if (!response?.delete) return;
this.organizationService.deleteOffice(row.id).subscribe({
next: () => {
this.showSuccess('Office Removed', `"${row.name}" has been deleted.`);
this.refreshOffices();
},
error: (err: any) => {
const apiMsg =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || 'Please try again.';
this.showError(`Failed to remove "${row.name}". ${apiMsg}`);
}
});
});
}
private refreshOffices(): void {
this.organizationService.getOffices().subscribe((offices: any[]) => {
this.officesData = offices || [];
this.officeTreeService.initialize(this.officesData);
this.buildRows();
this.staffCountCache.clear();
});
}
// ───── Dialog helpers (Nexora standard) ─────
private showSuccess(title: string, message: string): void {
this.dialog.open(SuccessDialogComponent, {
data: { title, message, buttonLabel: 'OK' },
panelClass: 'sd-panel'
});
}
private showError(message: string): void {
this.dialog.open(ErrorDialogComponent, { data: message });
}
// ───── Misc ─────
trackRow = (_i: number, row: OfficeRow) => row.id;
/** Look up an OfficeRow for a tree node so menu items can call the same handlers. */
rowForNode(node: OfficeNode): OfficeRow | undefined {
return this.rows.find((r) => r.id === String(node.id));
}
private formatDate(d: any): string {
if (!d) return '';
if (Array.isArray(d) && d.length >= 3) {
const dt = new Date(d[0], d[1] - 1, d[2]);
return dt.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
const dt = new Date(d);
return Number.isNaN(dt.getTime())
? String(d)
: dt.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
}
@@ -707,8 +707,17 @@ const routes: Routes = [
]
},
{
// Manage Holidays consolidated into the unified Bank Calendar UI.
// Flat redirect on the parent so /organization/holidays AND any
// legacy sub-path are caught and forwarded reliably (a nested
// path: '' redirect doesn't always fire under hash-strategy routing).
path: 'holidays',
data: { title: 'Manage Holidays', breadcrumb: 'Manage Holidays' },
pathMatch: 'full',
redirectTo: '/bank-calendar'
},
{
path: 'holidays-legacy',
data: { title: 'Manage Holidays (Legacy)', breadcrumb: 'Manage Holidays' },
children: [
{
path: '',
@@ -48,9 +48,9 @@
</a>
</div>
<!-- Manage Holidays -->
<!-- Manage Holidays (opens unified Bank Calendar UI) -->
<div #holidays>
<a class="org-card" [routerLink]="['holidays']" *mifosxHasPermission="'READ_HOLIDAY'">
<a class="org-card" [routerLink]="['/bank-calendar']" *mifosxHasPermission="'READ_HOLIDAY'">
<div class="org-card__icon org-card__icon--green">
<i class="fas fa-calendar-alt"></i>
</div>
@@ -139,6 +139,17 @@ export class OrganizationService {
return this.http.put(`/offices/${officeId}`, office);
}
/**
* Delete an office. Fineract enforces referential integrity server-side
* (rejects when staff / sub-offices / linked clients exist) — the UI also
* pre-checks staff count for a better experience.
* @param {string} officeId Office Id to remove.
* @returns {Observable<any>}
*/
deleteOffice(officeId: string): Observable<any> {
return this.http.delete(`/offices/${officeId}`);
}
/**
* @returns {Observable<any>}
*/