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:
co-authored by
Claude Opus 4.7
parent
325228c514
commit
83ea57ccf0
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user