Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/app/analytics/services/analytics-data-source.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,7 @@ export class AnalyticsDataSourceService {
return request$;
}

private extractAmountPair(response: any[], reportName: string): [
number,
number
] {
private extractAmountPair(response: any[], reportName: string): [number, number] {
const firstRow = response?.[0] || {};
const numericEntries = Object.entries(firstRow)
.map(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
],
qualification: [''],
age: [
{ value: '', disabled: true }
],
{ value: '', disabled: true }],
isDependent: [''],
relationshipId: [
'',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ describe('ClientActionNotifierService', () => {
ClientActionNotifierService,
{ provide: MatSnackBar, useValue: snackBar },
{ provide: TranslateService, useValue: translateService },
{ provide: Router, useValue: router }
]
{ provide: Router, useValue: router }]
});

service = TestBed.inject(ClientActionNotifierService);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ describe('TakeSurveyComponent', () => {
id: 10,
name: 'Test Survey',
questionDatas: [
{ id: 1, componentKey: 'A', answer: { id: 2, value: '5' } }
]
{ id: 1, componentKey: 'A', answer: { id: 2, value: '5' } }]
}
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,7 @@ export class AddFamilyMemberComponent implements OnInit {
],
qualification: [''],
age: [
{ value: '', disabled: true }
],
{ value: '', disabled: true }],
isDependent: [''],
relationshipId: [
'',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,7 @@ export class EditFamilyMemberComponent implements OnInit {
],
qualification: [familyMember.qualification],
age: [
{ value: familyMember.age, disabled: true }
],
{ value: familyMember.age, disabled: true }],
isDependent: [familyMember.isDependent],
relationshipId: [
familyMember.relationshipId,
Expand Down
27 changes: 9 additions & 18 deletions src/app/clients/create-client/create-client.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,9 @@ describe('CreateClientComponent - Integration Tests', () => {
}
],
officeOptions: [
{ id: 1, name: 'Head Office' }
],
{ id: 1, name: 'Head Office' }],
staffOptions: [
{ id: 1, displayName: 'John Doe' }
],
{ id: 1, displayName: 'John Doe' }],
savingsProductOptions: [] as any[],
genderOptions: [] as any[],
clientTypeOptions: [] as any[],
Expand All @@ -62,8 +60,7 @@ describe('CreateClientComponent - Integration Tests', () => {

const mockAddressConfig = {
addressTypeIdOptions: [
{ id: 1, name: 'Home' }
]
{ id: 1, name: 'Home' }]
};

beforeEach(async () => {
Expand Down Expand Up @@ -209,8 +206,7 @@ describe('CreateClientComponent - Integration Tests', () => {
Object.defineProperty(component.clientFamilyMembersStep, 'familyMembers', {
get: jest.fn(() => ({
familyMembers: [
{ firstName: 'Jane', relationship: 'Spouse' }
]
{ firstName: 'Jane', relationship: 'Spouse' }]
})),
configurable: true
});
Expand Down Expand Up @@ -240,8 +236,7 @@ describe('CreateClientComponent - Integration Tests', () => {
Object.defineProperty(component.clientFamilyMembersStep, 'familyMembers', {
get: jest.fn(() => ({
familyMembers: [
{ firstName: 'Jane', relationship: 'Spouse' }
]
{ firstName: 'Jane', relationship: 'Spouse' }]
})),
configurable: true
});
Expand All @@ -251,8 +246,7 @@ describe('CreateClientComponent - Integration Tests', () => {
Object.defineProperty(component.clientAddressStep, 'address', {
get: jest.fn(() => ({
address: [
{ addressTypeId: 1, street: '123 Main St' }
]
{ addressTypeId: 1, street: '123 Main St' }]
})),
configurable: true
});
Expand Down Expand Up @@ -379,8 +373,7 @@ describe('CreateClientComponent - Integration Tests', () => {
Object.defineProperty(component.clientAddressStep, 'address', {
get: jest.fn(() => ({
address: [
{ addressTypeId: 1, street: '123 Main St', city: 'New York' }
]
{ addressTypeId: 1, street: '123 Main St', city: 'New York' }]
})),
configurable: true
});
Expand Down Expand Up @@ -525,8 +518,7 @@ describe('CreateClientComponent - Integration Tests', () => {
Object.defineProperty(component.clientFamilyMembersStep, 'familyMembers', {
get: jest.fn(() => ({
familyMembers: [
{ firstName: 'John', relationship: 'Spouse' }
]
{ firstName: 'John', relationship: 'Spouse' }]
})),
configurable: true
});
Expand All @@ -537,8 +529,7 @@ describe('CreateClientComponent - Integration Tests', () => {
Object.defineProperty(component.clientAddressStep, 'address', {
get: jest.fn(() => ({
address: [
{ addressTypeId: 1, street: '456 Oak Ave', city: 'Boston' }
]
{ addressTypeId: 1, street: '456 Oak Ave', city: 'Boston' }]
})),
configurable: true
});
Expand Down
3 changes: 1 addition & 2 deletions src/app/configuration-wizard/popover/popover.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,7 @@ export class PopoverService {
Injector.create({
parent: this.injector,
providers: [
{ provide: PopoverRef, useValue: popoverRef }
]
{ provide: PopoverRef, useValue: popoverRef }]
})
// new PortalInjector(
// this.injector,
Expand Down
3 changes: 1 addition & 2 deletions src/app/core/error-handler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,7 @@ describe('ErrorHandlerService', () => {
providers: [
ErrorHandlerService,
{ provide: MatSnackBar, useValue: snackBarSpy },
{ provide: Router, useValue: routerSpy }
]
{ provide: Router, useValue: routerSpy }]
});

service = TestBed.inject(ErrorHandlerService);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,7 @@ export class EditPaymentTypeComponent implements OnInit {
],
description: [this.paymentTypeData.description],
isCashPayment: [
{ value: this.paymentTypeData.isCashPayment, disabled: this.isSystemDefined }
],
{ value: this.paymentTypeData.isCashPayment, disabled: this.isSystemDefined }],
position: [
{ value: this.paymentTypeData.position, disabled: this.isSystemDefined },
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ export class FloatingRatePeriodDialogComponent implements OnInit {
]
],
isDifferentialToBaseLendingRate: [
{ value: this.data?.isDifferentialToBaseLendingRate ?? false, disabled: rowDisabled }
]
{ value: this.data?.isDifferentialToBaseLendingRate ?? false, disabled: rowDisabled }]
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/app/reports/reports.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class ReportsService {
* @returns {Observable<ReportParameter[]>}
*/
getReportParams(reportName: string): Observable<ReportParameter[]> {
const httpParams = new HttpParams().set('R_reportListing', `${reportName}`).set('parameterType', 'true');
const httpParams = new HttpParams().set('R_reportListing', `'${reportName}'`).set('parameterType', 'true');
return this.http
.get(`/runreports/FullParameterList`, { params: httpParams })
.pipe(map((response: any) => response.data.map((entry: any) => new ReportParameter(entry.row))));
Expand Down
26 changes: 26 additions & 0 deletions src/app/settings/settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,30 @@ export class SettingsService {
get themeDarkEnabled(): boolean {
return JSON.parse(localStorage.getItem('mifosXThemeDarkEnabled'));
}

setOllamaEnabled(enabled: boolean) {
localStorage.setItem('mifosXOllamaEnabled', String(enabled));
}

setOllamaUrl(url: string) {
localStorage.setItem('mifosXOllamaUrl', url);
}

setOllamaModel(model: string) {
localStorage.setItem('mifosXOllamaModel', model);
}

get ollamaEnabled(): boolean {
const stored = localStorage.getItem('mifosXOllamaEnabled');
if (stored !== null) return stored === 'true';
return environment.ollamaEnabled;
}

get ollamaUrl(): string {
return localStorage.getItem('mifosXOllamaUrl') || environment.ollamaUrl || '';
}

get ollamaModel(): string {
return localStorage.getItem('mifosXOllamaModel') || environment.ollamaModel || '';
}
}
140 changes: 140 additions & 0 deletions src/app/shared/services/ollama.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpBackend } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { environment } from 'environments/environment';

interface OllamaTagsResponse {
models: Array<{ name: string }>;
}

interface OllamaGenerateResponse {
response: string;
}

/**
* Service for communicating with a local Ollama instance.
*
* Uses HttpBackend directly to bypass Fineract auth interceptors.
* The feature is fully optional — all methods no-op when disabled or unconfigured.
*/
@Injectable({
providedIn: 'root'
})
export class OllamaService {
private httpBackend = inject(HttpBackend);
private externalHttp = new HttpClient(this.httpBackend);

get enabled(): boolean {
const stored = localStorage.getItem('mifosXOllamaEnabled');
if (stored !== null) {
return stored === 'true';
}
return environment.ollamaEnabled;
}

get url(): string {
return localStorage.getItem('mifosXOllamaUrl') || environment.ollamaUrl || '';
}

get model(): string {
return localStorage.getItem('mifosXOllamaModel') || environment.ollamaModel || '';
}

/** Returns list of available model names from the configured Ollama URL. */
listModels(): Observable<string[]> {
return this.listModelsFromUrl(this.url);
}

/** Returns list of available model names from a given Ollama URL (used before saving settings). */
listModelsFromUrl(url: string): Observable<string[]> {
if (!url) return of([]);
return this.externalHttp.get<OllamaTagsResponse>(`${url}/api/tags`).pipe(
map((res) => (res?.models ?? []).map((m) => m.name)),
catchError(() => of([]))
);
}

/**
* Sends a prompt to Ollama and returns the generated text.
* Returns an empty string if the service is disabled or unconfigured.
*/
generate(prompt: string, system?: string): Observable<string> {
if (!this.enabled || !this.url || !this.model) return of('');
const body: Record<string, unknown> = { model: this.model, prompt, stream: false };
if (system) body['system'] = system;
return this.externalHttp.post<OllamaGenerateResponse>(`${this.url}/api/generate`, body).pipe(
map((res) => res?.response ?? ''),
catchError(() => of(''))
);
}

/**
* Generates a Fineract SELECT query from a natural-language description.
* Includes the core Fineract schema as a system prompt so Ollama can reference
* real table and column names. Fineract runs on MySQL, MariaDB, or PostgreSQL,
* so the prompt asks for portable ANSI SQL rather than a vendor-specific dialect.
*/
generateSqlQuery(description: string): Observable<string> {
const system = `You are a Mifos Fineract SQL report query generator.
Generate a single ANSI SQL SELECT query based on the user's description.
Fineract can run on MySQL, MariaDB, or PostgreSQL, so avoid vendor-specific syntax and stick to standard ANSI SQL.
Use only these tables and columns:

m_client: id, account_no, firstname, lastname, status_enum (100=pending 300=active 600=closed), office_id, staff_id, activation_date, birthdate, mobile_no, email_address
m_loan: id, account_no, client_id, group_id, product_id, loan_officer_id, loan_status_id (100=pending 200=approved 300=active 400=withdrawn 500=rejected 600=closed 700=overpaid), principal_amount, approved_principal, outstanding_derived, disbursedon_date, expected_maturedon_date, closedon_date, currency_code, office_id
m_loan_repayment_schedule: id, loan_id, duedate, installment, principal_amount, interest_amount, fee_charges_amount, penalty_charges_amount, principal_completed_derived, interest_completed_derived, completed_derived
m_loan_transaction: id, loan_id, transaction_type_enum (1=disbursement 2=repayment 5=waive_interest 9=written_off), transaction_date, amount, is_reversed
m_savings_account: id, account_no, client_id, group_id, product_id, status_enum (100=submitted 200=approved 300=active 600=closed), account_balance_derived, currency_code, office_id
m_savings_account_transaction: id, savings_account_id, transaction_type_enum (1=deposit 2=withdrawal 4=interest_posting), transaction_date, amount, running_balance_derived, is_reversed
m_office: id, name, hierarchy, parent_id, opening_date
m_staff: id, firstname, lastname, display_name, is_loan_officer, is_active, office_id
m_group: id, account_no, display_name, office_id, staff_id, status_enum, level_id, activation_date
m_product_loan: id, name, currency_code, min_principal_amount, max_principal_amount
m_savings_product: id, name, currency_code
m_currency: code, name, display_symbol, decimal_places

Rules:
- Return ONLY the raw SQL SELECT statement. No markdown, no backticks, no explanation.
- Use meaningful column aliases (e.g. c.firstname AS first_name).
- Parameterise date ranges with ${'{'}dateFormat${'}'} and ${'{'}startDate${'}'} / ${'{'}endDate${'}'} placeholders if the user mentions a date range.`;

return this.generate(description, system).pipe(map((sql) => this.validateSelectSql(this.stripMarkdownFences(sql))));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private stripMarkdownFences(text: string): string {
return text
.replace(/^```(?:sql)?\s*/i, '')
.replace(/\s*```\s*$/, '')
.trim();
}

/** Returns the SQL unchanged if it is a single SELECT statement, otherwise an empty string. */
private validateSelectSql(sql: string): string {
const normalized = sql.trim().replace(/;+\s*$/, '');
const isSingleSelect = /^select\b/i.test(normalized) && !/;\s*\S/.test(normalized);
return isSingleSelect ? normalized : '';
}

/** Returns true if the configured Ollama instance is reachable. */
checkConnection(): Observable<boolean> {
return this.checkConnectionAt(this.url);
}

/** Returns true if an Ollama instance at the given URL is reachable. */
checkConnectionAt(url: string): Observable<boolean> {
if (!url) return of(false);
return this.externalHttp.get<OllamaTagsResponse>(`${url}/api/tags`).pipe(
map(() => true),
catchError(() => of(false))
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
22 changes: 22 additions & 0 deletions src/app/system/external-services/external-services.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,28 @@ <h4 matLine [routerLink]="['notification']">
</p>
}
</mat-list-item>
Comment thread
onesketchyguy marked this conversation as resolved.

<mat-list-item [routerLink]="['ollama']">
<mat-icon matListIcon [routerLink]="['ollama']">
<fa-icon icon="robot" size="sm"></fa-icon>
</mat-icon>
<h4 matLine [routerLink]="['ollama']">{{ 'labels.heading.AI API Report Assistant' | translate }}</h4>
<button
mat-icon-button
type="button"
(click)="arrowBooleansToggle(4); $event.stopPropagation()"
[attr.aria-label]="
arrowBooleans[4] ? ('labels.buttons.Collapse' | translate) : ('labels.buttons.Expand' | translate)
"
>
<fa-icon [icon]="arrowBooleans[4] ? 'arrow-up' : 'arrow-down'" size="md"></fa-icon>
</button>
@if (arrowBooleans[4]) {
<p matLine [routerLink]="['ollama']">
{{ 'labels.text.AI API Report Assistant Configuration' | translate }}
</p>
}
</mat-list-item>
</mat-nav-list>
</div>
</div>
Expand Down
Loading
Loading