blob: 9e6a6b99a0bed1a940e97037ae698c18d279bfc2 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Observable } from 'rxjs';
import {
IonButton,
IonCard,
IonCardContent,
IonCardHeader,
IonCardTitle,
IonIcon,
IonItem,
IonLabel,
IonList,
IonPopover,
IonSegment,
IonSegmentButton,
} from '@ionic/angular/standalone';
import {
CellTemplateDirective,
ColumnDef,
DataTableComponent,
RequiresPermissionDirective,
StatusBadgeComponent,
} from '../../shared';
import { TooltipDirective } from '../../shared/directives/tooltip.directive';
import { BASE_PATH, GroupsService } from '../../api';
import { I18N, TranslatePipe } from '../../core/adapters';
import { DialogService } from '../../core/services/dialog.service';
import { NotificationService } from '../../core/services/notification.service';
import {
FINERACT_DATE_FORMAT,
FINERACT_LOCALE,
formatArrayDate,
formatDateToFineract,
} from '../../core/utils/date-formatter';
import {
GROUP_STATUS,
FineractDate,
GroupClientMember,
GroupDetail,
GroupRoleAssignment,
} from './group-detail.model';
import {
GroupActionDialogComponent,
GroupActionDialogData,
GroupActionResult,
} from './group-action-dialog.component';
import {
GroupMembersDialogComponent,
GroupMembersDialogData,
GroupMembersResult,
} from './group-members-dialog.component';
import {
GroupRoleDialogComponent,
GroupRoleDialogData,
GroupRoleResult,
} from './group-role-dialog.component';
import {
GroupStaffDialogComponent,
GroupStaffDialogData,
GroupStaffResult,
} from './group-staff-dialog.component';
import { GroupNotesListComponent } from './tabs/group-notes-list.component';
import { GroupAccountsTabComponent } from './tabs/group-accounts-tab.component';
import { EntityDocumentsComponent } from '../../shared/components/entity-documents/entity-documents.component';
import { EntityDatatablesComponent } from '../../shared/components/entity-datatables/entity-datatables.component';
/** A tab index that is also what the segment binds to; `ion-segment` deals in strings. */
/**
* The group detail screen: summary, membership, committee and notes, plus the lifecycle
* commands the platform exposes for a group (issue #180).
*
* Before this existed the Groups feature was a list and a create form, so a group could be made
* and then never looked at again — an officer had no way to see who was in one, and no way to
* activate, close, staff or amend it. Group lending is the product this application gates behind
* the `mfis` institution type, so the gap was the difference between advertising the feature and
* having it.
*
* The group is fetched with `associations=all`, which is what makes `clientMembers` and
* `groupRoles` present at all — without it the response carries the summary only and both tabs
* render empty against a group that has members. See {@link GroupDetail} for why the response is
* typed here rather than taken from the generated client.
*/
/**
* The tabs on this screen, named.
*
* They were positional strings — '0', '7' — which say nothing at the point of use and shift
* meaning whenever a tab is inserted in the middle. The values are still strings because
* `ion-segment` compares them as such.
*/
export const GROUP_TAB = {
general: 'general',
members: 'members',
committee: 'committee',
notes: 'notes',
accounts: 'accounts',
documents: 'documents',
customFields: 'customFields',
} as const;
export type GroupTab = (typeof GROUP_TAB)[keyof typeof GROUP_TAB];
@Component({
selector: 'app-group-view',
standalone: true,
imports: [
GroupAccountsTabComponent,
EntityDocumentsComponent,
EntityDatatablesComponent,
RouterModule,
TranslatePipe,
StatusBadgeComponent,
DataTableComponent,
CellTemplateDirective,
RequiresPermissionDirective,
TooltipDirective,
GroupNotesListComponent,
IonButton,
IonCard,
IonCardContent,
IonCardHeader,
IonCardTitle,
IonIcon,
IonItem,
IonLabel,
IonList,
IonPopover,
IonSegment,
IonSegmentButton,
],
template: `
@if (hasError()) {
<div class="load-error" data-testid="group-load-error">
<ion-icon name="alert-circle-outline"></ion-icon>
<p>{{ 'GROUPS.LOAD_FAILED' | appTranslate }}</p>
<ion-button color="primary" (click)="loadGroup()">
{{ 'COMMON.RETRY' | appTranslate }}
</ion-button>
</div>
} @else if (group(); as detail) {
<div class="view-container">
<ion-card class="header-card">
<ion-card-content>
<div class="header">
<div class="identity">
<h1 data-testid="group-name">{{ detail.name }}</h1>
<div class="sub">
<span class="account-no">#{{ detail.accountNo }}</span>
<app-status-badge
data-testid="group-status"
[status]="detail.status"
></app-status-badge>
</div>
</div>
<div class="header-actions">
<ion-button color="primary" id="group-actions-trigger" data-testid="group-actions">
<ion-icon name="ellipsis-vertical-outline"></ion-icon>
{{ 'COMMON.ACTIONS' | appTranslate }}
</ion-button>
<ion-popover trigger="group-actions-trigger" [dismissOnSelect]="true">
<ng-template>
<ion-list>
@if (isPending()) {
<ion-item
button
data-testid="group-action-activate"
(click)="onActivate()"
appRequiresPermission="ACTIVATE_GROUP"
>
<ion-icon slot="start" name="play-circle-outline"></ion-icon>
<ion-label>{{ 'GROUPS.ACTIVATE' | appTranslate }}</ion-label>
</ion-item>
}
@if (isActive()) {
<ion-item
button
data-testid="group-action-close"
(click)="onClose()"
appRequiresPermission="CLOSE_GROUP"
>
<ion-icon slot="start" name="close-circle-outline"></ion-icon>
<ion-label>{{ 'GROUPS.CLOSE' | appTranslate }}</ion-label>
</ion-item>
}
@if (!isClosed()) {
<ion-item
button
data-testid="group-action-assign-staff"
(click)="onAssignStaff()"
appRequiresPermission="ASSIGNSTAFF_GROUP"
>
<ion-icon slot="start" name="person-add-outline"></ion-icon>
<ion-label>{{ 'GROUPS.ASSIGN_STAFF' | appTranslate }}</ion-label>
</ion-item>
@if (detail.staffId) {
<ion-item
button
data-testid="group-action-unassign-staff"
(click)="onUnassignStaff()"
appRequiresPermission="UNASSIGNSTAFF_GROUP"
>
<ion-icon slot="start" name="person-remove-outline"></ion-icon>
<ion-label>{{ 'GROUPS.UNASSIGN_STAFF' | appTranslate }}</ion-label>
</ion-item>
}
<ion-item
button
data-testid="group-action-edit"
[routerLink]="['/groups/edit', groupId]"
appRequiresPermission="UPDATE_GROUP"
>
<ion-icon slot="start" name="create-outline"></ion-icon>
<ion-label>{{ 'COMMON.EDIT' | appTranslate }}</ion-label>
</ion-item>
}
</ion-list>
</ng-template>
</ion-popover>
<ion-button fill="clear" (click)="onBack()">
<ion-icon name="arrow-back-outline"></ion-icon>
{{ 'COMMON.BACK' | appTranslate }}
</ion-button>
</div>
</div>
</ion-card-content>
</ion-card>
<div class="content-body">
<ion-segment [value]="activeTab()" (ionChange)="activeTab.set($any($event).detail.value)">
<ion-segment-button [value]="TAB.general" data-testid="group-tab-general">
<ion-label>{{ 'GROUPS.GENERAL' | appTranslate }}</ion-label>
</ion-segment-button>
<ion-segment-button [value]="TAB.members" data-testid="group-tab-members">
<ion-label>{{ 'GROUPS.MEMBERS' | appTranslate }}</ion-label>
</ion-segment-button>
<ion-segment-button [value]="TAB.committee" data-testid="group-tab-committee">
<ion-label>{{ 'GROUPS.COMMITTEE' | appTranslate }}</ion-label>
</ion-segment-button>
<ion-segment-button [value]="TAB.accounts" data-testid="group-tab-accounts">
<ion-label>{{ 'COMMON.ACCOUNTS' | appTranslate }}</ion-label>
</ion-segment-button>
<ion-segment-button [value]="TAB.documents" data-testid="group-tab-documents">
<ion-label>{{ 'SAVINGS.DOCUMENTS' | appTranslate }}</ion-label>
</ion-segment-button>
<ion-segment-button [value]="TAB.customFields" data-testid="group-tab-custom-fields">
<ion-label>{{ 'SYSTEM.CUSTOM_FIELDS' | appTranslate }}</ion-label>
</ion-segment-button>
<ion-segment-button [value]="TAB.notes" data-testid="group-tab-notes">
<ion-label>{{ 'GROUPS.NOTES' | appTranslate }}</ion-label>
</ion-segment-button>
</ion-segment>
@if (activeTab() === TAB.general) {
<div class="tab-content">
<ion-card>
<ion-card-header>
<ion-card-title>{{ 'GROUPS.SUMMARY' | appTranslate }}</ion-card-title>
</ion-card-header>
<ion-card-content class="details-list">
<div class="detail-item">
<span class="label">{{ 'COMMON.ACCOUNT_NO' | appTranslate }}</span>
<span class="value">{{ detail.accountNo || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'COMMON.OFFICE' | appTranslate }}</span>
<span class="value">{{ detail.officeName || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'COMMON.STAFF' | appTranslate }}</span>
<span class="value" data-testid="group-staff-name">
{{ detail.staffName || ('GROUPS.UNASSIGNED' | appTranslate) }}
</span>
</div>
<div class="detail-item">
<span class="label">{{ 'GROUPS.EXTERNAL_ID' | appTranslate }}</span>
<span class="value">{{ detail.externalId || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'GROUPS.SUBMITTED_ON' | appTranslate }}</span>
<span class="value">{{ displayDate(detail.timeline?.submittedOnDate) }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'GROUPS.ACTIVATED_ON' | appTranslate }}</span>
<span class="value">{{ displayDate(detail.activationDate) }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'GROUPS.MEMBER_COUNT' | appTranslate }}</span>
<span class="value" data-testid="group-member-count">{{
members().length
}}</span>
</div>
</ion-card-content>
</ion-card>
</div>
}
@if (activeTab() === TAB.members) {
<div class="tab-content">
<div class="tab-actions">
<ion-button
color="primary"
data-testid="group-add-members"
[disabled]="isClosed()"
(click)="onAddMembers()"
appRequiresPermission="ASSOCIATECLIENTS_GROUP"
>
<ion-icon name="person-add-outline"></ion-icon>
{{ 'GROUPS.ADD_MEMBERS' | appTranslate }}
</ion-button>
<ion-button
color="medium"
fill="outline"
data-testid="group-remove-members"
[disabled]="isClosed() || !members().length"
(click)="onRemoveMembers()"
appRequiresPermission="DISASSOCIATECLIENTS_GROUP"
>
<ion-icon name="person-remove-outline"></ion-icon>
{{ 'GROUPS.REMOVE_MEMBERS' | appTranslate }}
</ion-button>
</div>
<app-data-table
[data]="members()"
[columns]="memberColumns"
[isLoading]="isLoading()"
[localLogic]="true"
>
<ng-template appCellTemplate="displayName" let-row>
<a [routerLink]="['/clients/view', row.id]">{{ row.displayName }}</a>
</ng-template>
<ng-template appCellTemplate="status" let-row>
<app-status-badge [status]="row.status"></app-status-badge>
</ng-template>
</app-data-table>
</div>
}
@if (activeTab() === TAB.committee) {
<div class="tab-content">
<div class="tab-actions">
<ion-button
color="primary"
data-testid="group-assign-role"
[disabled]="isClosed() || !members().length"
(click)="onAssignRole()"
appRequiresPermission="ASSIGNROLE_GROUP"
>
<ion-icon name="ribbon-outline"></ion-icon>
{{ 'GROUPS.ASSIGN_ROLE' | appTranslate }}
</ion-button>
</div>
<app-data-table
[data]="roles()"
[columns]="roleColumns"
[isLoading]="isLoading()"
[localLogic]="true"
>
<ng-template appCellTemplate="roleName" let-row>
{{ row.role?.name || '-' }}
</ng-template>
<ng-template appCellTemplate="actions" let-row>
<ion-button
fill="clear"
color="danger"
[attr.data-testid]="'group-unassign-role-' + row.id"
[disabled]="isClosed()"
(click)="onUnassignRole(row)"
appRequiresPermission="UNASSIGNROLE_GROUP"
[appTooltip]="'GROUPS.UNASSIGN_ROLE' | appTranslate"
[attr.aria-label]="'GROUPS.UNASSIGN_ROLE' | appTranslate"
>
<ion-icon name="trash-outline"></ion-icon>
</ion-button>
</ng-template>
</app-data-table>
</div>
}
@if (activeTab() === TAB.notes) {
<div class="tab-content">
<app-group-notes-list [groupId]="groupId"></app-group-notes-list>
</div>
}
@if (activeTab() === TAB.accounts) {
<div class="tab-content">
<app-group-accounts-tab [groupId]="groupId"></app-group-accounts-tab>
</div>
}
@if (activeTab() === TAB.documents) {
<div class="tab-content">
<app-entity-documents entityType="groups" [entityId]="groupId"></app-entity-documents>
</div>
}
@if (activeTab() === TAB.customFields) {
<div class="tab-content">
<app-entity-datatables
apptableName="m_group"
[entityId]="groupId"
></app-entity-datatables>
</div>
}
</div>
</div>
}
`,
styles: [
`
.view-container {
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.identity h1 {
margin: 0 0 8px;
font-size: 1.5rem;
}
.sub {
display: flex;
align-items: center;
gap: 12px;
}
.account-no {
color: var(--text-muted, #6b7280);
font-size: 0.9rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.content-body {
margin-top: 16px;
}
.tab-content {
padding-top: 16px;
}
.tab-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 16px;
}
.details-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px 24px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-item .label {
font-size: 12px;
color: var(--text-muted, #6b7280);
}
.detail-item .value {
font-size: 14px;
}
.load-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
color: var(--text-muted, #6b7280);
}
.load-error ion-icon {
font-size: 40px;
}
`,
],
})
export class GroupViewComponent implements OnInit {
private readonly groupsService = inject(GroupsService);
private readonly httpClient = inject(HttpClient);
private readonly basePath = inject(BASE_PATH);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dialogService = inject(DialogService);
private readonly notifications = inject(NotificationService);
private readonly i18n = inject(I18N);
groupId = 0;
readonly group = signal<GroupDetail | null>(null);
readonly isLoading = signal(false);
readonly hasError = signal(false);
/** Exposed so the template names its tabs instead of numbering them. */
protected readonly TAB = GROUP_TAB;
readonly activeTab = signal<GroupTab>(GROUP_TAB.general);
/**
* `clientMembers` rather than `activeClientMembers`.
*
* Both come back. Showing only the active ones hides a pending member from the very screen an
* officer would use to chase them, and makes the member count disagree with what the platform
* will let you disassociate.
*/
readonly members = computed<GroupClientMember[]>(() => this.group()?.clientMembers ?? []);
readonly roles = computed<GroupRoleAssignment[]>(() => this.group()?.groupRoles ?? []);
readonly isPending = computed(() => this.group()?.status?.id === GROUP_STATUS.PENDING);
readonly isActive = computed(() => this.group()?.status?.id === GROUP_STATUS.ACTIVE);
readonly isClosed = computed(() => this.group()?.status?.id === GROUP_STATUS.CLOSED);
memberColumns: ColumnDef[] = [
{ key: 'accountNo', label: 'COMMON.ACCOUNT_NO' },
{ key: 'displayName', label: 'COMMON.NAME' },
{ key: 'status', label: 'COMMON.STATUS' },
{ key: 'officeName', label: 'COMMON.OFFICE' },
];
roleColumns: ColumnDef[] = [
{ key: 'roleName', label: 'GROUPS.ROLE' },
{ key: 'clientName', label: 'GROUPS.MEMBER' },
{ key: 'actions', label: 'COMMON.ACTIONS' },
];
ngOnInit(): void {
this.groupId = Number(this.route.snapshot.paramMap.get('id'));
this.loadGroup();
}
/**
* Fetches the group with its associations.
*
* Issued through `HttpClient` rather than `GroupsService.getGroupsGroupId`, which takes only
* `staffInSelectedOfficeOnly` and `roleId` — the upstream OpenAPI document does not describe
* `associations`, so the generated client cannot send it. Plain `GET /groups/{id}` answers
* with the summary alone, leaving the members and committee tabs empty for every group that
* has both. `BASE_PATH` and the injected client keep the request on the configured API root
* and through the app's interceptors, the same escape hatch `loan-documents-tab` uses.
*
* `BASE_PATH` stops short of the version segment — `app.config.ts` strips a trailing `/v1`
* because the generated services prepend their own — so `/v1` belongs here.
*/
loadGroup(): void {
this.isLoading.set(true);
this.httpClient
.get<GroupDetail>(`${this.basePath}/v1/groups/${this.groupId}`, {
params: { associations: 'all' },
})
.subscribe({
next: (detail) => {
this.group.set(detail);
this.hasError.set(false);
this.isLoading.set(false);
},
error: () => {
this.hasError.set(true);
this.isLoading.set(false);
},
});
}
displayDate(value: number[] | undefined): string {
return value ? formatArrayDate(value) : '-';
}
onBack(): void {
void this.router.navigate(['/groups']);
}
// --- Lifecycle commands -------------------------------------------------------------------
async onActivate(): Promise<void> {
// The platform will not activate a group before the day it was submitted on.
const result = await this.openActionDialog(
'GROUPS.ACTIVATE',
'activate',
this.isoDate(this.group()?.timeline?.submittedOnDate),
);
if (!result) return;
this.runCommand('activate', {
activationDate: formatDateToFineract(new Date(result.actionDate)),
locale: FINERACT_LOCALE,
dateFormat: FINERACT_DATE_FORMAT,
});
}
async onClose(): Promise<void> {
// And it will not close one before the day it was activated on.
const result = await this.openActionDialog(
'GROUPS.CLOSE',
'close',
this.isoDate(this.group()?.timeline?.activatedOnDate),
);
if (!result) return;
this.runCommand('close', {
closureDate: formatDateToFineract(new Date(result.actionDate)),
closureReasonId: result.closureReasonId,
locale: FINERACT_LOCALE,
dateFormat: FINERACT_DATE_FORMAT,
});
}
private openActionDialog(
title: string,
command: GroupActionDialogData['command'],
minDate?: string,
): Promise<GroupActionResult | undefined> {
return this.dialogService.open<GroupActionResult>(GroupActionDialogComponent, {
data: { title, command, minDate } satisfies GroupActionDialogData,
});
}
/**
* A date the platform has stamped, as the ISO `YYYY-MM-DD` the action dialog floors on.
*
* These arrive rendered in the tenant's timezone, which is exactly what makes them usable as
* a floor for a picker that would otherwise only know what day it is in the browser's.
*/
private isoDate(value: FineractDate | undefined): string | undefined {
const iso = formatArrayDate(value);
return iso === '-' ? undefined : iso;
}
// --- Staff --------------------------------------------------------------------------------
async onAssignStaff(): Promise<void> {
const detail = this.group();
const result = await this.dialogService.open<GroupStaffResult>(GroupStaffDialogComponent, {
data: {
officeId: detail?.officeId,
staffId: detail?.staffId,
} satisfies GroupStaffDialogData,
});
if (!result) return;
this.runCommand('assignStaff', { staffId: result.staffId });
}
async onUnassignStaff(): Promise<void> {
const confirmed = await this.dialogService.confirm({
title: this.i18n.translate('GROUPS.UNASSIGN_STAFF'),
message: this.i18n.translate('GROUPS.CONFIRM_UNASSIGN_STAFF'),
});
if (!confirmed) return;
// The platform wants the staff it is being asked to remove, not an empty body: it validates
// the id against the current assignment and rejects a mismatch.
this.runCommand('unassignStaff', { staffId: this.group()?.staffId });
}
// --- Membership ---------------------------------------------------------------------------
async onAddMembers(): Promise<void> {
const result = await this.openMembersDialog('add');
if (!result) return;
this.runCommand('associateClients', { clientMembers: result.clientMembers });
}
async onRemoveMembers(): Promise<void> {
const result = await this.openMembersDialog('remove');
if (!result) return;
this.runCommand('disassociateClients', { clientMembers: result.clientMembers });
}
private openMembersDialog(
mode: GroupMembersDialogData['mode'],
): Promise<GroupMembersResult | undefined> {
return this.dialogService.open<GroupMembersResult>(GroupMembersDialogComponent, {
data: {
mode,
officeId: this.group()?.officeId,
members: this.members(),
} satisfies GroupMembersDialogData,
});
}
// --- Committee ----------------------------------------------------------------------------
async onAssignRole(): Promise<void> {
const result = await this.dialogService.open<GroupRoleResult>(GroupRoleDialogComponent, {
data: {
officeId: this.group()?.officeId,
members: this.members(),
} satisfies GroupRoleDialogData,
});
if (!result) return;
// `role`, not `roleId` — see GroupRoleDialogComponent.
this.runCommand('assignRole', { clientId: result.clientId, role: result.roleId });
}
async onUnassignRole(assignment: GroupRoleAssignment): Promise<void> {
const confirmed = await this.dialogService.confirm({
title: this.i18n.translate('GROUPS.UNASSIGN_ROLE'),
message: this.i18n.translate('GROUPS.CONFIRM_UNASSIGN_ROLE'),
destructive: true,
});
if (!confirmed) return;
// `roleId` here is the id of the *assignment*, which the platform passes as a query
// parameter rather than in the body.
this.runCommand('unassignRole', {}, assignment.id);
}
// --- Command plumbing ---------------------------------------------------------------------
/**
* Posts a group command and reloads.
*
* Reloading rather than patching the local copy: a command changes more than the field it
* names — activating stamps the activation date and flips the status, associating a client
* changes both member lists — and a screen that guessed at those would drift from the platform
* in ways only the next reload would reveal.
*
* No error toast here. `errorInterceptor` already raises one carrying the platform's own
* `defaultUserMessage`, and that message is the useful one: closing a group with members
* answers "Group cannot be closed because of active clients associated with it", which a
* generic "the group could not be updated" would replace with nothing. All this handler owes
* is clearing the pending state.
*/
private runCommand(command: string, body: Record<string, unknown>, roleId?: number): void {
this.isLoading.set(true);
(
this.groupsService.postGroupsGroupId(
this.groupId,
body,
command,
roleId,
) as Observable<unknown>
).subscribe({
next: () => {
void this.notifications.success(this.i18n.translate(`GROUPS.CMD_${command.toUpperCase()}`));
this.loadGroup();
},
error: () => this.isLoading.set(false),
});
}
}