diff --git a/cmp-navigation/build.gradle.kts b/cmp-navigation/build.gradle.kts index e6da9792065..18aa1ff4e85 100644 --- a/cmp-navigation/build.gradle.kts +++ b/cmp-navigation/build.gradle.kts @@ -51,6 +51,7 @@ kotlin { implementation(projects.feature.settings) implementation(projects.feature.search) implementation(projects.feature.searchRecord) + implementation(projects.feature.standingInstruction) implementation(projects.feature.passcode) implementation(compose.material3) diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/di/KoinModules.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/di/KoinModules.kt index 3b2ed092fad..01bf50a88fa 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/di/KoinModules.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/di/KoinModules.kt @@ -38,6 +38,7 @@ import com.mifos.feature.savings.di.SavingsModule import com.mifos.feature.search.di.SearchModule import com.mifos.feature.searchrecord.di.SearchRecordModule import com.mifos.feature.settings.di.SettingsModule +import com.mifos.feature.standingInstruction.di.StandingInstructionsModule import com.mifos.room.di.DaoModule import com.mifos.room.di.HelperModule import com.mifos.room.di.PlatformSpecificDatabaseModule @@ -94,6 +95,7 @@ object KoinModules { SettingsModule, SearchRecordModule, MifosAuthenticatorModule, + StandingInstructionsModule, ) } diff --git a/core/common/src/commonMain/kotlin/com/mifos/core/common/utils/Constants.kt b/core/common/src/commonMain/kotlin/com/mifos/core/common/utils/Constants.kt index 3d16febc28b..b9c0b30fe16 100644 --- a/core/common/src/commonMain/kotlin/com/mifos/core/common/utils/Constants.kt +++ b/core/common/src/commonMain/kotlin/com/mifos/core/common/utils/Constants.kt @@ -239,4 +239,10 @@ object Constants { const val POSTAL_CODE = "postalCode" const val STATUS = "status" const val DOCUMENT_KEY = "documentKey" + + /** + * Constant values for standing instructions account type + */ + const val LOAN_ACCOUNT_VAL = 1 + const val SAVINGS_ACCOUNT_VAL = 2 } diff --git a/core/data/src/commonMain/kotlin/com/mifos/core/data/di/RepositoryModule.kt b/core/data/src/commonMain/kotlin/com/mifos/core/data/di/RepositoryModule.kt index 3681f897491..7a582a8da08 100644 --- a/core/data/src/commonMain/kotlin/com/mifos/core/data/di/RepositoryModule.kt +++ b/core/data/src/commonMain/kotlin/com/mifos/core/data/di/RepositoryModule.kt @@ -60,6 +60,7 @@ import com.mifos.core.data.repository.SearchRecordRepository import com.mifos.core.data.repository.SearchRepository import com.mifos.core.data.repository.ShareAccountRepository import com.mifos.core.data.repository.SignatureRepository +import com.mifos.core.data.repository.StandingInstructionsRepository import com.mifos.core.data.repository.SurveyListRepository import com.mifos.core.data.repository.SurveySubmitRepository import com.mifos.core.data.repository.SyncCenterPayloadsRepository @@ -133,6 +134,7 @@ import com.mifos.core.data.repositoryImp.SearchRecordRepositoryImpl import com.mifos.core.data.repositoryImp.SearchRepositoryImp import com.mifos.core.data.repositoryImp.ShareAccountRepositoryImpl import com.mifos.core.data.repositoryImp.SignatureRepositoryImp +import com.mifos.core.data.repositoryImp.StandingInstructionsRepositoryImp import com.mifos.core.data.repositoryImp.SurveyListRepositoryImp import com.mifos.core.data.repositoryImp.SurveySubmitRepositoryImp import com.mifos.core.data.repositoryImp.SyncCenterPayloadsRepositoryImp @@ -221,6 +223,9 @@ val RepositoryModule = module { singleOf(::SavingsAccountTransactionRepositoryImp) bind SavingsAccountTransactionRepository::class singleOf(::SavingsAccountTransactionReceiptRepositoryImpl) bind SavingsAccountTransactionReceiptRepository::class + // Standing Instructions + singleOf(::StandingInstructionsRepositoryImp) bind StandingInstructionsRepository::class + // Sync singleOf(::SyncCenterPayloadsRepositoryImp) bind SyncCenterPayloadsRepository::class singleOf(::SyncCentersDialogRepositoryImp) bind SyncCentersDialogRepository::class diff --git a/core/data/src/commonMain/kotlin/com/mifos/core/data/mappers/standingInstructions/StandingInstructionEntityMapper.kt b/core/data/src/commonMain/kotlin/com/mifos/core/data/mappers/standingInstructions/StandingInstructionEntityMapper.kt new file mode 100644 index 00000000000..f5e491f45a7 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/mifos/core/data/mappers/standingInstructions/StandingInstructionEntityMapper.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.data.mappers.standingInstructions + +import com.mifos.core.model.objects.standingInstructions.StandingInstruction +import com.mifos.core.model.objects.standingInstructions.StandingInstructionAccount +import com.mifos.core.model.objects.standingInstructions.StandingInstructionClient +import com.mifos.core.network.dto.standingInstruction.StandingInstructionDto +import com.mifos.room.entities.standingInstructions.StandingInstructionAccountEntity +import com.mifos.room.entities.standingInstructions.StandingInstructionClientEntity +import com.mifos.room.entities.standingInstructions.StandingInstructionEntity + +fun StandingInstructionDto.toEntity(): StandingInstructionEntity { + return StandingInstructionEntity( + id = this.id, + amount = this.amount, + validFrom = this.validFrom, + fromClient = this.fromClient?.let { StandingInstructionClientEntity(it.id, it.displayName) }, + toClient = this.toClient?.let { StandingInstructionClientEntity(it.id, it.displayName) }, + fromAccount = this.fromAccount?.let { StandingInstructionAccountEntity(it.id, it.accountNo, it.productName) }, + toAccount = this.toAccount?.let { + StandingInstructionAccountEntity( + it.id, + it.accountNo, + it.productName, + ) + }, + ) +} + +fun StandingInstructionEntity.toDomain(): StandingInstruction { + return StandingInstruction( + id = this.id, + amount = this.amount, + validFrom = this.validFrom, + fromClient = this.fromClient?.toDomain(), + toClient = this.toClient?.toDomain(), + fromAccount = this.fromAccount?.toDomain(), + toAccount = this.toAccount?.toDomain(), + ) +} + +fun StandingInstructionClientEntity.toDomain(): StandingInstructionClient { + return StandingInstructionClient( + id = this.id, + displayName = this.displayName, + ) +} + +fun StandingInstructionAccountEntity.toDomain(): StandingInstructionAccount { + return StandingInstructionAccount( + id = this.id, + accountNo = this.accountNo, + productName = this.productName, + ) +} diff --git a/core/data/src/commonMain/kotlin/com/mifos/core/data/mappers/standingInstructions/StandingInstructionsDtoMapper.kt b/core/data/src/commonMain/kotlin/com/mifos/core/data/mappers/standingInstructions/StandingInstructionsDtoMapper.kt new file mode 100644 index 00000000000..5d985228549 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/mifos/core/data/mappers/standingInstructions/StandingInstructionsDtoMapper.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.data.mappers.standingInstructions + +import com.mifos.core.model.objects.standingInstructions.StandingInstruction +import com.mifos.core.model.objects.standingInstructions.StandingInstructionAccount +import com.mifos.core.model.objects.standingInstructions.StandingInstructionClient +import com.mifos.core.model.objects.standingInstructions.StandingInstructionUpdate +import com.mifos.core.network.dto.standingInstruction.ClientDto +import com.mifos.core.network.dto.standingInstruction.StandingInstructionAccountDto +import com.mifos.core.network.dto.standingInstruction.StandingInstructionDto +import com.mifos.core.network.dto.standingInstruction.UpdateStandingInstructionDto + +fun StandingInstructionDto.toDomain(): StandingInstruction { + return StandingInstruction( + id = this.id, + amount = this.amount, + validFrom = this.validFrom, + fromClient = this.fromClient?.toDomain(), + toClient = this.toClient?.toDomain(), + fromAccount = this.fromAccount?.toDomain(), + toAccount = this.toAccount?.toDomain(), + ) +} + +fun ClientDto.toDomain(): StandingInstructionClient { + return StandingInstructionClient( + id = this.id, + displayName = this.displayName, + ) +} + +fun StandingInstructionAccountDto.toDomain(): StandingInstructionAccount { + return StandingInstructionAccount( + id = this.id, + accountNo = this.accountNo, + productName = this.productName, + ) +} + +fun StandingInstructionUpdate.toDto(): UpdateStandingInstructionDto { + return UpdateStandingInstructionDto( + amount = this.amount, + validFrom = this.validFrom, + locale = this.locale, + dateFormat = this.dateFormat, + ) +} diff --git a/core/data/src/commonMain/kotlin/com/mifos/core/data/repository/StandingInstructionsRepository.kt b/core/data/src/commonMain/kotlin/com/mifos/core/data/repository/StandingInstructionsRepository.kt new file mode 100644 index 00000000000..f6cba766011 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/mifos/core/data/repository/StandingInstructionsRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.data.repository + +import com.mifos.core.common.utils.DataState +import com.mifos.core.common.utils.Page +import com.mifos.core.model.objects.standingInstructions.StandingInstruction +import com.mifos.core.model.objects.standingInstructions.StandingInstructionUpdate +import kotlinx.coroutines.flow.Flow + +/** + * Created by Shlok Sharma(techsavvy185) on 19/06/2026. + */ +interface StandingInstructionsRepository { + fun getStandingInstructionList( + clientId: Long, + clientName: String, + fromAccountType: Int, + fromAccountId: Long, + limit: Int, + offset: Int, + ): Flow>> + + suspend fun updateStandingInstruction( + standingInstructionId: Long, + update: StandingInstructionUpdate, + ): DataState + + suspend fun deleteStandingInstruction( + standingInstructionId: Long, + ): DataState +} diff --git a/core/data/src/commonMain/kotlin/com/mifos/core/data/repositoryImp/StandingInstructionsRepositoryImp.kt b/core/data/src/commonMain/kotlin/com/mifos/core/data/repositoryImp/StandingInstructionsRepositoryImp.kt new file mode 100644 index 00000000000..51c9027d3f6 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/mifos/core/data/repositoryImp/StandingInstructionsRepositoryImp.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.data.repositoryImp + +import com.mifos.core.common.utils.DataState +import com.mifos.core.common.utils.Page +import com.mifos.core.common.utils.asDataStateFlow +import com.mifos.core.data.mappers.standingInstructions.toDomain +import com.mifos.core.data.mappers.standingInstructions.toDto +import com.mifos.core.data.repository.StandingInstructionsRepository +import com.mifos.core.data.util.NetworkMonitor +import com.mifos.core.data.util.runAsDataState +import com.mifos.core.data.util.withNetworkCheck +import com.mifos.core.model.objects.standingInstructions.StandingInstruction +import com.mifos.core.model.objects.standingInstructions.StandingInstructionUpdate +import com.mifos.core.network.datamanager.DataManagerStandingInstructions +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import template.core.base.common.manager.DispatcherManager + +class StandingInstructionsRepositoryImp( + private val dataManagerStandingInstructions: DataManagerStandingInstructions, + private val networkMonitor: NetworkMonitor, + private val dispatcher: DispatcherManager, +) : StandingInstructionsRepository { + + override fun getStandingInstructionList( + clientId: Long, + clientName: String, + fromAccountType: Int, + fromAccountId: Long, + limit: Int, + offset: Int, + ): Flow>> { + return networkMonitor.withNetworkCheck( + dataManagerStandingInstructions + .retrieveListStandingInstructions( + clientId = clientId, + clientName = clientName, + fromAccountType = fromAccountType, + fromAccountId = fromAccountId, + limit = limit, + offset = offset, + ) + .map { pageDto -> + Page( + totalFilteredRecords = pageDto.totalFilteredRecords, + pageItems = pageDto.pageItems.map { it.toDomain() }, + ) + } + .asDataStateFlow(), + ).flowOn(dispatcher.io) + } + + override suspend fun updateStandingInstruction( + standingInstructionId: Long, + update: StandingInstructionUpdate, + ): DataState { + return runAsDataState( + networkMonitor, + dispatcher.io, + ) { + dataManagerStandingInstructions.updateStandingInstruction( + standingInstructionId, + update.toDto(), + ) + } + } + + override suspend fun deleteStandingInstruction( + standingInstructionId: Long, + ): DataState { + return runAsDataState( + networkMonitor, + dispatcher.io, + ) { + dataManagerStandingInstructions.deleteStandingInstruction( + standingInstructionId, + ) + } + } +} diff --git a/core/database/src/commonMain/kotlin/com/mifos/room/basemodel/APIEndPoint.kt b/core/database/src/commonMain/kotlin/com/mifos/room/basemodel/APIEndPoint.kt index 6f13f07d98c..3575b4a66fa 100644 --- a/core/database/src/commonMain/kotlin/com/mifos/room/basemodel/APIEndPoint.kt +++ b/core/database/src/commonMain/kotlin/com/mifos/room/basemodel/APIEndPoint.kt @@ -45,4 +45,5 @@ object APIEndPoint { const val FIXED_DEPOSIT = "fixeddepositaccounts" const val ACCOUNT_TRANSFERS = "accounttransfers" const val RESCHEDULE_LOANS = "rescheduleloans" + const val STANDING_INSTRUCTIONS = "standinginstructions" } diff --git a/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionAccountEntity.kt b/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionAccountEntity.kt new file mode 100644 index 00000000000..d83fd714fa0 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionAccountEntity.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.room.entities.standingInstructions + +data class StandingInstructionAccountEntity( + val id: Int? = null, + val accountNo: String? = null, + val productName: String? = null, +) diff --git a/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionClientEntity.kt b/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionClientEntity.kt new file mode 100644 index 00000000000..65827b45983 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionClientEntity.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.room.entities.standingInstructions + +data class StandingInstructionClientEntity( + val id: Int? = null, + val displayName: String? = null, +) diff --git a/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionEntity.kt b/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionEntity.kt new file mode 100644 index 00000000000..4f54b298771 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/mifos/room/entities/standingInstructions/StandingInstructionEntity.kt @@ -0,0 +1,38 @@ +/* + * Copyright 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.room.entities.standingInstructions + +import template.core.base.database.Entity +import template.core.base.database.PrimaryKey + +@Entity( + tableName = "StandingInstruction", + indices = [], + inheritSuperIndices = false, + primaryKeys = [], + ignoredColumns = [], + foreignKeys = [], +) +data class StandingInstructionEntity( + @PrimaryKey(autoGenerate = false) + val id: Int, + + val amount: Double? = null, + + val validFrom: String? = null, + + val fromClient: StandingInstructionClientEntity? = null, + + val toClient: StandingInstructionClientEntity? = null, + + val fromAccount: StandingInstructionAccountEntity? = null, + + val toAccount: StandingInstructionAccountEntity? = null, +) diff --git a/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstruction.kt b/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstruction.kt new file mode 100644 index 00000000000..1974d461bc3 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstruction.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.model.objects.standingInstructions + +import kotlinx.serialization.Serializable + +@Serializable +data class StandingInstruction( + val id: Int, + val amount: Double? = null, + val validFrom: String? = null, + val fromClient: StandingInstructionClient? = null, + val toClient: StandingInstructionClient? = null, + val fromAccount: StandingInstructionAccount? = null, + val toAccount: StandingInstructionAccount? = null, +) diff --git a/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionAccount.kt b/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionAccount.kt new file mode 100644 index 00000000000..cf697f7d7c8 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionAccount.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.model.objects.standingInstructions + +import kotlinx.serialization.Serializable + +@Serializable +data class StandingInstructionAccount( + val id: Int? = null, + val accountNo: String? = null, + val productName: String? = null, +) diff --git a/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionClient.kt b/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionClient.kt new file mode 100644 index 00000000000..4954834c30f --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionClient.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.model.objects.standingInstructions + +import kotlinx.serialization.Serializable + +@Serializable +data class StandingInstructionClient( + val id: Int? = null, + val displayName: String? = null, +) diff --git a/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionUpdate.kt b/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionUpdate.kt new file mode 100644 index 00000000000..84f3a7d1061 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/mifos/core/model/objects/standingInstructions/StandingInstructionUpdate.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.model.objects.standingInstructions + +data class StandingInstructionUpdate( + val amount: String? = null, + val validFrom: String? = null, + val locale: String? = null, + val dateFormat: String? = null, +) diff --git a/core/network/src/commonMain/kotlin/com/mifos/core/network/BaseApiManager.kt b/core/network/src/commonMain/kotlin/com/mifos/core/network/BaseApiManager.kt index a66108fa756..01979920ae3 100644 --- a/core/network/src/commonMain/kotlin/com/mifos/core/network/BaseApiManager.kt +++ b/core/network/src/commonMain/kotlin/com/mifos/core/network/BaseApiManager.kt @@ -44,6 +44,7 @@ import com.mifos.core.network.services.SavingsAccountService import com.mifos.core.network.services.SearchService import com.mifos.core.network.services.ShareAccountService import com.mifos.core.network.services.StaffService +import com.mifos.core.network.services.StandingInstructionService import com.mifos.core.network.services.SurveyService import com.mifos.core.network.services.createCenterService import com.mifos.core.network.services.createChargeService @@ -64,6 +65,7 @@ import com.mifos.core.network.services.createSavingsAccountService import com.mifos.core.network.services.createSearchService import com.mifos.core.network.services.createShareAccountService import com.mifos.core.network.services.createStaffService +import com.mifos.core.network.services.createStandingInstructionService import com.mifos.core.network.services.createSurveyService import de.jensklingenberg.ktorfit.Ktorfit @@ -93,6 +95,7 @@ class BaseApiManager( val noteService: NoteService = ktorfit.createNoteService() val runReportsService: RunReportsService = ktorfit.createRunReportsService() val shareAccountService: ShareAccountService = ktorfit.createShareAccountService() + val standingInstructionService: StandingInstructionService = ktorfit.createStandingInstructionService() // sdk apis val clientIdentifiersApi: ClientIdentifierApi = ktorfit.createClientIdentifierApi() diff --git a/core/network/src/commonMain/kotlin/com/mifos/core/network/datamanager/DataManagerStandingInstructions.kt b/core/network/src/commonMain/kotlin/com/mifos/core/network/datamanager/DataManagerStandingInstructions.kt new file mode 100644 index 00000000000..b7156378c24 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/mifos/core/network/datamanager/DataManagerStandingInstructions.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.network.datamanager + +import com.mifos.core.model.objects.clients.Page +import com.mifos.core.network.BaseApiManager +import com.mifos.core.network.dto.standingInstruction.StandingInstructionDto +import com.mifos.core.network.dto.standingInstruction.StandingInstructionUpdateResponseDto +import com.mifos.core.network.dto.standingInstruction.UpdateStandingInstructionDto +import kotlinx.coroutines.flow.Flow + +class DataManagerStandingInstructions( + private val mBaseApiManager: BaseApiManager, +) { + fun retrieveListStandingInstructions( + clientId: Long, + clientName: String, + fromAccountType: Int, + fromAccountId: Long, + limit: Int, + offset: Int, + locale: String = "en", + dateFormat: String = "dd MMMM yyyy", + ): Flow> { + return mBaseApiManager.standingInstructionService.getStandingInstructions( + clientId = clientId, + clientName = clientName, + fromAccountType = fromAccountType, + fromAccountId = fromAccountId, + locale = locale, + dateFormat = dateFormat, + limit = limit, + offset = offset, + ) + } + + suspend fun updateStandingInstruction( + standingInstructionId: Long, + updateStandingInstructionDto: UpdateStandingInstructionDto, + ): StandingInstructionUpdateResponseDto { + return mBaseApiManager.standingInstructionService.updateStandingInstruction( + standingInstructionId = standingInstructionId, + updateStandingInstructionDto = updateStandingInstructionDto, + ) + } + + suspend fun deleteStandingInstruction( + standingInstructionId: Long, + ): StandingInstructionUpdateResponseDto { + return mBaseApiManager.standingInstructionService.deleteStandingInstruction( + standingInstructionId = standingInstructionId, + ) + } +} diff --git a/core/network/src/commonMain/kotlin/com/mifos/core/network/di/DataMangerModule.kt b/core/network/src/commonMain/kotlin/com/mifos/core/network/di/DataMangerModule.kt index e1fbafe91f4..aa65fe74e9e 100644 --- a/core/network/src/commonMain/kotlin/com/mifos/core/network/di/DataMangerModule.kt +++ b/core/network/src/commonMain/kotlin/com/mifos/core/network/di/DataMangerModule.kt @@ -31,6 +31,7 @@ import com.mifos.core.network.datamanager.DataManagerSavings import com.mifos.core.network.datamanager.DataManagerSearch import com.mifos.core.network.datamanager.DataManagerShare import com.mifos.core.network.datamanager.DataManagerStaff +import com.mifos.core.network.datamanager.DataManagerStandingInstructions import com.mifos.core.network.datamanager.DataManagerSurveys import org.koin.dsl.module @@ -57,4 +58,5 @@ val DataManagerModule = module { single { DataManagerRecurringAccount(get()) } single { DataManagerShare(get()) } single { DataManagerFixedDeposit(get()) } + single { DataManagerStandingInstructions(get()) } } diff --git a/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/StandingInstructionDto.kt b/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/StandingInstructionDto.kt new file mode 100644 index 00000000000..4ae3467fc43 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/StandingInstructionDto.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.network.dto.standingInstruction + +import kotlinx.serialization.Serializable + +@Serializable +data class StandingInstructionDto( + val id: Int, + val accountDetailId: Int? = null, + val name: String? = null, + val amount: Double? = null, + val validFrom: String? = null, + val recurrenceInterval: Int? = null, + val recurrenceOnMonthDay: String? = null, + + val instructionType: CodeDescriptionDto? = null, + val priority: CodeDescriptionDto? = null, + val recurrenceFrequency: CodeDescriptionDto? = null, + val recurrenceType: CodeDescriptionDto? = null, + val status: CodeDescriptionDto? = null, + val transferType: CodeDescriptionDto? = null, + + // From + val fromOffice: OfficeDto? = null, + val fromClient: ClientDto? = null, + val fromAccountType: CodeDescriptionDto? = null, + val fromAccount: StandingInstructionAccountDto? = null, + + // To + val toOffice: OfficeDto? = null, + val toClient: ClientDto? = null, + val toAccountType: CodeDescriptionDto? = null, + val toAccount: StandingInstructionAccountDto? = null, +) + +// --- Sub DTOs --- + +@Serializable +data class CodeDescriptionDto( + val id: Int? = null, + val code: String? = null, + val description: String? = null, +) + +@Serializable +data class OfficeDto( + val id: Int? = null, + val name: String? = null, +) + +@Serializable +data class ClientDto( + val id: Int? = null, + val displayName: String? = null, + val officeId: Int? = null, + val officeName: String? = null, +) + +@Serializable +data class StandingInstructionAccountDto( + val id: Int? = null, + val accountNo: String? = null, + val productId: Int? = null, + val productName: String? = null, +) diff --git a/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/StandingInstructionUpdateResponseDto.kt b/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/StandingInstructionUpdateResponseDto.kt new file mode 100644 index 00000000000..27b71ddc858 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/StandingInstructionUpdateResponseDto.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.network.dto.standingInstruction + +import kotlinx.serialization.Serializable + +@Serializable +data class StandingInstructionUpdateResponseDto( + val resourceId: Int? = null, + val changes: StandingInstructionChangesDto? = null, +) + +@Serializable +data class StandingInstructionChangesDto( + val amount: Double? = null, + val dateFormat: String? = null, + val instructionType: String? = null, + val locale: String? = null, + val monthDayFormat: String? = null, + val name: String? = null, + val priority: String? = null, + val recurrenceFrequency: String? = null, + val recurrenceInterval: Int? = null, + val recurrenceOnMonthDay: String? = null, + val recurrenceType: String? = null, + val status: String? = null, + val validFrom: String? = null, + val validTill: String? = null, +) diff --git a/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/UpdateStandingInstructionDto.kt b/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/UpdateStandingInstructionDto.kt new file mode 100644 index 00000000000..ef96ddac9c5 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/mifos/core/network/dto/standingInstruction/UpdateStandingInstructionDto.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.network.dto.standingInstruction + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateStandingInstructionDto( + val amount: String? = null, + val dateFormat: String? = null, + val instructionType: String? = null, + val locale: String? = null, + val monthDayFormat: String? = null, + val name: String? = null, + val priority: String? = null, + val recurrenceFrequency: String? = null, + val recurrenceInterval: String? = null, + val recurrenceOnMonthDay: String? = null, + val recurrenceType: String? = null, + val status: String? = null, + val validFrom: String? = null, + val validTill: String? = null, +) diff --git a/core/network/src/commonMain/kotlin/com/mifos/core/network/services/StandingInstructionService.kt b/core/network/src/commonMain/kotlin/com/mifos/core/network/services/StandingInstructionService.kt new file mode 100644 index 00000000000..134dfb3a314 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/mifos/core/network/services/StandingInstructionService.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.core.network.services + +import com.mifos.core.model.objects.clients.Page +import com.mifos.core.network.dto.standingInstruction.StandingInstructionDto +import com.mifos.core.network.dto.standingInstruction.StandingInstructionUpdateResponseDto +import com.mifos.core.network.dto.standingInstruction.UpdateStandingInstructionDto +import com.mifos.room.basemodel.APIEndPoint +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query +import kotlinx.coroutines.flow.Flow + +interface StandingInstructionService { + /** + * Retrieve a list of standing instructions based on client and account details. + * + * @param clientId The ID of the client. + * @param clientName The name of the client. + * @param fromAccountId The ID of the source account. + * @param fromAccountType The type of the source account (e.g., 1 for Savings, 2 for Loans). + * @param locale The locale for formatting (default: "en"). + * @param dateFormat The date format for parsing dates (default: "dd MMMM yyyy"). + * @param limit The maximum number of results to return. + * @param offset The number of results to skip. + * @return A [Flow] of [Page] containing [StandingInstructionDto]. + */ + @GET(APIEndPoint.STANDING_INSTRUCTIONS) + fun getStandingInstructions( + @Query("clientId") clientId: Long, + @Query("clientName") clientName: String, + @Query("fromAccountId") fromAccountId: Long, + @Query("fromAccountType") fromAccountType: Int, + @Query("locale") locale: String = "en", + @Query("dateFormat") dateFormat: String = "dd MMMM yyyy", + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): Flow> + + /** + * Update an existing standing instruction. + * + * @param standingInstructionId The unique ID of the standing instruction to update. + * @param command The command to execute (default: "update"). + * @param updateStandingInstructionDto The data to be updated. + * @return A [StandingInstructionUpdateResponseDto] containing the result of the update. + */ + @PUT("${APIEndPoint.STANDING_INSTRUCTIONS}/{standingInstructionId}") + suspend fun updateStandingInstruction( + @Path("standingInstructionId") standingInstructionId: Long, + @Query("command") command: String = "update", + @Body updateStandingInstructionDto: UpdateStandingInstructionDto, + ): StandingInstructionUpdateResponseDto + + /** + * Delete (disable) an existing standing instruction. + * + * @param standingInstructionId The unique ID of the standing instruction to delete. + * @param command The command to execute (default: "delete"). + * @return A [StandingInstructionUpdateResponseDto] containing the result of the deletion. + */ + @PUT("${APIEndPoint.STANDING_INSTRUCTIONS}/{standingInstructionId}") + suspend fun deleteStandingInstruction( + @Path("standingInstructionId") standingInstructionId: Long, + @Query("command") command: String = "delete", + ): StandingInstructionUpdateResponseDto +} diff --git a/feature/client/build.gradle.kts b/feature/client/build.gradle.kts index 5c447598e76..2dc330fd12b 100644 --- a/feature/client/build.gradle.kts +++ b/feature/client/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { implementation(projects.feature.document) implementation(projects.feature.recurringDeposit) implementation(projects.feature.groups) + implementation(projects.feature.standingInstruction) implementation(compose.ui) implementation(compose.material3) diff --git a/feature/client/src/commonMain/kotlin/com/mifos/feature/client/navigation/ClientNavigation.kt b/feature/client/src/commonMain/kotlin/com/mifos/feature/client/navigation/ClientNavigation.kt index 3ba470126db..82dff2ccaa5 100644 --- a/feature/client/src/commonMain/kotlin/com/mifos/feature/client/navigation/ClientNavigation.kt +++ b/feature/client/src/commonMain/kotlin/com/mifos/feature/client/navigation/ClientNavigation.kt @@ -111,6 +111,8 @@ import com.mifos.feature.savings.navigation.navigateToSavingsAccountApproval import com.mifos.feature.savings.navigation.navigateToSavingsAccountSummaryScreen import com.mifos.feature.savings.navigation.savingsDestination import com.mifos.feature.savings.savingsAccountv2.navigateToSavingsAccountRoute +import com.mifos.feature.standingInstructions.navigation.standingInstructionsDestination +import com.mifos.feature.standingInstructions.viewStandingInstructions.navigateToViewStandingInstructionsScreen import com.mifos.room.entities.accounts.savings.SavingAccountDepositTypeEntity import com.mifos.room.entities.noncore.DataTableEntity import com.mifos.room.entities.survey.SurveyEntity @@ -401,6 +403,11 @@ fun NavGraphBuilder.clientNavGraph( loadMoreSavingsAccountInfo = navController::navigateToDataTable, loadDocuments = navController::navigateToDocumentListScreen, onFinish = navController::popBackStack, + onViewStandingInstructions = navController::navigateToViewStandingInstructionsScreen, + ) + + standingInstructionsDestination( + navController = navController, ) createShareAccountDestination( diff --git a/feature/savings/build.gradle.kts b/feature/savings/build.gradle.kts index 54f544c5ed8..044dc4ff176 100644 --- a/feature/savings/build.gradle.kts +++ b/feature/savings/build.gradle.kts @@ -25,6 +25,7 @@ kotlin{ implementation(compose.components.uiToolingPreview) implementation(compose.ui) implementation(libs.kotlinx.serialization.json) + implementation(projects.feature.standingInstruction) } } } diff --git a/feature/savings/src/commonMain/composeResources/values-es/feature_savings_strings.xml b/feature/savings/src/commonMain/composeResources/values-es/feature_savings_strings.xml index 9a453f1d883..bf0ff44a51b 100644 --- a/feature/savings/src/commonMain/composeResources/values-es/feature_savings_strings.xml +++ b/feature/savings/src/commonMain/composeResources/values-es/feature_savings_strings.xml @@ -10,6 +10,7 @@ --> Más información de la cuenta de ahorro + Ver instrucciones permanentes Resumen de Cuenta de Ahorro Documentos Nombre del Cliente diff --git a/feature/savings/src/commonMain/composeResources/values/feature_savings_strings.xml b/feature/savings/src/commonMain/composeResources/values/feature_savings_strings.xml index d0d8541343c..19f55ab83a1 100644 --- a/feature/savings/src/commonMain/composeResources/values/feature_savings_strings.xml +++ b/feature/savings/src/commonMain/composeResources/values/feature_savings_strings.xml @@ -10,6 +10,7 @@ --> More savings account info + View Standing Instructions Savings Account Summary Documents Client Name diff --git a/feature/savings/src/commonMain/kotlin/com/mifos/feature/savings/navigation/SavingsNavigation.kt b/feature/savings/src/commonMain/kotlin/com/mifos/feature/savings/navigation/SavingsNavigation.kt index e2ae675c5d5..b77e65b392d 100644 --- a/feature/savings/src/commonMain/kotlin/com/mifos/feature/savings/navigation/SavingsNavigation.kt +++ b/feature/savings/src/commonMain/kotlin/com/mifos/feature/savings/navigation/SavingsNavigation.kt @@ -31,6 +31,7 @@ fun NavGraphBuilder.savingsDestination( onFinish: () -> Unit, loadMoreSavingsAccountInfo: (String, Int) -> Unit, loadDocuments: (Int, String) -> Unit, + onViewStandingInstructions: (clientId: Long, clientName: String, accountId: Long, accountType: Int, currencyCode: String) -> Unit, ) { savingsSummaryScreen( onBackPressed = navController::popBackStack, @@ -65,6 +66,7 @@ fun NavGraphBuilder.savingsDestination( accountNumber, ) }, + onViewStandingInstructions = onViewStandingInstructions, ) addSavingsAccountScreen { @@ -108,6 +110,7 @@ fun NavGraphBuilder.savingsSummaryScreen( onWithdrawButtonClicked: (SavingsAccountWithAssociationsEntity, SavingAccountDepositTypeEntity?) -> Unit, approveSavings: (savingsAccountType: SavingAccountDepositTypeEntity?, savingsAccountNumber: Int) -> Unit, activateSavings: (savingsAccountType: SavingAccountDepositTypeEntity?, savingsAccountNumber: Int) -> Unit, + onViewStandingInstructions: (clientId: Long, clientName: String, accountId: Long, accountType: Int, currencyCode: String) -> Unit, ) { composable( route = SavingsScreens.SavingsAccountSummary.route, @@ -123,6 +126,7 @@ fun NavGraphBuilder.savingsSummaryScreen( onWithdrawButtonClicked = onWithdrawButtonClicked, approveSavings = approveSavings, activateSavings = activateSavings, + onViewStandingInstructions = onViewStandingInstructions, ) } } diff --git a/feature/savings/src/commonMain/kotlin/com/mifos/feature/savings/savingsAccountSummary/SavingsAccountSummaryScreen.kt b/feature/savings/src/commonMain/kotlin/com/mifos/feature/savings/savingsAccountSummary/SavingsAccountSummaryScreen.kt index b82d9d932f8..406650b06e0 100644 --- a/feature/savings/src/commonMain/kotlin/com/mifos/feature/savings/savingsAccountSummary/SavingsAccountSummaryScreen.kt +++ b/feature/savings/src/commonMain/kotlin/com/mifos/feature/savings/savingsAccountSummary/SavingsAccountSummaryScreen.kt @@ -33,6 +33,7 @@ import androidclient.feature.savings.generated.resources.feature_savings_total_w import androidclient.feature.savings.generated.resources.feature_savings_transaction_id import androidclient.feature.savings.generated.resources.feature_savings_transaction_type import androidclient.feature.savings.generated.resources.feature_savings_transactions +import androidclient.feature.savings.generated.resources.feature_savings_view_standing_instructions import androidclient.feature.savings.generated.resources.feature_savings_withdrawal import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -70,6 +71,7 @@ import androidx.compose.ui.graphics.Color.Companion.DarkGray import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mifos.core.common.utils.Constants import com.mifos.core.common.utils.DateHelper import com.mifos.core.designsystem.component.MifosCard import com.mifos.core.designsystem.component.MifosMenuDropDownItem @@ -108,6 +110,7 @@ internal fun SavingsAccountSummaryScreen( onWithdrawButtonClicked: (savings: SavingsAccountWithAssociationsEntity, type: SavingAccountDepositTypeEntity?) -> Unit, approveSavings: (type: SavingAccountDepositTypeEntity?, accountNumber: Int) -> Unit, activateSavings: (type: SavingAccountDepositTypeEntity?, accountNumber: Int) -> Unit, + onViewStandingInstructions: (clientId: Long, clientName: String, accountId: Long, accountType: Int, currencyCode: String) -> Unit, viewmodel: SavingsAccountSummaryViewModel = koinViewModel(), ) { val uiState by viewmodel.savingsAccountSummaryUiState.collectAsStateWithLifecycle() @@ -139,6 +142,18 @@ internal fun SavingsAccountSummaryScreen( activateSavings = { activateSavings.invoke(savingsAccountType, accountId) }, + onViewStandingInstructions = { + val currentState = uiState + if (currentState is SavingsAccountSummaryUiState.ShowSavingAccount) { + onViewStandingInstructions.invoke( + currentState.savingsAccountWithAssociations.clientId?.toLong() ?: 0L, + currentState.savingsAccountWithAssociations.clientName ?: "", + accountId.toLong(), + Constants.SAVINGS_ACCOUNT_VAL, + currentState.savingsAccountWithAssociations.currency?.code ?: "", + ) + } + }, ) } @@ -148,6 +163,7 @@ internal fun SavingsAccountSummaryScreen( navigateBack: () -> Unit, onRetry: () -> Unit, loadMoreSavingsAccountInfo: () -> Unit, + onViewStandingInstructions: () -> Unit, loadDocuments: () -> Unit, onDepositButtonClicked: (savings: SavingsAccountWithAssociationsEntity) -> Unit, onWithdrawButtonClicked: (savings: SavingsAccountWithAssociationsEntity) -> Unit, @@ -188,6 +204,13 @@ internal fun SavingsAccountSummaryScreen( loadDocuments.invoke() }, ) + MifosMenuDropDownItem( + option = stringResource(Res.string.feature_savings_view_standing_instructions), + onClick = { + showDropdown = false + onViewStandingInstructions.invoke() + }, + ) } } }, @@ -676,6 +699,7 @@ private fun PreviewSavingsAccountSummaryScreen( onDepositButtonClicked = { _ -> }, onWithdrawButtonClicked = { _ -> }, approveSavings = { }, + onViewStandingInstructions = {}, ) { } } diff --git a/feature/standing-instruction/.gitignore b/feature/standing-instruction/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/feature/standing-instruction/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/standing-instruction/build.gradle.kts b/feature/standing-instruction/build.gradle.kts new file mode 100644 index 00000000000..9bbf6c9545f --- /dev/null +++ b/feature/standing-instruction/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +plugins { + alias(libs.plugins.cmp.feature.convention) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.mifos.feature.standing_instruction" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(compose.material3) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(compose.ui) + implementation(projects.core.common) + implementation(projects.core.domain) + implementation(projects.core.model) + implementation(libs.kotlinx.serialization.json) + } + } +} + +dependencies { + debugImplementation(compose.uiTooling) +} diff --git a/feature/standing-instruction/src/commonMain/composeResources/values/strings.xml b/feature/standing-instruction/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000000..4c7d92576a4 --- /dev/null +++ b/feature/standing-instruction/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,32 @@ + + + + Client ID + Client + From Account + To Account + Amount + Beneficiary + Validity + Standing Instructions + No Standing Instructions Found + Edit Standing Instruction + Delete Standing Instruction + Are you sure you want to delete this Standing Instruction? + Amount + Valid From + Cancel + Confirm + Ok + Edit + Delete + Select Action + \ No newline at end of file diff --git a/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/di/StandingInstructionsModule.kt b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/di/StandingInstructionsModule.kt new file mode 100644 index 00000000000..7db926cfb50 --- /dev/null +++ b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/di/StandingInstructionsModule.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.feature.standingInstruction.di + +import com.mifos.feature.standingInstruction.viewStandingInstructions.ViewStandingInstructionsViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val StandingInstructionsModule = module { + viewModelOf(::ViewStandingInstructionsViewModel) +} diff --git a/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/navigation/StandingInstructionsNavigation.kt b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/navigation/StandingInstructionsNavigation.kt new file mode 100644 index 00000000000..a43828f08be --- /dev/null +++ b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/navigation/StandingInstructionsNavigation.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.feature.standingInstructions.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import com.mifos.feature.standingInstructions.viewStandingInstructions.viewStandingInstructionsScreen + +fun NavGraphBuilder.standingInstructionsDestination( + navController: NavController, +) { + viewStandingInstructionsScreen( + onBackPressed = { + navController.popBackStack() + }, + ) +} diff --git a/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsRoute.kt b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsRoute.kt new file mode 100644 index 00000000000..a43c10aa871 --- /dev/null +++ b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsRoute.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.feature.standingInstructions.viewStandingInstructions + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.mifos.feature.standingInstruction.viewStandingInstructions.ViewStandingInstructionsScreen +import kotlinx.serialization.Serializable + +@Serializable +data class ViewStandingInstructionsScreenRoute( + val clientId: Long, + val clientName: String, + val fromAccountId: Long, + val fromAccountType: Int, + val currencyCode: String, +) + +fun NavController.navigateToViewStandingInstructionsScreen( + clientId: Long, + clientName: String, + fromAccountId: Long, + fromAccountType: Int, + currencyCode: String, +) { + navigate( + ViewStandingInstructionsScreenRoute( + clientId = clientId, + clientName = clientName, + fromAccountId = fromAccountId, + fromAccountType = fromAccountType, + currencyCode = currencyCode, + ), + ) +} + +fun NavGraphBuilder.viewStandingInstructionsScreen( + onBackPressed: () -> Unit, +) { + composable { + ViewStandingInstructionsScreen( + navigateBack = onBackPressed, + ) + } +} diff --git a/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsScreen.kt b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsScreen.kt new file mode 100644 index 00000000000..24cfae31c83 --- /dev/null +++ b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsScreen.kt @@ -0,0 +1,435 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.feature.standingInstruction.viewStandingInstructions + +import androidclient.feature.standing_instruction.generated.resources.Res +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_amount +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_cancel +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_confirm +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_delete +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_delete_confirm_message +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_delete_instruction +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_edit +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_edit_instruction +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_no_standing_instructions_found_message +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_ok +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_standing_instructions +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_table_header_amount +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_table_header_beneficiary +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_table_header_client +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_table_header_client_id +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_table_header_from_account +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_table_header_to_account +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_table_header_validity +import androidclient.feature.standing_instruction.generated.resources.feature_standing_instructions_valid_from +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mifos.core.common.utils.ApiDateFormatter +import com.mifos.core.common.utils.DataState +import com.mifos.core.common.utils.DateFormatPattern +import com.mifos.core.designsystem.component.BasicDialogState +import com.mifos.core.designsystem.component.MifosBasicDialog +import com.mifos.core.designsystem.component.MifosDatePickerTextField +import com.mifos.core.designsystem.component.MifosMenuDropDownItem +import com.mifos.core.designsystem.component.MifosOutlinedTextField +import com.mifos.core.designsystem.component.MifosScaffold +import com.mifos.core.designsystem.component.MifosSweetError +import com.mifos.core.designsystem.component.MifosTableRow +import com.mifos.core.designsystem.component.MifosTextFieldConfig +import com.mifos.core.designsystem.icon.MifosIcons +import com.mifos.core.designsystem.theme.DesignToken +import com.mifos.core.designsystem.theme.MifosTypography +import com.mifos.core.ui.components.MifosEmptyCard +import com.mifos.core.ui.components.MifosProgressIndicator +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Clock + +@Composable +internal fun ViewStandingInstructionsScreen( + viewModel: ViewStandingInstructionsViewModel = koinViewModel(), + navigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.eventFlow.collect { event -> + when (event) { + ViewStandingInstructionsEvent.NavigateBack -> navigateBack() + } + } + } + + ViewStandingInstructionsContent( + state = state, + onAction = viewModel::trySendAction, + ) + + ViewStandingInstructionsDialogs( + dialogState = state.dialogState, + onAction = viewModel::trySendAction, + ) +} + +@Composable +internal fun ViewStandingInstructionsContent( + state: ViewStandingInstructionsState, + onAction: (ViewStandingInstructionsAction) -> Unit, +) { + val snackbarHostState = remember { + SnackbarHostState() + } + + MifosScaffold( + title = stringResource(Res.string.feature_standing_instructions_standing_instructions), + snackbarHostState = snackbarHostState, + onBackPressed = { onAction(ViewStandingInstructionsAction.OnNavigateBack) }, + actions = { + IconButton(onClick = { onAction(ViewStandingInstructionsAction.Retry) }) { + Icon( + imageVector = MifosIcons.Refresh, + contentDescription = null, + ) + } + }, + ) { + Box(modifier = Modifier.padding(it)) { + when (state.dataState) { + is DataState.Error -> { + MifosSweetError( + message = state.dataState.message, + onclick = { onAction(ViewStandingInstructionsAction.Retry) }, + ) + } + + is DataState.Success -> { + state.tableData?.let { data -> + ViewStandingInstructionsData( + tableData = data, + dialogState = state.dialogState, + onAction = onAction, + ) + } + } + + DataState.Loading -> { + MifosProgressIndicator() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ViewStandingInstructionsDialogs( + dialogState: ViewStandingInstructionsState.DialogState?, + onAction: (ViewStandingInstructionsAction) -> Unit, +) { + when (dialogState) { + is ViewStandingInstructionsState.DialogState.Error -> { + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + message = dialogState.message, + title = dialogState.title, + ), + onDismissRequest = { onAction(ViewStandingInstructionsAction.DismissErrorDialog) }, + ) + } + + is ViewStandingInstructionsState.DialogState.Options -> Unit + + is ViewStandingInstructionsState.DialogState.ConfirmDelete -> { + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + title = stringResource(Res.string.feature_standing_instructions_delete_instruction), + message = stringResource(Res.string.feature_standing_instructions_delete_confirm_message), + ), + onConfirm = { onAction(ViewStandingInstructionsAction.OnConfirmDelete(dialogState.rowData.id.toLong())) }, + onDismissRequest = { onAction(ViewStandingInstructionsAction.OnDismissDialog) }, + confirmText = stringResource(Res.string.feature_standing_instructions_delete), + dismissText = stringResource(Res.string.feature_standing_instructions_cancel), + ) + } + is ViewStandingInstructionsState.DialogState.Edit -> { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = Clock.System.now().toEpochMilliseconds(), + ) + MifosBasicDialog( + onConfirm = { + onAction( + ViewStandingInstructionsAction.OnConfirmEdit( + instructionId = dialogState.rowData.id.toLong(), + amount = dialogState.amount, + validFrom = dialogState.validFrom, + beneficiary = dialogState.beneficiary, + fromAccount = dialogState.fromAccount, + toAccount = dialogState.toAccount, + ), + ) + }, + onDismissRequest = { onAction(ViewStandingInstructionsAction.OnDismissDialog) }, + confirmText = stringResource(Res.string.feature_standing_instructions_confirm), + dismissText = stringResource(Res.string.feature_standing_instructions_cancel), + isConfirmEnabled = dialogState.amount.isNotEmpty() && dialogState.amountError == null, + title = stringResource(Res.string.feature_standing_instructions_edit_instruction), + content = { + Column { + if (dialogState.isDatePickerShown) { + DatePickerDialog( + onDismissRequest = { onAction(ViewStandingInstructionsAction.OnToggleDatePicker(false)) }, + confirmButton = { + TextButton( + onClick = { + onAction(ViewStandingInstructionsAction.OnToggleDatePicker(false)) + datePickerState.selectedDateMillis?.let { + val formattedDate = ApiDateFormatter.formatForApi(it, DateFormatPattern.ISO) + onAction(ViewStandingInstructionsAction.OnEditFieldChanged(EditField.VALID_FROM, formattedDate)) + } + }, + ) { Text(stringResource(Res.string.feature_standing_instructions_ok)) } + }, + dismissButton = { + TextButton( + onClick = { onAction(ViewStandingInstructionsAction.OnToggleDatePicker(false)) }, + ) { Text(stringResource(Res.string.feature_standing_instructions_cancel)) } + }, + ) { + DatePicker(state = datePickerState) + } + } + + MifosOutlinedTextField( + value = dialogState.amount, + onValueChange = { onAction(ViewStandingInstructionsAction.OnEditFieldChanged(EditField.AMOUNT, it)) }, + label = stringResource(Res.string.feature_standing_instructions_amount), + config = MifosTextFieldConfig( + isError = dialogState.amountError != null, + errorText = dialogState.amountError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + ), + ), + ) + + Spacer(modifier = Modifier.height(DesignToken.padding.medium)) + + MifosDatePickerTextField( + value = dialogState.validFrom, + label = stringResource(Res.string.feature_standing_instructions_valid_from), + openDatePicker = { onAction(ViewStandingInstructionsAction.OnToggleDatePicker(true)) }, + ) + } + }, + ) + } + is ViewStandingInstructionsState.DialogState.Loading -> { + MifosProgressIndicator() + } + null -> Unit + } +} + +@Composable +private fun ViewStandingInstructionsData( + tableData: StandingInstructionTableData, + dialogState: ViewStandingInstructionsState.DialogState?, + onAction: (ViewStandingInstructionsAction) -> Unit, +) { + val scrollState = rememberScrollState() + + val smallWidth = DesignToken.sizes.tableCellWidthSmall + val mediumWidth = DesignToken.sizes.tableCellWidthMedium + val largeWidth = DesignToken.sizes.tableCellWidthLarge + + val columnWidths = remember(smallWidth, mediumWidth, largeWidth) { + listOf( + smallWidth, + largeWidth, + mediumWidth, + largeWidth, + mediumWidth, + mediumWidth, + largeWidth, + ) + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(vertical = DesignToken.padding.large), + horizontalAlignment = Alignment.Start, + ) { + if (tableData.rows.isNotEmpty()) { + stickyHeader { + Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + ) { + StandingInstructionTableHeader(columnWidths) + } + } + items(tableData.rows) { row -> + Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + ) { + StandingInstructionRow( + row = row, + widths = columnWidths, + onClick = { onAction(ViewStandingInstructionsAction.OnRowClick(row)) }, + showDropdown = dialogState is ViewStandingInstructionsState.DialogState.Options && dialogState.rowData.id == row.id, + onDismissDropdown = { onAction(ViewStandingInstructionsAction.OnDismissDialog) }, + onEditClick = { onAction(ViewStandingInstructionsAction.OnEditClick(row)) }, + onDeleteClick = { onAction(ViewStandingInstructionsAction.OnDeleteClick(row)) }, + ) + } + } + } else { + item { + MifosEmptyCard( + msg = stringResource(Res.string.feature_standing_instructions_no_standing_instructions_found_message), + title = stringResource(Res.string.feature_standing_instructions_no_standing_instructions_found_message), + ) + } + } + } +} + +@Composable +private fun StandingInstructionTableHeader(widths: List) { + val headers = listOf( + stringResource(Res.string.feature_standing_instructions_table_header_client_id), + stringResource(Res.string.feature_standing_instructions_table_header_client), + stringResource(Res.string.feature_standing_instructions_table_header_from_account), + stringResource(Res.string.feature_standing_instructions_table_header_beneficiary), + stringResource(Res.string.feature_standing_instructions_table_header_to_account), + stringResource(Res.string.feature_standing_instructions_table_header_amount), + stringResource(Res.string.feature_standing_instructions_table_header_validity), + ) + + MifosTableRow( + cells = headers.map { header -> + { + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = DesignToken.padding.small, + horizontal = DesignToken.padding.extraSmall, + ), + ) { + Text( + text = header, + style = MifosTypography.labelMediumEmphasized, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + }, + widths = widths, + backgroundColor = MaterialTheme.colorScheme.primary, + edgeOffset = DesignToken.spacing.medium, + cornerShape = DesignToken.shapes.topMedium, + ) +} + +@Composable +private fun StandingInstructionRow( + row: StandingInstructionRowData, + widths: List, + onClick: () -> Unit, + showDropdown: Boolean, + onDismissDropdown: () -> Unit, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + val rowValues = listOf( + row.id.toString(), + row.client, + row.fromAccount, + row.beneficiary, + row.toAccount, + row.amount, + row.validity, + ) + + Box { + MifosTableRow( + cells = rowValues.map { value -> + { + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = DesignToken.padding.small, + horizontal = DesignToken.padding.extraSmall, + ), + ) { + Text(text = value, style = MifosTypography.labelMedium) + } + } + }, + widths = widths, + backgroundColor = Color.Transparent, + edgeOffset = DesignToken.spacing.medium, + onClick = onClick, + ) + + DropdownMenu( + expanded = showDropdown, + onDismissRequest = onDismissDropdown, + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + MifosMenuDropDownItem( + option = stringResource(Res.string.feature_standing_instructions_edit), + onClick = onEditClick, + ) + MifosMenuDropDownItem( + option = stringResource(Res.string.feature_standing_instructions_delete), + onClick = onDeleteClick, + ) + } + } +} diff --git a/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsViewModel.kt b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsViewModel.kt new file mode 100644 index 00000000000..7320465b315 --- /dev/null +++ b/feature/standing-instruction/src/commonMain/kotlin/com/mifos/feature/standingInstruction/viewStandingInstructions/ViewStandingInstructionsViewModel.kt @@ -0,0 +1,356 @@ +/* + * Copyright 2026 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 https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md + */ +package com.mifos.feature.standingInstruction.viewStandingInstructions + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.mifos.core.common.utils.ApiDateFormatter +import com.mifos.core.common.utils.CurrencyFormatter +import com.mifos.core.common.utils.DataState +import com.mifos.core.common.utils.DataState.Loading +import com.mifos.core.common.utils.DataState.Success +import com.mifos.core.common.utils.DateFormatPattern +import com.mifos.core.common.utils.Page +import com.mifos.core.data.repository.StandingInstructionsRepository +import com.mifos.core.model.objects.standingInstructions.StandingInstruction +import com.mifos.core.model.objects.standingInstructions.StandingInstructionUpdate +import com.mifos.core.ui.util.BaseViewModel +import com.mifos.feature.standingInstructions.viewStandingInstructions.ViewStandingInstructionsScreenRoute +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ViewStandingInstructionsViewModel( + private val repository: StandingInstructionsRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = run { + val route = savedStateHandle.toRoute() + ViewStandingInstructionsState( + fromAccountId = route.fromAccountId, + fromAccountType = route.fromAccountType, + clientId = route.clientId, + clientName = route.clientName, + currencyCode = route.currencyCode, + ) + }, +) { + + init { + viewModelScope.launch { + loadStandingInstructions() + } + } + + override fun handleAction(action: ViewStandingInstructionsAction) { + when (action) { + ViewStandingInstructionsAction.OnNavigateBack -> { + sendEvent(ViewStandingInstructionsEvent.NavigateBack) + } + + ViewStandingInstructionsAction.Retry -> { + retry() + } + + ViewStandingInstructionsAction.DismissErrorDialog -> { + mutableStateFlow.update { + it.copy(dialogState = null) + } + } + + is ViewStandingInstructionsAction.OnRowClick -> { + mutableStateFlow.update { + it.copy(dialogState = ViewStandingInstructionsState.DialogState.Options(action.rowData)) + } + } + + is ViewStandingInstructionsAction.OnDeleteClick -> { + mutableStateFlow.update { + it.copy(dialogState = ViewStandingInstructionsState.DialogState.ConfirmDelete(action.rowData)) + } + } + + is ViewStandingInstructionsAction.OnEditClick -> { + mutableStateFlow.update { + it.copy( + dialogState = ViewStandingInstructionsState.DialogState.Edit( + rowData = action.rowData, + amount = action.rowData.amount, + validFrom = action.rowData.validity, + beneficiary = action.rowData.beneficiary, + fromAccount = action.rowData.fromAccount, + toAccount = action.rowData.toAccount, + ), + ) + } + } + + is ViewStandingInstructionsAction.OnEditFieldChanged -> { + val currentEdit = state.dialogState as? ViewStandingInstructionsState.DialogState.Edit + if (currentEdit != null) { + mutableStateFlow.update { + val updatedEdit = when (action.field) { + EditField.AMOUNT -> { + val amountError = if (action.value.toDoubleOrNull() == null || action.value.toDouble() <= 0) { + "Invalid amount" + } else { + null + } + currentEdit.copy(amount = action.value, amountError = amountError) + } + EditField.VALID_FROM -> currentEdit.copy(validFrom = action.value) + EditField.BENEFICIARY -> currentEdit.copy(beneficiary = action.value) + EditField.FROM_ACCOUNT -> currentEdit.copy(fromAccount = action.value) + EditField.TO_ACCOUNT -> currentEdit.copy(toAccount = action.value) + } + it.copy(dialogState = updatedEdit) + } + } + } + + is ViewStandingInstructionsAction.OnToggleDatePicker -> { + val currentEdit = state.dialogState as? ViewStandingInstructionsState.DialogState.Edit + if (currentEdit != null) { + mutableStateFlow.update { + it.copy( + dialogState = currentEdit.copy( + isDatePickerShown = action.show, + ), + ) + } + } + } + + is ViewStandingInstructionsAction.OnConfirmDelete -> { + deleteStandingInstruction(action.instructionId) + } + + is ViewStandingInstructionsAction.OnConfirmEdit -> { + updateStandingInstruction(action.instructionId, action.amount, action.validFrom) + } + + ViewStandingInstructionsAction.OnDismissDialog -> { + mutableStateFlow.update { + it.copy(dialogState = null) + } + } + } + } + + private fun deleteStandingInstruction(instructionId: Long) { + mutableStateFlow.update { + it.copy(dialogState = ViewStandingInstructionsState.DialogState.Loading) + } + viewModelScope.launch { + val result = repository.deleteStandingInstruction(instructionId) + when (result) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = ViewStandingInstructionsState.DialogState.Error( + title = "Error deleting instruction", + message = result.message, + ), + ) + } + } + is DataState.Success -> { + mutableStateFlow.update { + it.copy(dialogState = null) + } + loadStandingInstructions() + } + DataState.Loading -> Unit + } + } + } + + private fun updateStandingInstruction(instructionId: Long, amount: String, validFrom: String) { + mutableStateFlow.update { + it.copy(dialogState = ViewStandingInstructionsState.DialogState.Loading) + } + viewModelScope.launch { + val update = StandingInstructionUpdate( + amount = amount, + validFrom = validFrom, + dateFormat = DateFormatPattern.ISO.pattern, + locale = ApiDateFormatter.LOCALE, + ) + val result = repository.updateStandingInstruction(instructionId, update) + when (result) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = ViewStandingInstructionsState.DialogState.Error( + title = "Error updating instruction", + message = result.message, + ), + ) + } + } + is DataState.Success -> { + mutableStateFlow.update { + it.copy(dialogState = null) + } + loadStandingInstructions() + } + DataState.Loading -> Unit + } + } + } + + private fun retry() { + viewModelScope.launch { + loadStandingInstructions() + } + } + + private fun loadStandingInstructions() { + mutableStateFlow.update { + it.copy(dataState = DataState.Loading) + } + + viewModelScope.launch { + repository.getStandingInstructionList( + clientId = state.clientId, + clientName = state.clientName, + fromAccountId = state.fromAccountId, + fromAccountType = state.fromAccountType, + limit = 14, + offset = 0, + ).collect { dataState -> + when (dataState) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + dataState = dataState, + ) + } + } + + Loading -> { + mutableStateFlow.update { + it.copy(dataState = DataState.Loading) + } + } + + is Success -> { + val tableData = mapToTableData(dataState.data) + mutableStateFlow.update { + it.copy( + tableData = tableData, + dataState = DataState.Success(dataState.data), + ) + } + } + } + } + } + } + + private fun mapToTableData(page: Page): StandingInstructionTableData { + val rows = page.pageItems.map { instruction -> + StandingInstructionRowData( + id = instruction.id, + client = instruction.fromClient?.displayName ?: "--", + fromAccount = instruction.fromAccount?.accountNo ?: "--", + beneficiary = instruction.toClient?.displayName ?: "--", + toAccount = instruction.toAccount?.accountNo ?: "--", + amount = if (state.currencyCode.isNotEmpty()) { + CurrencyFormatter.format( + balance = instruction.amount, + currencyCode = state.currencyCode, + maximumFractionDigits = 2, + ) + } else { + instruction.amount?.toString() ?: "--" + }, + validity = instruction.validFrom ?: "--", + ) + } + + return StandingInstructionTableData(rows = rows) + } +} + +data class ViewStandingInstructionsState( + val fromAccountId: Long, + val fromAccountType: Int, + val clientId: Long, + val clientName: String, + val currencyCode: String, + val dataState: DataState> = Loading, + val tableData: StandingInstructionTableData? = null, + val dialogState: DialogState? = null, +) { + sealed interface DialogState { + data class Error(val title: String, val message: String) : DialogState + data class Options(val rowData: StandingInstructionRowData) : DialogState + data class ConfirmDelete(val rowData: StandingInstructionRowData) : DialogState + data class Edit( + val rowData: StandingInstructionRowData, + val amount: String, + val validFrom: String, + val beneficiary: String, + val fromAccount: String, + val toAccount: String, + val amountError: String? = null, + val isDatePickerShown: Boolean = false, + ) : DialogState + data object Loading : DialogState + } +} + +data class StandingInstructionTableData( + val rows: List, +) + +data class StandingInstructionRowData( + val id: Int, + val client: String, + val fromAccount: String, + val beneficiary: String, + val toAccount: String, + val amount: String, + val validity: String, +) + +sealed interface ViewStandingInstructionsEvent { + data object NavigateBack : ViewStandingInstructionsEvent +} + +enum class EditField { + AMOUNT, + VALID_FROM, + BENEFICIARY, + FROM_ACCOUNT, + TO_ACCOUNT, +} + +sealed interface ViewStandingInstructionsAction { + data object OnNavigateBack : ViewStandingInstructionsAction + data object Retry : ViewStandingInstructionsAction + data object DismissErrorDialog : ViewStandingInstructionsAction + data class OnRowClick(val rowData: StandingInstructionRowData) : ViewStandingInstructionsAction + data class OnDeleteClick(val rowData: StandingInstructionRowData) : ViewStandingInstructionsAction + data class OnEditClick(val rowData: StandingInstructionRowData) : ViewStandingInstructionsAction + data class OnEditFieldChanged(val field: EditField, val value: String) : ViewStandingInstructionsAction + data class OnToggleDatePicker(val show: Boolean) : ViewStandingInstructionsAction + data class OnConfirmDelete(val instructionId: Long) : ViewStandingInstructionsAction + data class OnConfirmEdit( + val instructionId: Long, + val amount: String, + val validFrom: String, + val beneficiary: String, + val fromAccount: String, + val toAccount: String, + ) : ViewStandingInstructionsAction + data object OnDismissDialog : ViewStandingInstructionsAction +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e158fd9e051..7b477e406a6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -106,3 +106,4 @@ include(":feature:search") include(":feature:settings") include(":feature:passcode") include(":feature:search-record") +include(":feature:standing-instruction")