diff --git a/packages/server/src/common/dtos/BulkDelete.dto.ts b/packages/server/src/common/dtos/BulkDelete.dto.ts new file mode 100644 index 000000000..244475c8a --- /dev/null +++ b/packages/server/src/common/dtos/BulkDelete.dto.ts @@ -0,0 +1,55 @@ +import { IsArray, IsInt, ArrayNotEmpty, IsBoolean, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { parseBoolean } from '@/utils/parse-boolean'; + +export class BulkDeleteDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + @ApiProperty({ + description: 'Array of IDs to delete', + type: [Number], + example: [1, 2, 3], + }) + ids: number[]; + + @IsOptional() + @IsBoolean() + @Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false)) + @ApiPropertyOptional({ + description: 'When true, undeletable items will be skipped and only deletable ones will be removed.', + type: Boolean, + default: false, + }) + skipUndeletable?: boolean; +} + +export class ValidateBulkDeleteResponseDto { + @ApiProperty({ + description: 'Number of items that can be deleted', + example: 2, + }) + deletableCount: number; + + @ApiProperty({ + description: 'Number of items that cannot be deleted', + example: 1, + }) + nonDeletableCount: number; + + @ApiProperty({ + description: 'IDs of items that can be deleted', + type: [Number], + example: [1, 2], + }) + deletableIds: number[]; + + @ApiProperty({ + description: 'IDs of items that cannot be deleted', + type: [Number], + example: [3], + }) + nonDeletableIds: number[]; +} + diff --git a/packages/server/src/modules/Accounts/Accounts.controller.ts b/packages/server/src/modules/Accounts/Accounts.controller.ts index 31bc86158..d7d1f01ce 100644 --- a/packages/server/src/modules/Accounts/Accounts.controller.ts +++ b/packages/server/src/modules/Accounts/Accounts.controller.ts @@ -27,16 +27,56 @@ import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionRe import { GetAccountTransactionsQueryDto } from './dtos/GetAccountTransactionsQuery.dto'; import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('accounts') @ApiTags('Accounts') @ApiExtraModels(AccountResponseDto) @ApiExtraModels(AccountTypeResponseDto) @ApiExtraModels(GetAccountTransactionResponseDto) +@ApiExtraModels(ValidateBulkDeleteResponseDto) @ApiCommonHeaders() export class AccountsController { constructor(private readonly accountsApplication: AccountsApplication) { } + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which accounts can be deleted and returns counts of deletable and non-deletable accounts.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed. Returns counts and IDs of deletable and non-deletable accounts.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + async validateBulkDeleteAccounts( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.accountsApplication.validateBulkDeleteAccounts( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple accounts in bulk.' }) + @ApiResponse({ + status: 200, + description: 'The accounts have been successfully deleted.', + }) + async bulkDeleteAccounts( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.accountsApplication.bulkDeleteAccounts(bulkDeleteDto.ids, { + skipUndeletable: bulkDeleteDto.skipUndeletable ?? false, + }); + } + @Post() @ApiOperation({ summary: 'Create an account' }) @ApiResponse({ diff --git a/packages/server/src/modules/Accounts/Accounts.module.ts b/packages/server/src/modules/Accounts/Accounts.module.ts index 42abb8fcf..85f0c6488 100644 --- a/packages/server/src/modules/Accounts/Accounts.module.ts +++ b/packages/server/src/modules/Accounts/Accounts.module.ts @@ -19,6 +19,8 @@ import { GetAccountsService } from './GetAccounts.service'; import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { AccountsExportable } from './AccountsExportable.service'; import { AccountsImportable } from './AccountsImportable.service'; +import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service'; +import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service'; const models = [RegisterTenancyModel(BankAccount)]; @@ -40,7 +42,9 @@ const models = [RegisterTenancyModel(BankAccount)]; GetAccountTransactionsService, GetAccountsService, AccountsExportable, - AccountsImportable + AccountsImportable, + BulkDeleteAccountsService, + ValidateBulkDeleteAccountsService, ], exports: [ AccountRepository, diff --git a/packages/server/src/modules/Accounts/AccountsApplication.service.ts b/packages/server/src/modules/Accounts/AccountsApplication.service.ts index a34cb8bb2..7eab22bfc 100644 --- a/packages/server/src/modules/Accounts/AccountsApplication.service.ts +++ b/packages/server/src/modules/Accounts/AccountsApplication.service.ts @@ -15,6 +15,9 @@ import { GetAccountsService } from './GetAccounts.service'; import { IFilterMeta } from '@/interfaces/Model'; import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto'; import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto'; +import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service'; +import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service'; +import { ValidateBulkDeleteResponseDto } from '@/common/dtos/BulkDelete.dto'; @Injectable() export class AccountsApplication { @@ -37,6 +40,8 @@ export class AccountsApplication { private readonly getAccountService: GetAccount, private readonly getAccountTransactionsService: GetAccountTransactionsService, private readonly getAccountsService: GetAccountsService, + private readonly bulkDeleteAccountsService: BulkDeleteAccountsService, + private readonly validateBulkDeleteAccountsService: ValidateBulkDeleteAccountsService, ) { } /** @@ -128,4 +133,28 @@ export class AccountsApplication { ): Promise> => { return this.getAccountTransactionsService.getAccountsTransactions(filter); }; + + /** + * Validates which accounts can be deleted in bulk. + */ + public validateBulkDeleteAccounts = ( + accountIds: number[], + ): Promise => { + return this.validateBulkDeleteAccountsService.validateBulkDeleteAccounts( + accountIds, + ); + }; + + /** + * Deletes multiple accounts in bulk. + */ + public bulkDeleteAccounts = ( + accountIds: number[], + options?: { skipUndeletable?: boolean }, + ): Promise => { + return this.bulkDeleteAccountsService.bulkDeleteAccounts( + accountIds, + options, + ); + }; } diff --git a/packages/server/src/modules/Accounts/BulkDeleteAccounts.service.ts b/packages/server/src/modules/Accounts/BulkDeleteAccounts.service.ts new file mode 100644 index 000000000..7f40c16f4 --- /dev/null +++ b/packages/server/src/modules/Accounts/BulkDeleteAccounts.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteAccount } from './DeleteAccount.service'; + +@Injectable() +export class BulkDeleteAccountsService { + constructor(private readonly deleteAccountService: DeleteAccount) { } + + /** + * Deletes multiple accounts. + * @param {number | Array} accountIds - The account id or ids. + * @param {Knex.Transaction} trx - The transaction. + */ + async bulkDeleteAccounts( + accountIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const accountsIds = uniq(castArray(accountIds)); + + const results = await PromisePool.withConcurrency(1) + .for(accountsIds) + .process(async (accountId: number) => { + try { + await this.deleteAccountService.deleteAccount(accountId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/Accounts/DeleteAccount.service.ts b/packages/server/src/modules/Accounts/DeleteAccount.service.ts index 985989998..7f02b6cc0 100644 --- a/packages/server/src/modules/Accounts/DeleteAccount.service.ts +++ b/packages/server/src/modules/Accounts/DeleteAccount.service.ts @@ -50,8 +50,12 @@ export class DeleteAccount { /** * Deletes the account from the storage. * @param {number} accountId + * @param {Knex.Transaction} trx - Database transaction instance. */ - public deleteAccount = async (accountId: number): Promise => { + public deleteAccount = async ( + accountId: number, + trx?: Knex.Transaction, + ): Promise => { // Retrieve account or not found service error. const oldAccount = await this.accountModel().query().findById(accountId); @@ -82,6 +86,6 @@ export class DeleteAccount { oldAccount, trx, } as IAccountEventDeletedPayload); - }); + }, trx); }; } diff --git a/packages/server/src/modules/Accounts/ValidateBulkDeleteAccounts.service.ts b/packages/server/src/modules/Accounts/ValidateBulkDeleteAccounts.service.ts new file mode 100644 index 000000000..b65398875 --- /dev/null +++ b/packages/server/src/modules/Accounts/ValidateBulkDeleteAccounts.service.ts @@ -0,0 +1,63 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteAccount } from './DeleteAccount.service'; +import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception'; + +@Injectable() +export class ValidateBulkDeleteAccountsService { + constructor( + private readonly deleteAccountService: DeleteAccount, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) {} + + /** + * Validates which accounts from the provided IDs can be deleted. + * Uses the actual deleteAccount service to validate, ensuring the same validation logic. + * Uses a transaction that is always rolled back to ensure no database changes. + * @param {number[]} accountIds - Array of account IDs to validate + * @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>} + */ + public async validateBulkDeleteAccounts(accountIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const accountId of accountIds) { + try { + await this.deleteAccountService.deleteAccount(accountId, trx); + deletableIds.push(accountId); + } catch (error) { + if (error instanceof ModelHasRelationsError) { + nonDeletableIds.push(accountId); + } else { + nonDeletableIds.push(accountId); + } + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/Bills/Bills.application.ts b/packages/server/src/modules/Bills/Bills.application.ts index 5c02bbc39..207b400ee 100644 --- a/packages/server/src/modules/Bills/Bills.application.ts +++ b/packages/server/src/modules/Bills/Bills.application.ts @@ -9,6 +9,8 @@ import { Injectable } from '@nestjs/common'; import { GetBillsService } from './queries/GetBills.service'; import { CreateBillDto, EditBillDto } from './dtos/Bill.dto'; import { GetBillPaymentTransactionsService } from './queries/GetBillPayments'; +import { BulkDeleteBillsService } from './BulkDeleteBills.service'; +import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service'; // import { GetBillPayments } from './queries/GetBillPayments'; // import { GetBills } from './queries/GetBills'; @@ -23,7 +25,9 @@ export class BillsApplication { private openBillService: OpenBillService, private getBillsService: GetBillsService, private getBillPaymentTransactionsService: GetBillPaymentTransactionsService, - ) {} + private bulkDeleteBillsService: BulkDeleteBillsService, + private validateBulkDeleteBillsService: ValidateBulkDeleteBillsService, + ) { } /** * Creates a new bill with associated GL entries. @@ -53,6 +57,25 @@ export class BillsApplication { return this.deleteBillService.deleteBill(billId); } + /** + * Deletes multiple bills. + * @param {number[]} billIds + */ + public bulkDeleteBills( + billIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeleteBillsService.bulkDeleteBills(billIds, options); + } + + /** + * Validates which bills can be deleted. + * @param {number[]} billIds + */ + public validateBulkDeleteBills(billIds: number[]) { + return this.validateBulkDeleteBillsService.validateBulkDeleteBills(billIds); + } + /** * Retrieve bills data table list. * @param {IBillsFilter} billsFilter - diff --git a/packages/server/src/modules/Bills/Bills.controller.ts b/packages/server/src/modules/Bills/Bills.controller.ts index c77639715..3619ecd18 100644 --- a/packages/server/src/modules/Bills/Bills.controller.ts +++ b/packages/server/src/modules/Bills/Bills.controller.ts @@ -22,14 +22,51 @@ import { CreateBillDto, EditBillDto } from './dtos/Bill.dto'; import { BillResponseDto } from './dtos/BillResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('bills') @ApiTags('Bills') @ApiExtraModels(BillResponseDto) @ApiExtraModels(PaginatedResponseDto) @ApiCommonHeaders() +@ApiExtraModels(ValidateBulkDeleteResponseDto) export class BillsController { - constructor(private billsApplication: BillsApplication) {} + constructor(private billsApplication: BillsApplication) { } + + @Post('validate-bulk-delete') + @ApiOperation({ + summary: 'Validate which bills can be deleted and return the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable bills.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + validateBulkDeleteBills( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.billsApplication.validateBulkDeleteBills(bulkDeleteDto.ids); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple bills.' }) + @ApiResponse({ + status: 200, + description: 'Bills deleted successfully', + }) + bulkDeleteBills( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.billsApplication.bulkDeleteBills(bulkDeleteDto.ids, { + skipUndeletable: bulkDeleteDto.skipUndeletable ?? false, + }); + } @Post() @ApiOperation({ summary: 'Create a new bill.' }) diff --git a/packages/server/src/modules/Bills/Bills.module.ts b/packages/server/src/modules/Bills/Bills.module.ts index a9599cea9..7b768f124 100644 --- a/packages/server/src/modules/Bills/Bills.module.ts +++ b/packages/server/src/modules/Bills/Bills.module.ts @@ -29,6 +29,8 @@ import { InventoryCostModule } from '../InventoryCost/InventoryCost.module'; import { BillsExportable } from './commands/BillsExportable'; import { BillsImportable } from './commands/BillsImportable'; import { GetBillPaymentTransactionsService } from './queries/GetBillPayments'; +import { BulkDeleteBillsService } from './BulkDeleteBills.service'; +import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service'; @Module({ imports: [ @@ -63,8 +65,10 @@ import { GetBillPaymentTransactionsService } from './queries/GetBillPayments'; BillsExportable, BillsImportable, GetBillPaymentTransactionsService, + BulkDeleteBillsService, + ValidateBulkDeleteBillsService, ], controllers: [BillsController], exports: [BillsExportable, BillsImportable], }) -export class BillsModule {} +export class BillsModule { } diff --git a/packages/server/src/modules/Bills/BulkDeleteBills.service.ts b/packages/server/src/modules/Bills/BulkDeleteBills.service.ts new file mode 100644 index 000000000..338c740ce --- /dev/null +++ b/packages/server/src/modules/Bills/BulkDeleteBills.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteBill } from './commands/DeleteBill.service'; + +@Injectable() +export class BulkDeleteBillsService { + constructor(private readonly deleteBillService: DeleteBill) { } + + async bulkDeleteBills( + billIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const billsIds = uniq(castArray(billIds)); + + const results = await PromisePool.withConcurrency(1) + .for(billsIds) + .process(async (billId: number) => { + try { + await this.deleteBillService.deleteBill(billId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/Bills/ValidateBulkDeleteBills.service.ts b/packages/server/src/modules/Bills/ValidateBulkDeleteBills.service.ts new file mode 100644 index 000000000..f65f076a6 --- /dev/null +++ b/packages/server/src/modules/Bills/ValidateBulkDeleteBills.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteBill } from './commands/DeleteBill.service'; + +@Injectable() +export class ValidateBulkDeleteBillsService { + constructor( + private readonly deleteBillService: DeleteBill, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) { } + + public async validateBulkDeleteBills(billIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const billId of billIds) { + try { + await this.deleteBillService.deleteBill(billId, trx); + deletableIds.push(billId); + } catch (error) { + nonDeletableIds.push(billId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/Bills/commands/DeleteBill.service.ts b/packages/server/src/modules/Bills/commands/DeleteBill.service.ts index 62d691da9..de2c8b26f 100644 --- a/packages/server/src/modules/Bills/commands/DeleteBill.service.ts +++ b/packages/server/src/modules/Bills/commands/DeleteBill.service.ts @@ -29,9 +29,10 @@ export class DeleteBill { /** * Deletes the bill with associated entries. * @param {number} billId + * @param {Knex.Transaction} trx - Database transaction instance. * @return {void} */ - public async deleteBill(billId: number) { + public async deleteBill(billId: number, trx?: Knex.Transaction) { // Retrieve the given bill or throw not found error. const oldBill = await this.billModel() .query() @@ -75,6 +76,6 @@ export class DeleteBill { oldBill, trx, } as IBIllEventDeletedPayload); - }); + }, trx); } } diff --git a/packages/server/src/modules/CreditNotes/BulkDeleteCreditNotes.service.ts b/packages/server/src/modules/CreditNotes/BulkDeleteCreditNotes.service.ts new file mode 100644 index 000000000..a5ea6a717 --- /dev/null +++ b/packages/server/src/modules/CreditNotes/BulkDeleteCreditNotes.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service'; + +@Injectable() +export class BulkDeleteCreditNotesService { + constructor( + private readonly deleteCreditNoteService: DeleteCreditNoteService, + ) { } + + async bulkDeleteCreditNotes( + creditNoteIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const notesIds = uniq(castArray(creditNoteIds)); + + const results = await PromisePool.withConcurrency(1) + .for(notesIds) + .process(async (creditNoteId: number) => { + try { + await this.deleteCreditNoteService.deleteCreditNote(creditNoteId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/CreditNotes/CreditNoteApplication.service.ts b/packages/server/src/modules/CreditNotes/CreditNoteApplication.service.ts index a024cde88..472508f92 100644 --- a/packages/server/src/modules/CreditNotes/CreditNoteApplication.service.ts +++ b/packages/server/src/modules/CreditNotes/CreditNoteApplication.service.ts @@ -9,6 +9,8 @@ import { GetCreditNotesService } from './queries/GetCreditNotes.service'; import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto'; import { GetCreditNoteState } from './queries/GetCreditNoteState.service'; import { GetCreditNoteService } from './queries/GetCreditNote.service'; +import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service'; +import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service'; @Injectable() export class CreditNoteApplication { @@ -20,8 +22,10 @@ export class CreditNoteApplication { private readonly getCreditNotePdfService: GetCreditNotePdf, private readonly getCreditNotesService: GetCreditNotesService, private readonly getCreditNoteStateService: GetCreditNoteState, - private readonly getCreditNoteService: GetCreditNoteService - ) {} + private readonly getCreditNoteService: GetCreditNoteService, + private readonly bulkDeleteCreditNotesService: BulkDeleteCreditNotesService, + private readonly validateBulkDeleteCreditNotesService: ValidateBulkDeleteCreditNotesService, + ) { } /** * Creates a new credit note. @@ -97,4 +101,30 @@ export class CreditNoteApplication { getCreditNote(creditNoteId: number) { return this.getCreditNoteService.getCreditNote(creditNoteId); } + + /** + * Deletes multiple credit notes. + * @param {number[]} creditNoteIds + * @returns {Promise} + */ + bulkDeleteCreditNotes( + creditNoteIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeleteCreditNotesService.bulkDeleteCreditNotes( + creditNoteIds, + options, + ); + } + + /** + * Validates which credit notes can be deleted. + * @param {number[]} creditNoteIds + * @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>} + */ + validateBulkDeleteCreditNotes(creditNoteIds: number[]) { + return this.validateBulkDeleteCreditNotesService.validateBulkDeleteCreditNotes( + creditNoteIds, + ); + } } diff --git a/packages/server/src/modules/CreditNotes/CreditNotes.controller.ts b/packages/server/src/modules/CreditNotes/CreditNotes.controller.ts index 52dde0e72..3ca2b48da 100644 --- a/packages/server/src/modules/CreditNotes/CreditNotes.controller.ts +++ b/packages/server/src/modules/CreditNotes/CreditNotes.controller.ts @@ -22,17 +22,22 @@ import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto'; import { CreditNoteResponseDto } from './dtos/CreditNoteResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('credit-notes') @ApiTags('Credit Notes') @ApiExtraModels(CreditNoteResponseDto) @ApiExtraModels(PaginatedResponseDto) +@ApiExtraModels(ValidateBulkDeleteResponseDto) @ApiCommonHeaders() export class CreditNotesController { /** * @param {CreditNoteApplication} creditNoteApplication - The credit note application service. */ - constructor(private creditNoteApplication: CreditNoteApplication) {} + constructor(private creditNoteApplication: CreditNoteApplication) { } @Post() @ApiOperation({ summary: 'Create a new credit note' }) @@ -112,6 +117,42 @@ export class CreditNotesController { return this.creditNoteApplication.openCreditNote(creditNoteId); } + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which credit notes can be deleted and returns the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable credit notes.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + validateBulkDeleteCreditNotes( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.creditNoteApplication.validateBulkDeleteCreditNotes( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple credit notes.' }) + @ApiResponse({ + status: 200, + description: 'Credit notes deleted successfully', + }) + bulkDeleteCreditNotes( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.creditNoteApplication.bulkDeleteCreditNotes( + bulkDeleteDto.ids, + { skipUndeletable: bulkDeleteDto.skipUndeletable ?? false }, + ); + } + @Delete(':id') @ApiOperation({ summary: 'Delete a credit note' }) @ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' }) diff --git a/packages/server/src/modules/CreditNotes/CreditNotes.module.ts b/packages/server/src/modules/CreditNotes/CreditNotes.module.ts index 3b4ed39ae..26a44e004 100644 --- a/packages/server/src/modules/CreditNotes/CreditNotes.module.ts +++ b/packages/server/src/modules/CreditNotes/CreditNotes.module.ts @@ -34,6 +34,8 @@ import { CreditNoteInventoryTransactions } from './commands/CreditNotesInventory import { InventoryCostModule } from '../InventoryCost/InventoryCost.module'; import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.module'; import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module'; +import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service'; +import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service'; @Module({ imports: [ @@ -73,6 +75,8 @@ import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/Credit RefundSyncCreditNoteBalanceSubscriber, DeleteCustomerLinkedCreditSubscriber, CreditNoteAutoSerialSubscriber, + BulkDeleteCreditNotesService, + ValidateBulkDeleteCreditNotesService, ], exports: [ CreateCreditNoteService, diff --git a/packages/server/src/modules/CreditNotes/ValidateBulkDeleteCreditNotes.service.ts b/packages/server/src/modules/CreditNotes/ValidateBulkDeleteCreditNotes.service.ts new file mode 100644 index 000000000..18c011fb7 --- /dev/null +++ b/packages/server/src/modules/CreditNotes/ValidateBulkDeleteCreditNotes.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service'; + +@Injectable() +export class ValidateBulkDeleteCreditNotesService { + constructor( + private readonly deleteCreditNoteService: DeleteCreditNoteService, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) { } + + public async validateBulkDeleteCreditNotes(creditNoteIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const creditNoteId of creditNoteIds) { + try { + await this.deleteCreditNoteService.deleteCreditNote( + creditNoteId, + trx, + ); + deletableIds.push(creditNoteId); + } catch (error) { + nonDeletableIds.push(creditNoteId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/CreditNotes/commands/DeleteCreditNote.service.ts b/packages/server/src/modules/CreditNotes/commands/DeleteCreditNote.service.ts index beb16d606..b1d94a1cb 100644 --- a/packages/server/src/modules/CreditNotes/commands/DeleteCreditNote.service.ts +++ b/packages/server/src/modules/CreditNotes/commands/DeleteCreditNote.service.ts @@ -49,9 +49,13 @@ export class DeleteCreditNoteService { /** * Deletes the given credit note transactions. * @param {number} creditNoteId + * @param {Knex.Transaction} trx - Database transaction instance. * @returns {Promise} */ - public async deleteCreditNote(creditNoteId: number): Promise { + public async deleteCreditNote( + creditNoteId: number, + trx?: Knex.Transaction, + ): Promise { // Retrieve the credit note or throw not found service error. const oldCreditNote = await this.creditNoteModel() .query() @@ -88,7 +92,7 @@ export class DeleteCreditNoteService { creditNoteId, trx, } as ICreditNoteDeletedPayload); - }); + }, trx); } /** diff --git a/packages/server/src/modules/Customers/BulkDeleteCustomers.service.ts b/packages/server/src/modules/Customers/BulkDeleteCustomers.service.ts new file mode 100644 index 000000000..add4d19f6 --- /dev/null +++ b/packages/server/src/modules/Customers/BulkDeleteCustomers.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { castArray, uniq } from 'lodash'; +import { PromisePool } from '@supercharge/promise-pool'; +import { DeleteCustomer } from './commands/DeleteCustomer.service'; + +@Injectable() +export class BulkDeleteCustomersService { + constructor(private readonly deleteCustomerService: DeleteCustomer) {} + + public async bulkDeleteCustomers( + customerIds: number[], + options?: { skipUndeletable?: boolean }, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const ids = uniq(castArray(customerIds)); + + const results = await PromisePool.withConcurrency(1) + .for(ids) + .process(async (customerId: number) => { + try { + await this.deleteCustomerService.deleteCustomer(customerId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw ?? results.errors[0]; + } + } +} + diff --git a/packages/server/src/modules/Customers/Customers.controller.ts b/packages/server/src/modules/Customers/Customers.controller.ts index 71de58616..bded38902 100644 --- a/packages/server/src/modules/Customers/Customers.controller.ts +++ b/packages/server/src/modules/Customers/Customers.controller.ts @@ -24,6 +24,10 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto'; import { EditCustomerDto } from './dtos/EditCustomer.dto'; import { CustomerResponseDto } from './dtos/CustomerResponse.dto'; import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto'; +import { + BulkDeleteCustomersDto, + ValidateBulkDeleteCustomersResponseDto, +} from './dtos/BulkDeleteCustomers.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('customers') @@ -31,7 +35,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @ApiExtraModels(CustomerResponseDto) @ApiCommonHeaders() export class CustomersController { - constructor(private customersApplication: CustomersApplication) {} + constructor(private customersApplication: CustomersApplication) { } @Get(':id') @ApiOperation({ summary: 'Retrieves the customer details.' }) @@ -109,4 +113,37 @@ export class CustomersController { openingBalanceDTO, ); } + + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which customers can be deleted and returns counts of deletable and non-deletable customers.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed. Returns counts and IDs of deletable and non-deletable customers.', + schema: { $ref: getSchemaPath(ValidateBulkDeleteCustomersResponseDto) }, + }) + validateBulkDeleteCustomers( + @Body() bulkDeleteDto: BulkDeleteCustomersDto, + ): Promise { + return this.customersApplication.validateBulkDeleteCustomers( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple customers in bulk.' }) + @ApiResponse({ + status: 200, + description: 'The customers have been successfully deleted.', + }) + async bulkDeleteCustomers( + @Body() bulkDeleteDto: BulkDeleteCustomersDto, + ): Promise { + return this.customersApplication.bulkDeleteCustomers(bulkDeleteDto.ids, { + skipUndeletable: bulkDeleteDto.skipUndeletable ?? false, + }); + } } diff --git a/packages/server/src/modules/Customers/Customers.module.ts b/packages/server/src/modules/Customers/Customers.module.ts index 5396a0424..7beb5a53b 100644 --- a/packages/server/src/modules/Customers/Customers.module.ts +++ b/packages/server/src/modules/Customers/Customers.module.ts @@ -16,6 +16,8 @@ import { CustomersExportable } from './CustomersExportable'; import { CustomersImportable } from './CustomersImportable'; import { GetCustomers } from './queries/GetCustomers.service'; import { DynamicListModule } from '../DynamicListing/DynamicList.module'; +import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service'; +import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service'; @Module({ imports: [TenancyDatabaseModule, DynamicListModule], @@ -37,6 +39,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module'; CustomersExportable, CustomersImportable, GetCustomers, + BulkDeleteCustomersService, + ValidateBulkDeleteCustomersService, ], }) export class CustomersModule {} diff --git a/packages/server/src/modules/Customers/CustomersApplication.service.ts b/packages/server/src/modules/Customers/CustomersApplication.service.ts index 114eb7711..74923ff71 100644 --- a/packages/server/src/modules/Customers/CustomersApplication.service.ts +++ b/packages/server/src/modules/Customers/CustomersApplication.service.ts @@ -12,6 +12,8 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto'; import { EditCustomerDto } from './dtos/EditCustomer.dto'; import { GetCustomers } from './queries/GetCustomers.service'; import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto'; +import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service'; +import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service'; @Injectable() export class CustomersApplication { @@ -22,6 +24,8 @@ export class CustomersApplication { private deleteCustomerService: DeleteCustomer, private editOpeningBalanceService: EditOpeningBalanceCustomer, private getCustomersService: GetCustomers, + private readonly bulkDeleteCustomersService: BulkDeleteCustomersService, + private readonly validateBulkDeleteCustomersService: ValidateBulkDeleteCustomersService, ) {} /** @@ -83,4 +87,20 @@ export class CustomersApplication { public getCustomers = (filterDTO: GetCustomersQueryDto) => { return this.getCustomersService.getCustomersList(filterDTO); }; + + public bulkDeleteCustomers = ( + customerIds: number[], + options?: { skipUndeletable?: boolean }, + ) => { + return this.bulkDeleteCustomersService.bulkDeleteCustomers( + customerIds, + options, + ); + }; + + public validateBulkDeleteCustomers = (customerIds: number[]) => { + return this.validateBulkDeleteCustomersService.validateBulkDeleteCustomers( + customerIds, + ); + }; } diff --git a/packages/server/src/modules/Customers/ValidateBulkDeleteCustomers.service.ts b/packages/server/src/modules/Customers/ValidateBulkDeleteCustomers.service.ts new file mode 100644 index 000000000..b8ffbed80 --- /dev/null +++ b/packages/server/src/modules/Customers/ValidateBulkDeleteCustomers.service.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteCustomer } from './commands/DeleteCustomer.service'; +import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Injectable() +export class ValidateBulkDeleteCustomersService { + constructor( + private readonly deleteCustomerService: DeleteCustomer, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) {} + + public async validateBulkDeleteCustomers(customerIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const customerId of customerIds) { + try { + await this.deleteCustomerService.deleteCustomer(customerId, trx); + deletableIds.push(customerId); + } catch (error) { + if ( + error instanceof ModelHasRelationsError || + (error instanceof ServiceError && + error.errorType === 'CUSTOMER_HAS_TRANSACTIONS') + ) { + nonDeletableIds.push(customerId); + } else { + nonDeletableIds.push(customerId); + } + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/Customers/commands/DeleteCustomer.service.ts b/packages/server/src/modules/Customers/commands/DeleteCustomer.service.ts index 9b1475e3f..df6209389 100644 --- a/packages/server/src/modules/Customers/commands/DeleteCustomer.service.ts +++ b/packages/server/src/modules/Customers/commands/DeleteCustomer.service.ts @@ -31,12 +31,13 @@ export class DeleteCustomer { * @param {number} customerId - Customer ID. * @return {Promise} */ - public async deleteCustomer(customerId: number): Promise { + public async deleteCustomer( + customerId: number, + trx?: Knex.Transaction, + ): Promise { // Retrieve the customer or throw not found service error. - const oldCustomer = await this.customerModel() - .query() - .findById(customerId) - .throwIfNotFound(); + const query = this.customerModel().query(trx); + const oldCustomer = await query.findById(customerId).throwIfNotFound(); // Triggers `onCustomerDeleting` event. await this.eventPublisher.emitAsync(events.customers.onDeleting, { @@ -45,10 +46,10 @@ export class DeleteCustomer { } as ICustomerDeletingPayload); // Deletes the customer and associated entities under UOW transaction. - return this.uow.withTransaction(async (trx: Knex.Transaction) => { + return this.uow.withTransaction(async (transaction: Knex.Transaction) => { // Delete the customer from the storage. await this.customerModel() - .query(trx) + .query(transaction) .findById(customerId) .deleteIfNoRelations({ type: ERRORS.CUSTOMER_HAS_TRANSACTIONS, @@ -58,8 +59,8 @@ export class DeleteCustomer { await this.eventPublisher.emitAsync(events.customers.onDeleted, { customerId, oldCustomer, - trx, + trx: transaction, } as ICustomerEventDeletedPayload); - }); + }, trx); } } diff --git a/packages/server/src/modules/Customers/dtos/BulkDeleteCustomers.dto.ts b/packages/server/src/modules/Customers/dtos/BulkDeleteCustomers.dto.ts new file mode 100644 index 000000000..a08230fdf --- /dev/null +++ b/packages/server/src/modules/Customers/dtos/BulkDeleteCustomers.dto.ts @@ -0,0 +1,62 @@ +import { + ArrayNotEmpty, + IsArray, + IsInt, + IsOptional, + IsBoolean, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { parseBoolean } from '@/utils/parse-boolean'; + +export class BulkDeleteCustomersDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + @ApiProperty({ + description: 'Array of customer IDs to delete', + type: [Number], + example: [1, 2, 3], + }) + ids: number[]; + + @IsOptional() + @IsBoolean() + @Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false)) + @ApiPropertyOptional({ + description: + 'When true, undeletable customers will be skipped and only deletable ones removed.', + type: Boolean, + default: false, + }) + skipUndeletable?: boolean; +} + +export class ValidateBulkDeleteCustomersResponseDto { + @ApiProperty({ + description: 'Number of customers that can be deleted', + example: 2, + }) + deletableCount: number; + + @ApiProperty({ + description: 'Number of customers that cannot be deleted', + example: 1, + }) + nonDeletableCount: number; + + @ApiProperty({ + description: 'IDs of customers that can be deleted', + type: [Number], + example: [1, 2], + }) + deletableIds: number[]; + + @ApiProperty({ + description: 'IDs of customers that cannot be deleted', + type: [Number], + example: [3], + }) + nonDeletableIds: number[]; +} + diff --git a/packages/server/src/modules/Expenses/BulkDeleteExpenses.service.ts b/packages/server/src/modules/Expenses/BulkDeleteExpenses.service.ts new file mode 100644 index 000000000..7e62444c4 --- /dev/null +++ b/packages/server/src/modules/Expenses/BulkDeleteExpenses.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteExpense } from './commands/DeleteExpense.service'; + +@Injectable() +export class BulkDeleteExpensesService { + constructor(private readonly deleteExpenseService: DeleteExpense) { } + + async bulkDeleteExpenses( + expenseIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const expensesIds = uniq(castArray(expenseIds)); + + const results = await PromisePool.withConcurrency(1) + .for(expensesIds) + .process(async (expenseId: number) => { + try { + await this.deleteExpenseService.deleteExpense(expenseId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/Expenses/Expenses.controller.ts b/packages/server/src/modules/Expenses/Expenses.controller.ts index 2dfb05409..2f80404a2 100644 --- a/packages/server/src/modules/Expenses/Expenses.controller.ts +++ b/packages/server/src/modules/Expenses/Expenses.controller.ts @@ -21,13 +21,55 @@ import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { ExpenseResponseDto } from './dtos/ExpenseResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('expenses') @ApiTags('Expenses') -@ApiExtraModels(PaginatedResponseDto, ExpenseResponseDto) +@ApiExtraModels( + PaginatedResponseDto, + ExpenseResponseDto, + ValidateBulkDeleteResponseDto, +) @ApiCommonHeaders() export class ExpensesController { - constructor(private readonly expensesApplication: ExpensesApplication) {} + constructor(private readonly expensesApplication: ExpensesApplication) { } + + @Post('validate-bulk-delete') + @ApiOperation({ + summary: 'Validate which expenses can be deleted and return the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable expenses.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + public validateBulkDeleteExpenses( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.expensesApplication.validateBulkDeleteExpenses( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple expenses.' }) + @ApiResponse({ + status: 200, + description: 'Expenses deleted successfully', + }) + public bulkDeleteExpenses( + @Body() bulkDeleteDto: BulkDeleteDto, + ) { + return this.expensesApplication.bulkDeleteExpenses(bulkDeleteDto.ids, { + skipUndeletable: bulkDeleteDto.skipUndeletable ?? false, + }); + } /** * Create a new expense transaction. diff --git a/packages/server/src/modules/Expenses/Expenses.module.ts b/packages/server/src/modules/Expenses/Expenses.module.ts index 9a36478ab..4041acb06 100644 --- a/packages/server/src/modules/Expenses/Expenses.module.ts +++ b/packages/server/src/modules/Expenses/Expenses.module.ts @@ -19,6 +19,8 @@ import { GetExpensesService } from './queries/GetExpenses.service'; import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { ExpensesExportable } from './ExpensesExportable'; import { ExpensesImportable } from './ExpensesImportable'; +import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service'; +import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service'; @Module({ imports: [LedgerModule, BranchesModule, DynamicListModule], @@ -41,6 +43,8 @@ import { ExpensesImportable } from './ExpensesImportable'; GetExpensesService, ExpensesExportable, ExpensesImportable, + BulkDeleteExpensesService, + ValidateBulkDeleteExpensesService, ], }) export class ExpensesModule {} diff --git a/packages/server/src/modules/Expenses/ExpensesApplication.service.ts b/packages/server/src/modules/Expenses/ExpensesApplication.service.ts index 18e611655..aa89c99a5 100644 --- a/packages/server/src/modules/Expenses/ExpensesApplication.service.ts +++ b/packages/server/src/modules/Expenses/ExpensesApplication.service.ts @@ -7,6 +7,8 @@ import { GetExpenseService } from './queries/GetExpense.service'; import { IExpensesFilter } from './interfaces/Expenses.interface'; import { GetExpensesService } from './queries/GetExpenses.service'; import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto'; +import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service'; +import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service'; @Injectable() export class ExpensesApplication { @@ -17,7 +19,9 @@ export class ExpensesApplication { private readonly publishExpenseService: PublishExpense, private readonly getExpenseService: GetExpenseService, private readonly getExpensesService: GetExpensesService, - ) {} + private readonly bulkDeleteExpensesService: BulkDeleteExpensesService, + private readonly validateBulkDeleteExpensesService: ValidateBulkDeleteExpensesService, + ) { } /** * Create a new expense transaction. @@ -47,6 +51,30 @@ export class ExpensesApplication { return this.deleteExpenseService.deleteExpense(expenseId); } + /** + * Deletes expenses in bulk. + * @param {number[]} expenseIds - Expense ids. + */ + public bulkDeleteExpenses( + expenseIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeleteExpensesService.bulkDeleteExpenses( + expenseIds, + options, + ); + } + + /** + * Validates which expenses can be deleted. + * @param {number[]} expenseIds - Expense ids. + */ + public validateBulkDeleteExpenses(expenseIds: number[]) { + return this.validateBulkDeleteExpensesService.validateBulkDeleteExpenses( + expenseIds, + ); + } + /** * Publishes the given expense. * @param {number} expenseId - Expense id. diff --git a/packages/server/src/modules/Expenses/ValidateBulkDeleteExpenses.service.ts b/packages/server/src/modules/Expenses/ValidateBulkDeleteExpenses.service.ts new file mode 100644 index 000000000..42c73e304 --- /dev/null +++ b/packages/server/src/modules/Expenses/ValidateBulkDeleteExpenses.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteExpense } from './commands/DeleteExpense.service'; + +@Injectable() +export class ValidateBulkDeleteExpensesService { + constructor( + private readonly deleteExpenseService: DeleteExpense, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) { } + + public async validateBulkDeleteExpenses(expenseIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const expenseId of expenseIds) { + try { + await this.deleteExpenseService.deleteExpense(expenseId, trx); + deletableIds.push(expenseId); + } catch (error) { + nonDeletableIds.push(expenseId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/Expenses/commands/DeleteExpense.service.ts b/packages/server/src/modules/Expenses/commands/DeleteExpense.service.ts index 5820642a2..14feff3c3 100644 --- a/packages/server/src/modules/Expenses/commands/DeleteExpense.service.ts +++ b/packages/server/src/modules/Expenses/commands/DeleteExpense.service.ts @@ -36,9 +36,12 @@ export class DeleteExpense { /** * Deletes the given expense. * @param {number} expenseId - * @param {ISystemUser} authorizedUser + * @param {Knex.Transaction} trx - Database transaction instance. */ - public async deleteExpense(expenseId: number): Promise { + public async deleteExpense( + expenseId: number, + trx?: Knex.Transaction, + ): Promise { // Retrieves the expense transaction with associated entries or // throw not found error. const oldExpense = await this.expenseModel() @@ -74,6 +77,6 @@ export class DeleteExpense { oldExpense, trx, } as IExpenseEventDeletePayload); - }); + }, trx); } } diff --git a/packages/server/src/modules/ItemCategories/BulkDeleteItemCategories.service.ts b/packages/server/src/modules/ItemCategories/BulkDeleteItemCategories.service.ts new file mode 100644 index 000000000..30e608d52 --- /dev/null +++ b/packages/server/src/modules/ItemCategories/BulkDeleteItemCategories.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service'; + +@Injectable() +export class BulkDeleteItemCategoriesService { + constructor( + private readonly deleteItemCategoryService: DeleteItemCategoryService, + ) { } + + async bulkDeleteItemCategories( + itemCategoryIds: number | Array, + trx?: Knex.Transaction, + ): Promise { + const categoriesIds = uniq(castArray(itemCategoryIds)); + + const results = await PromisePool.withConcurrency(1) + .for(categoriesIds) + .process(async (itemCategoryId: number) => { + await this.deleteItemCategoryService.deleteItemCategory(itemCategoryId); + }); + + if (results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/ItemCategories/ValidateBulkDeleteItemCategories.service.ts b/packages/server/src/modules/ItemCategories/ValidateBulkDeleteItemCategories.service.ts new file mode 100644 index 000000000..d66239175 --- /dev/null +++ b/packages/server/src/modules/ItemCategories/ValidateBulkDeleteItemCategories.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service'; + +@Injectable() +export class ValidateBulkDeleteItemCategoriesService { + constructor( + private readonly deleteItemCategoryService: DeleteItemCategoryService, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) {} + + public async validateBulkDeleteItemCategories(itemCategoryIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const itemCategoryId of itemCategoryIds) { + try { + await this.deleteItemCategoryService.deleteItemCategory( + itemCategoryId, + trx, + ); + deletableIds.push(itemCategoryId); + } catch (error) { + nonDeletableIds.push(itemCategoryId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/ItemCategories/commands/DeleteItemCategory.service.ts b/packages/server/src/modules/ItemCategories/commands/DeleteItemCategory.service.ts index c1f8e45e8..0fa68a217 100644 --- a/packages/server/src/modules/ItemCategories/commands/DeleteItemCategory.service.ts +++ b/packages/server/src/modules/ItemCategories/commands/DeleteItemCategory.service.ts @@ -32,9 +32,13 @@ export class DeleteItemCategoryService { /** * Deletes the given item category. * @param {number} itemCategoryId - Item category id. + * @param {Knex.Transaction} trx - Database transaction instance. * @return {Promise} */ - public async deleteItemCategory(itemCategoryId: number) { + public async deleteItemCategory( + itemCategoryId: number, + trx?: Knex.Transaction, + ) { // Retrieve item category or throw not found error. const oldItemCategory = await this.itemCategoryModel() .query() @@ -56,7 +60,7 @@ export class DeleteItemCategoryService { itemCategoryId, oldItemCategory, } as IItemCategoryDeletedPayload); - }); + }, trx); } /** diff --git a/packages/server/src/modules/Items/BulkDeleteItems.service.ts b/packages/server/src/modules/Items/BulkDeleteItems.service.ts new file mode 100644 index 000000000..083296332 --- /dev/null +++ b/packages/server/src/modules/Items/BulkDeleteItems.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteItemService } from './DeleteItem.service'; + +@Injectable() +export class BulkDeleteItemsService { + constructor(private readonly deleteItemService: DeleteItemService) {} + + /** + * Deletes multiple items. + * @param {number | Array} itemIds - The item id or ids. + * @param {Knex.Transaction} trx - The transaction. + */ + async bulkDeleteItems( + itemIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const itemsIds = uniq(castArray(itemIds)); + + const results = await PromisePool.withConcurrency(1) + .for(itemsIds) + .process(async (itemId: number) => { + try { + await this.deleteItemService.deleteItem(itemId, trx); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw ?? results.errors[0]; + } + } +} + diff --git a/packages/server/src/modules/Items/Item.controller.ts b/packages/server/src/modules/Items/Item.controller.ts index 0afa9b467..b55f8f1d4 100644 --- a/packages/server/src/modules/Items/Item.controller.ts +++ b/packages/server/src/modules/Items/Item.controller.ts @@ -29,6 +29,10 @@ import { ItemEstimatesResponseDto } from './dtos/ItemEstimatesResponse.dto'; import { ItemBillsResponseDto } from './dtos/ItemBillsResponse.dto'; import { ItemReceiptsResponseDto } from './dtos/ItemReceiptsResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteItemsDto, + ValidateBulkDeleteItemsResponseDto, +} from './dtos/BulkDeleteItems.dto'; @Controller('/items') @ApiTags('Items') @@ -39,6 +43,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @ApiExtraModels(ItemBillsResponseDto) @ApiExtraModels(ItemEstimatesResponseDto) @ApiExtraModels(ItemReceiptsResponseDto) +@ApiExtraModels(ValidateBulkDeleteItemsResponseDto) @ApiCommonHeaders() export class ItemsController extends TenantController { constructor(private readonly itemsApplication: ItemsApplicationService) { @@ -340,4 +345,37 @@ export class ItemsController extends TenantController { const itemId = parseInt(id, 10); return this.itemsApplication.getItemReceiptsTransactions(itemId); } + + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which items can be deleted and returns counts of deletable and non-deletable items.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed. Returns counts and IDs of deletable and non-deletable items.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteItemsResponseDto), + }, + }) + async validateBulkDeleteItems( + @Body() bulkDeleteDto: BulkDeleteItemsDto, + ): Promise { + return this.itemsApplication.validateBulkDeleteItems(bulkDeleteDto.ids); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple items in bulk.' }) + @ApiResponse({ + status: 200, + description: 'The items have been successfully deleted.', + }) + async bulkDeleteItems( + @Body() bulkDeleteDto: BulkDeleteItemsDto, + ): Promise { + return this.itemsApplication.bulkDeleteItems(bulkDeleteDto.ids, { + skipUndeletable: bulkDeleteDto.skipUndeletable ?? false, + }); + } } diff --git a/packages/server/src/modules/Items/Items.module.ts b/packages/server/src/modules/Items/Items.module.ts index 8cc26d759..b18e8dad8 100644 --- a/packages/server/src/modules/Items/Items.module.ts +++ b/packages/server/src/modules/Items/Items.module.ts @@ -18,6 +18,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { InventoryAdjustmentsModule } from '../InventoryAdjutments/InventoryAdjustments.module'; import { ItemsExportable } from './ItemsExportable.service'; import { ItemsImportable } from './ItemsImportable.service'; +import { BulkDeleteItemsService } from './BulkDeleteItems.service'; +import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service'; @Module({ imports: [ @@ -41,8 +43,10 @@ import { ItemsImportable } from './ItemsImportable.service'; TransformerInjectable, ItemsEntriesService, ItemsExportable, - ItemsImportable + ItemsImportable, + BulkDeleteItemsService, + ValidateBulkDeleteItemsService, ], exports: [ItemsEntriesService, ItemsExportable, ItemsImportable], }) -export class ItemsModule {} +export class ItemsModule { } diff --git a/packages/server/src/modules/Items/ItemsApplication.service.ts b/packages/server/src/modules/Items/ItemsApplication.service.ts index a93e29587..6e41d7b53 100644 --- a/packages/server/src/modules/Items/ItemsApplication.service.ts +++ b/packages/server/src/modules/Items/ItemsApplication.service.ts @@ -13,6 +13,8 @@ import { GetItemsService } from './GetItems.service'; import { IItemsFilter } from './types/Items.types'; import { EditItemDto, CreateItemDto } from './dtos/Item.dto'; import { GetItemsQueryDto } from './dtos/GetItemsQuery.dto'; +import { BulkDeleteItemsService } from './BulkDeleteItems.service'; +import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service'; @Injectable() export class ItemsApplicationService { @@ -25,7 +27,9 @@ export class ItemsApplicationService { private readonly getItemService: GetItemService, private readonly getItemsService: GetItemsService, private readonly itemTransactionsService: ItemTransactionsService, - ) {} + private readonly bulkDeleteItemsService: BulkDeleteItemsService, + private readonly validateBulkDeleteItemsService: ValidateBulkDeleteItemsService, + ) { } /** * Creates a new item. @@ -134,4 +138,30 @@ export class ItemsApplicationService { async getItemReceiptsTransactions(itemId: number): Promise { return this.itemTransactionsService.getItemReceiptTransactions(itemId); } + + /** + * Validates which items can be deleted in bulk. + * @param {number[]} itemIds - Array of item IDs to validate + * @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>} + */ + async validateBulkDeleteItems(itemIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + return this.validateBulkDeleteItemsService.validateBulkDeleteItems(itemIds); + } + + /** + * Deletes multiple items in bulk. + * @param {number[]} itemIds - Array of item IDs to delete + * @returns {Promise} + */ + async bulkDeleteItems( + itemIds: number[], + options?: { skipUndeletable?: boolean }, + ): Promise { + return this.bulkDeleteItemsService.bulkDeleteItems(itemIds, options); + } } diff --git a/packages/server/src/modules/Items/ValidateBulkDeleteItems.service.ts b/packages/server/src/modules/Items/ValidateBulkDeleteItems.service.ts new file mode 100644 index 000000000..45f7e6c8d --- /dev/null +++ b/packages/server/src/modules/Items/ValidateBulkDeleteItems.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteItemService } from './DeleteItem.service'; +import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception'; + +@Injectable() +export class ValidateBulkDeleteItemsService { + constructor( + private readonly deleteItemService: DeleteItemService, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) { } + + /** + * Validates which items from the provided IDs can be deleted. + * Uses the actual deleteItem service to validate, ensuring the same validation logic. + * Uses a transaction that is always rolled back to ensure no database changes. + * @param {number[]} itemIds - Array of item IDs to validate + * @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>} + */ + public async validateBulkDeleteItems(itemIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + // Create a transaction that will be rolled back + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + // Check each item to see if it can be deleted by attempting deletion in transaction + for (const itemId of itemIds) { + try { + // Attempt to delete the item using the deleteItem service with the transaction + // This will use the exact same validation logic as the actual delete + await this.deleteItemService.deleteItem(itemId, trx); + + // If deletion succeeds, item is deletable + deletableIds.push(itemId); + } catch (error) { + // If error occurs, check the type of error + if (error instanceof ModelHasRelationsError) { + // Item has associated transactions/relations, cannot be deleted + nonDeletableIds.push(itemId); + } else { + // Other errors (e.g., item not found), also mark as non-deletable + nonDeletableIds.push(itemId); + } + } + } + + // Always rollback the transaction to ensure no changes are persisted + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + // Rollback in case of any error + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/Items/dtos/BulkDeleteItems.dto.ts b/packages/server/src/modules/Items/dtos/BulkDeleteItems.dto.ts new file mode 100644 index 000000000..9a1a471a8 --- /dev/null +++ b/packages/server/src/modules/Items/dtos/BulkDeleteItems.dto.ts @@ -0,0 +1,62 @@ +import { + IsArray, + IsInt, + ArrayNotEmpty, + IsOptional, + IsBoolean, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { parseBoolean } from '@/utils/parse-boolean'; + +export class BulkDeleteItemsDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + @ApiProperty({ + description: 'Array of item IDs to delete', + type: [Number], + example: [1, 2, 3], + }) + ids: number[]; + + @IsOptional() + @IsBoolean() + @Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false)) + @ApiPropertyOptional({ + description: + 'When true, undeletable items will be skipped and only deletable ones removed.', + type: Boolean, + default: false, + }) + skipUndeletable?: boolean; +} + +export class ValidateBulkDeleteItemsResponseDto { + @ApiProperty({ + description: 'Number of items that can be deleted', + example: 2, + }) + deletableCount: number; + + @ApiProperty({ + description: 'Number of items that cannot be deleted', + example: 1, + }) + nonDeletableCount: number; + + @ApiProperty({ + description: 'IDs of items that can be deleted', + type: [Number], + example: [1, 2], + }) + deletableIds: number[]; + + @ApiProperty({ + description: 'IDs of items that cannot be deleted', + type: [Number], + example: [3], + }) + nonDeletableIds: number[]; +} + diff --git a/packages/server/src/modules/ManualJournals/BulkDeleteManualJournals.service.ts b/packages/server/src/modules/ManualJournals/BulkDeleteManualJournals.service.ts new file mode 100644 index 000000000..4684aea65 --- /dev/null +++ b/packages/server/src/modules/ManualJournals/BulkDeleteManualJournals.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteManualJournalService } from './commands/DeleteManualJournal.service'; + +@Injectable() +export class BulkDeleteManualJournalsService { + constructor( + private readonly deleteManualJournalService: DeleteManualJournalService, + ) { } + + async bulkDeleteManualJournals( + manualJournalIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const journalsIds = uniq(castArray(manualJournalIds)); + + const results = await PromisePool.withConcurrency(1) + .for(journalsIds) + .process(async (manualJournalId: number) => { + try { + await this.deleteManualJournalService.deleteManualJournal( + manualJournalId, + ); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/ManualJournals/ManualJournals.controller.ts b/packages/server/src/modules/ManualJournals/ManualJournals.controller.ts index 1d813c346..239ebadf7 100644 --- a/packages/server/src/modules/ManualJournals/ManualJournals.controller.ts +++ b/packages/server/src/modules/ManualJournals/ManualJournals.controller.ts @@ -25,13 +25,54 @@ import { import { IManualJournalsFilter } from './types/ManualJournals.types'; import { ManualJournalResponseDto } from './dtos/ManualJournalResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('manual-journals') @ApiTags('Manual Journals') @ApiExtraModels(ManualJournalResponseDto) +@ApiExtraModels(ValidateBulkDeleteResponseDto) @ApiCommonHeaders() export class ManualJournalsController { - constructor(private manualJournalsApplication: ManualJournalsApplication) {} + constructor(private manualJournalsApplication: ManualJournalsApplication) { } + + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validate which manual journals can be deleted and return the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable manual journals.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + public validateBulkDeleteManualJournals( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.manualJournalsApplication.validateBulkDeleteManualJournals( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple manual journals.' }) + @ApiResponse({ + status: 200, + description: 'Manual journals deleted successfully', + }) + public bulkDeleteManualJournals( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.manualJournalsApplication.bulkDeleteManualJournals( + bulkDeleteDto.ids, + { skipUndeletable: bulkDeleteDto.skipUndeletable ?? false }, + ); + } @Post() @ApiOperation({ summary: 'Create a new manual journal.' }) diff --git a/packages/server/src/modules/ManualJournals/ManualJournals.module.ts b/packages/server/src/modules/ManualJournals/ManualJournals.module.ts index be1dabfb8..cbe5aae89 100644 --- a/packages/server/src/modules/ManualJournals/ManualJournals.module.ts +++ b/packages/server/src/modules/ManualJournals/ManualJournals.module.ts @@ -19,6 +19,8 @@ import { ManualJournalsExportable } from './commands/ManualJournalExportable'; import { ManualJournalImportable } from './commands/ManualJournalsImport'; import { GetManualJournals } from './queries/GetManualJournals.service'; import { DynamicListModule } from '../DynamicListing/DynamicList.module'; +import { BulkDeleteManualJournalsService } from './BulkDeleteManualJournals.service'; +import { ValidateBulkDeleteManualJournalsService } from './ValidateBulkDeleteManualJournals.service'; @Module({ imports: [BranchesModule, LedgerModule, DynamicListModule], @@ -41,6 +43,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module'; ManualJournalWriteGLSubscriber, ManualJournalsExportable, ManualJournalImportable, + BulkDeleteManualJournalsService, + ValidateBulkDeleteManualJournalsService, ], exports: [ManualJournalsExportable, ManualJournalImportable], }) diff --git a/packages/server/src/modules/ManualJournals/ManualJournalsApplication.service.ts b/packages/server/src/modules/ManualJournals/ManualJournalsApplication.service.ts index 9fdba3557..164bfd943 100644 --- a/packages/server/src/modules/ManualJournals/ManualJournalsApplication.service.ts +++ b/packages/server/src/modules/ManualJournals/ManualJournalsApplication.service.ts @@ -10,6 +10,8 @@ import { EditManualJournalDto, } from './dtos/ManualJournal.dto'; import { GetManualJournals } from './queries/GetManualJournals.service'; +import { BulkDeleteManualJournalsService } from './BulkDeleteManualJournals.service'; +import { ValidateBulkDeleteManualJournalsService } from './ValidateBulkDeleteManualJournals.service'; // import { GetManualJournals } from './queries/GetManualJournals'; @Injectable() @@ -21,7 +23,9 @@ export class ManualJournalsApplication { private publishManualJournalService: PublishManualJournal, private getManualJournalService: GetManualJournal, private getManualJournalsService: GetManualJournals, - ) {} + private bulkDeleteManualJournalsService: BulkDeleteManualJournalsService, + private validateBulkDeleteManualJournalsService: ValidateBulkDeleteManualJournalsService, + ) { } /** * Make journal entries. @@ -57,6 +61,30 @@ export class ManualJournalsApplication { return this.deleteManualJournalService.deleteManualJournal(manualJournalId); }; + /** + * Bulk deletes manual journals. + * @param {number[]} manualJournalIds + */ + public bulkDeleteManualJournals = ( + manualJournalIds: number[], + options?: { skipUndeletable?: boolean }, + ) => { + return this.bulkDeleteManualJournalsService.bulkDeleteManualJournals( + manualJournalIds, + options, + ); + }; + + /** + * Validates which manual journals can be deleted. + * @param {number[]} manualJournalIds + */ + public validateBulkDeleteManualJournals = (manualJournalIds: number[]) => { + return this.validateBulkDeleteManualJournalsService.validateBulkDeleteManualJournals( + manualJournalIds, + ); + }; + /** * Publish the given manual journal. * @param {number} manualJournalId - Manual journal id. diff --git a/packages/server/src/modules/ManualJournals/ValidateBulkDeleteManualJournals.service.ts b/packages/server/src/modules/ManualJournals/ValidateBulkDeleteManualJournals.service.ts new file mode 100644 index 000000000..3d275cabb --- /dev/null +++ b/packages/server/src/modules/ManualJournals/ValidateBulkDeleteManualJournals.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteManualJournalService } from './commands/DeleteManualJournal.service'; + +@Injectable() +export class ValidateBulkDeleteManualJournalsService { + constructor( + private readonly deleteManualJournalService: DeleteManualJournalService, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) {} + + public async validateBulkDeleteManualJournals( + manualJournalIds: number[], + ): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const manualJournalId of manualJournalIds) { + try { + await this.deleteManualJournalService.deleteManualJournal( + manualJournalId, + trx, + ); + deletableIds.push(manualJournalId); + } catch (error) { + nonDeletableIds.push(manualJournalId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/ManualJournals/commands/DeleteManualJournal.service.ts b/packages/server/src/modules/ManualJournals/commands/DeleteManualJournal.service.ts index 0f87103e3..d703dcabe 100644 --- a/packages/server/src/modules/ManualJournals/commands/DeleteManualJournal.service.ts +++ b/packages/server/src/modules/ManualJournals/commands/DeleteManualJournal.service.ts @@ -29,10 +29,12 @@ export class DeleteManualJournalService { /** * Deletes the given manual journal * @param {number} manualJournalId + * @param {Knex.Transaction} trx - Database transaction instance. * @return {Promise} */ public deleteManualJournal = async ( manualJournalId: number, + trx?: Knex.Transaction, ): Promise<{ oldManualJournal: ManualJournal; }> => { @@ -70,6 +72,6 @@ export class DeleteManualJournalService { } as IManualJournalEventDeletedPayload); return { oldManualJournal }; - }); + }, trx); }; } diff --git a/packages/server/src/modules/PaymentReceived/BulkDeletePaymentReceived.service.ts b/packages/server/src/modules/PaymentReceived/BulkDeletePaymentReceived.service.ts new file mode 100644 index 000000000..c36f41a00 --- /dev/null +++ b/packages/server/src/modules/PaymentReceived/BulkDeletePaymentReceived.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeletePaymentReceivedService } from './commands/DeletePaymentReceived.service'; + +@Injectable() +export class BulkDeletePaymentReceivedService { + constructor( + private readonly deletePaymentReceivedService: DeletePaymentReceivedService, + ) { } + + async bulkDeletePaymentReceived( + paymentReceiveIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const paymentsIds = uniq(castArray(paymentReceiveIds)); + + const results = await PromisePool.withConcurrency(1) + .for(paymentsIds) + .process(async (paymentReceiveId: number) => { + try { + await this.deletePaymentReceivedService.deletePaymentReceive( + paymentReceiveId, + ); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/PaymentReceived/PaymentReceived.application.ts b/packages/server/src/modules/PaymentReceived/PaymentReceived.application.ts index 2a51c5155..32780174d 100644 --- a/packages/server/src/modules/PaymentReceived/PaymentReceived.application.ts +++ b/packages/server/src/modules/PaymentReceived/PaymentReceived.application.ts @@ -18,6 +18,8 @@ import { } from './dtos/PaymentReceived.dto'; import { PaymentsReceivedPagesService } from './queries/PaymentsReceivedPages.service'; import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service'; +import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service'; +import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service'; @Injectable() export class PaymentReceivesApplication { @@ -33,7 +35,9 @@ export class PaymentReceivesApplication { private getPaymentReceivePdfService: GetPaymentReceivedPdfService, private getPaymentReceivedStateService: GetPaymentReceivedStateService, private paymentsReceivedPagesService: PaymentsReceivedPagesService, - ) {} + private bulkDeletePaymentReceivedService: BulkDeletePaymentReceivedService, + private validateBulkDeletePaymentReceivedService: ValidateBulkDeletePaymentReceivedService, + ) { } /** * Creates a new payment receive. @@ -73,6 +77,29 @@ export class PaymentReceivesApplication { ); } + /** + * Deletes multiple payment receives. + * @param {number[]} paymentReceiveIds + */ + public bulkDeletePaymentReceives( + paymentReceiveIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeletePaymentReceivedService.bulkDeletePaymentReceived( + paymentReceiveIds, + options, + ); + } + + /** + * Validates which payment receives can be deleted. + * @param {number[]} paymentReceiveIds + */ + public validateBulkDeletePaymentReceives(paymentReceiveIds: number[]) { + return this.validateBulkDeletePaymentReceivedService + .validateBulkDeletePaymentReceived(paymentReceiveIds); + } + /** * Retrieve payment receives paginated and filterable. * @param {number} tenantId diff --git a/packages/server/src/modules/PaymentReceived/PaymentsReceived.controller.ts b/packages/server/src/modules/PaymentReceived/PaymentsReceived.controller.ts index abc5577d0..06a501e17 100644 --- a/packages/server/src/modules/PaymentReceived/PaymentsReceived.controller.ts +++ b/packages/server/src/modules/PaymentReceived/PaymentsReceived.controller.ts @@ -32,15 +32,20 @@ import { PaymentReceivedResponseDto } from './dtos/PaymentReceivedResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { PaymentReceivedStateResponseDto } from './dtos/PaymentReceivedStateResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('payments-received') @ApiTags('Payments Received') @ApiExtraModels(PaymentReceivedResponseDto) @ApiExtraModels(PaginatedResponseDto) @ApiExtraModels(PaymentReceivedStateResponseDto) +@ApiExtraModels(ValidateBulkDeleteResponseDto) @ApiCommonHeaders() export class PaymentReceivesController { - constructor(private paymentReceivesApplication: PaymentReceivesApplication) {} + constructor(private paymentReceivesApplication: PaymentReceivesApplication) { } @Post(':id/mail') @HttpCode(200) @@ -143,6 +148,42 @@ export class PaymentReceivesController { return this.paymentReceivesApplication.getPaymentsReceived(filterDTO); } + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which payments received can be deleted and returns the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable payments received.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + public validateBulkDeletePaymentsReceived( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.paymentReceivesApplication.validateBulkDeletePaymentReceives( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple payments received.' }) + @ApiResponse({ + status: 200, + description: 'Payments received deleted successfully.', + }) + public bulkDeletePaymentsReceived( + @Body() bulkDeleteDto: BulkDeleteDto, + ) { + return this.paymentReceivesApplication.bulkDeletePaymentReceives( + bulkDeleteDto.ids, + { skipUndeletable: bulkDeleteDto.skipUndeletable ?? false }, + ); + } + @Get('state') @ApiOperation({ summary: 'Retrieves the payment received state.' }) @ApiResponse({ diff --git a/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts b/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts index 8d062a461..ed7d034b3 100644 --- a/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts +++ b/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts @@ -39,6 +39,8 @@ import { PaymentsReceivedImportable } from './commands/PaymentsReceivedImportabl import { PaymentsReceivedPagesService } from './queries/PaymentsReceivedPages.service'; import { GetPaymentReceivedMailTemplate } from './queries/GetPaymentReceivedMailTemplate.service'; import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service'; +import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service'; +import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service'; @Module({ controllers: [PaymentReceivesController], @@ -68,7 +70,9 @@ import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailSta PaymentsReceivedImportable, PaymentsReceivedPagesService, GetPaymentReceivedMailTemplate, - GetPaymentReceivedMailState + GetPaymentReceivedMailState, + BulkDeletePaymentReceivedService, + ValidateBulkDeletePaymentReceivedService, ], exports: [ PaymentReceivesApplication, @@ -76,7 +80,7 @@ import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailSta PaymentReceivedGLEntries, PaymentsReceivedExportable, PaymentsReceivedImportable, - PaymentReceivedValidators + PaymentReceivedValidators, ], imports: [ ChromiumlyTenancyModule, diff --git a/packages/server/src/modules/PaymentReceived/ValidateBulkDeletePaymentReceived.service.ts b/packages/server/src/modules/PaymentReceived/ValidateBulkDeletePaymentReceived.service.ts new file mode 100644 index 000000000..81bbfd729 --- /dev/null +++ b/packages/server/src/modules/PaymentReceived/ValidateBulkDeletePaymentReceived.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeletePaymentReceivedService } from './commands/DeletePaymentReceived.service'; + +@Injectable() +export class ValidateBulkDeletePaymentReceivedService { + constructor( + private readonly deletePaymentReceivedService: DeletePaymentReceivedService, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) { } + + public async validateBulkDeletePaymentReceived( + paymentReceiveIds: number[], + ): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const paymentReceiveId of paymentReceiveIds) { + try { + await this.deletePaymentReceivedService.deletePaymentReceive( + paymentReceiveId, + trx, + ); + deletableIds.push(paymentReceiveId); + } catch (error) { + nonDeletableIds.push(paymentReceiveId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/PaymentReceived/commands/DeletePaymentReceived.service.ts b/packages/server/src/modules/PaymentReceived/commands/DeletePaymentReceived.service.ts index 7d17c4f2f..b621ede45 100644 --- a/packages/server/src/modules/PaymentReceived/commands/DeletePaymentReceived.service.ts +++ b/packages/server/src/modules/PaymentReceived/commands/DeletePaymentReceived.service.ts @@ -30,7 +30,7 @@ export class DeletePaymentReceivedService { private paymentReceiveEntryModel: TenantModelProxy< typeof PaymentReceivedEntry >, - ) {} + ) { } /** * Deletes the given payment receive with associated entries @@ -43,9 +43,12 @@ export class DeletePaymentReceivedService { * - Revert the payment amount of the associated invoices. * @async * @param {Integer} paymentReceiveId - Payment receive id. - * @param {IPaymentReceived} paymentReceive - Payment receive object. + * @param {Knex.Transaction} trx - Database transaction instance. */ - public async deletePaymentReceive(paymentReceiveId: number) { + public async deletePaymentReceive( + paymentReceiveId: number, + trx?: Knex.Transaction, + ) { // Retreive payment receive or throw not found service error. const oldPaymentReceive = await this.paymentReceiveModel() .query() @@ -79,6 +82,6 @@ export class DeletePaymentReceivedService { oldPaymentReceive, trx, } as IPaymentReceivedDeletedPayload); - }); + }, trx); } } diff --git a/packages/server/src/modules/SaleEstimates/BulkDeleteSaleEstimates.service.ts b/packages/server/src/modules/SaleEstimates/BulkDeleteSaleEstimates.service.ts new file mode 100644 index 000000000..e5dc864e5 --- /dev/null +++ b/packages/server/src/modules/SaleEstimates/BulkDeleteSaleEstimates.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteSaleEstimate } from './commands/DeleteSaleEstimate.service'; + +@Injectable() +export class BulkDeleteSaleEstimatesService { + constructor(private readonly deleteSaleEstimateService: DeleteSaleEstimate) { } + + async bulkDeleteSaleEstimates( + saleEstimateIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const estimatesIds = uniq(castArray(saleEstimateIds)); + + const results = await PromisePool.withConcurrency(1) + .for(estimatesIds) + .process(async (saleEstimateId: number) => { + try { + await this.deleteSaleEstimateService.deleteEstimate(saleEstimateId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/SaleEstimates/SaleEstimates.application.ts b/packages/server/src/modules/SaleEstimates/SaleEstimates.application.ts index a8424755a..f81042a8d 100644 --- a/packages/server/src/modules/SaleEstimates/SaleEstimates.application.ts +++ b/packages/server/src/modules/SaleEstimates/SaleEstimates.application.ts @@ -19,6 +19,8 @@ import { EditSaleEstimateDto, } from './dtos/SaleEstimate.dto'; import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service'; +import { BulkDeleteSaleEstimatesService } from './BulkDeleteSaleEstimates.service'; +import { ValidateBulkDeleteSaleEstimatesService } from './ValidateBulkDeleteSaleEstimates.service'; @Injectable() export class SaleEstimatesApplication { @@ -35,7 +37,9 @@ export class SaleEstimatesApplication { private readonly getSaleEstimateStateService: GetSaleEstimateState, private readonly saleEstimatesPdfService: GetSaleEstimatePdf, private readonly getSaleEstimateMailStateService: GetSaleEstimateMailStateService, - ) {} + private readonly bulkDeleteSaleEstimatesService: BulkDeleteSaleEstimatesService, + private readonly validateBulkDeleteSaleEstimatesService: ValidateBulkDeleteSaleEstimatesService, + ) { } /** * Create a sale estimate. @@ -68,6 +72,31 @@ export class SaleEstimatesApplication { return this.deleteSaleEstimateService.deleteEstimate(estimateId); } + /** + * Deletes multiple sale estimates. + * @param {number[]} saleEstimateIds + * @return {Promise} + */ + public bulkDeleteSaleEstimates( + saleEstimateIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeleteSaleEstimatesService.bulkDeleteSaleEstimates( + saleEstimateIds, + options, + ); + } + + /** + * Validates which sale estimates can be deleted. + * @param {number[]} saleEstimateIds + */ + public validateBulkDeleteSaleEstimates(saleEstimateIds: number[]) { + return this.validateBulkDeleteSaleEstimatesService.validateBulkDeleteSaleEstimates( + saleEstimateIds, + ); + } + /** * Retrieves the given sale estimate. * @param {number} estimateId - Sale estimate ID. diff --git a/packages/server/src/modules/SaleEstimates/SaleEstimates.controller.ts b/packages/server/src/modules/SaleEstimates/SaleEstimates.controller.ts index 449dbfce2..a8fe3d2f5 100644 --- a/packages/server/src/modules/SaleEstimates/SaleEstimates.controller.ts +++ b/packages/server/src/modules/SaleEstimates/SaleEstimates.controller.ts @@ -36,6 +36,10 @@ import { SaleEstimateResponseDto } from './dtos/SaleEstimateResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { SaleEstiamteStateResponseDto } from './dtos/SaleEstimateStateResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('sale-estimates') @ApiTags('Sale Estimates') @@ -43,13 +47,50 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @ApiExtraModels(PaginatedResponseDto) @ApiExtraModels(SaleEstiamteStateResponseDto) @ApiCommonHeaders() +@ApiExtraModels(ValidateBulkDeleteResponseDto) export class SaleEstimatesController { + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which sale estimates can be deleted and returns the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable sale estimates.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + public validateBulkDeleteSaleEstimates( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.saleEstimatesApplication.validateBulkDeleteSaleEstimates( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple sale estimates.' }) + @ApiResponse({ + status: 200, + description: 'Sale estimates deleted successfully', + }) + public bulkDeleteSaleEstimates( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.saleEstimatesApplication.bulkDeleteSaleEstimates( + bulkDeleteDto.ids, + { skipUndeletable: bulkDeleteDto.skipUndeletable ?? false }, + ); + } + /** * @param {SaleEstimatesApplication} saleEstimatesApplication - Sale estimates application. */ constructor( private readonly saleEstimatesApplication: SaleEstimatesApplication, - ) {} + ) { } @Post() @ApiOperation({ summary: 'Create a new sale estimate.' }) diff --git a/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts b/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts index d4efa4564..a19ea923e 100644 --- a/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts +++ b/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts @@ -40,6 +40,8 @@ import { SaleEstimatesImportable } from './SaleEstimatesImportable'; import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service'; import { GetSaleEstimateMailTemplateService } from './queries/GetSaleEstimateMailTemplate.service'; import { SaleEstimateAutoIncrementSubscriber } from './subscribers/SaleEstimateAutoIncrementSubscriber'; +import { BulkDeleteSaleEstimatesService } from './BulkDeleteSaleEstimates.service'; +import { ValidateBulkDeleteSaleEstimatesService } from './ValidateBulkDeleteSaleEstimates.service'; @Module({ imports: [ @@ -85,6 +87,8 @@ import { SaleEstimateAutoIncrementSubscriber } from './subscribers/SaleEstimateA GetSaleEstimateMailStateService, GetSaleEstimateMailTemplateService, SaleEstimateAutoIncrementSubscriber, + BulkDeleteSaleEstimatesService, + ValidateBulkDeleteSaleEstimatesService, ], exports: [ SaleEstimatesExportable, diff --git a/packages/server/src/modules/SaleEstimates/ValidateBulkDeleteSaleEstimates.service.ts b/packages/server/src/modules/SaleEstimates/ValidateBulkDeleteSaleEstimates.service.ts new file mode 100644 index 000000000..3ab7609d0 --- /dev/null +++ b/packages/server/src/modules/SaleEstimates/ValidateBulkDeleteSaleEstimates.service.ts @@ -0,0 +1,55 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteSaleEstimate } from './commands/DeleteSaleEstimate.service'; + +@Injectable() +export class ValidateBulkDeleteSaleEstimatesService { + constructor( + private readonly deleteSaleEstimateService: DeleteSaleEstimate, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) { } + + public async validateBulkDeleteSaleEstimates( + saleEstimateIds: number[], + ): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const saleEstimateId of saleEstimateIds) { + try { + await this.deleteSaleEstimateService.deleteEstimate( + saleEstimateId, + trx, + ); + deletableIds.push(saleEstimateId); + } catch (error) { + nonDeletableIds.push(saleEstimateId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} diff --git a/packages/server/src/modules/SaleEstimates/commands/DeleteSaleEstimate.service.ts b/packages/server/src/modules/SaleEstimates/commands/DeleteSaleEstimate.service.ts index 823c60bb2..5a1e5b5ff 100644 --- a/packages/server/src/modules/SaleEstimates/commands/DeleteSaleEstimate.service.ts +++ b/packages/server/src/modules/SaleEstimates/commands/DeleteSaleEstimate.service.ts @@ -24,18 +24,22 @@ export class DeleteSaleEstimate { private readonly eventPublisher: EventEmitter2, private readonly uow: UnitOfWork, - ) {} + ) { } /** * Deletes the given estimate id with associated entries. * @async - * @param {number} estimateId + * @param {number} estimateId - Sale estimate id. + * @param {Knex.Transaction} trx - Database transaction instance. * @return {Promise} */ - public async deleteEstimate(estimateId: number): Promise { + public async deleteEstimate( + estimateId: number, + trx?: Knex.Transaction, + ): Promise { // Retrieve sale estimate or throw not found service error. const oldSaleEstimate = await this.saleEstimateModel() - .query() + .query(trx) .findById(estimateId) .throwIfNotFound(); @@ -70,6 +74,6 @@ export class DeleteSaleEstimate { oldSaleEstimate, trx, } as ISaleEstimateDeletedPayload); - }); + }, trx); } } diff --git a/packages/server/src/modules/SaleInvoices/BulkDeleteSaleInvoices.service.ts b/packages/server/src/modules/SaleInvoices/BulkDeleteSaleInvoices.service.ts new file mode 100644 index 000000000..142634787 --- /dev/null +++ b/packages/server/src/modules/SaleInvoices/BulkDeleteSaleInvoices.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteSaleInvoice } from './commands/DeleteSaleInvoice.service'; + +@Injectable() +export class BulkDeleteSaleInvoicesService { + constructor(private readonly deleteSaleInvoiceService: DeleteSaleInvoice) { } + + async bulkDeleteSaleInvoices( + saleInvoiceIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const invoicesIds = uniq(castArray(saleInvoiceIds)); + + const results = await PromisePool.withConcurrency(1) + .for(invoicesIds) + .process(async (saleInvoiceId: number) => { + try { + await this.deleteSaleInvoiceService.deleteSaleInvoice(saleInvoiceId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} diff --git a/packages/server/src/modules/SaleInvoices/SaleInvoices.application.ts b/packages/server/src/modules/SaleInvoices/SaleInvoices.application.ts index 8c4911c10..0cd0535ab 100644 --- a/packages/server/src/modules/SaleInvoices/SaleInvoices.application.ts +++ b/packages/server/src/modules/SaleInvoices/SaleInvoices.application.ts @@ -23,6 +23,8 @@ import { EditSaleInvoiceDto, } from './dtos/SaleInvoice.dto'; import { GenerateShareLink } from './commands/GenerateInvoicePaymentLink.service'; +import { BulkDeleteSaleInvoicesService } from './BulkDeleteSaleInvoices.service'; +import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleInvoices.service'; @Injectable() export class SaleInvoiceApplication { @@ -41,7 +43,9 @@ export class SaleInvoiceApplication { private sendSaleInvoiceMailService: SendSaleInvoiceMail, private getSaleInvoiceMailStateService: GetSaleInvoiceMailState, private generateShareLinkService: GenerateShareLink, - ) {} + private bulkDeleteSaleInvoicesService: BulkDeleteSaleInvoicesService, + private validateBulkDeleteSaleInvoicesService: ValidateBulkDeleteSaleInvoicesService, + ) { } /** * Creates a new sale invoice with associated GL entries. @@ -78,6 +82,31 @@ export class SaleInvoiceApplication { return this.deleteSaleInvoiceService.deleteSaleInvoice(saleInvoiceId); } + /** + * Deletes multiple sale invoices. + * @param {number[]} saleInvoiceIds + * @return {Promise} + */ + public bulkDeleteSaleInvoices( + saleInvoiceIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeleteSaleInvoicesService.bulkDeleteSaleInvoices( + saleInvoiceIds, + options, + ); + } + + /** + * Validates which sale invoices can be deleted. + * @param {number[]} saleInvoiceIds + */ + public validateBulkDeleteSaleInvoices(saleInvoiceIds: number[]) { + return this.validateBulkDeleteSaleInvoicesService.validateBulkDeleteSaleInvoices( + saleInvoiceIds, + ); + } + /** * Retrieves the given sale invoice details. * @param {ISalesInvoicesFilter} filterDTO diff --git a/packages/server/src/modules/SaleInvoices/SaleInvoices.controller.ts b/packages/server/src/modules/SaleInvoices/SaleInvoices.controller.ts index 91166e1ed..476c7cd3b 100644 --- a/packages/server/src/modules/SaleInvoices/SaleInvoices.controller.ts +++ b/packages/server/src/modules/SaleInvoices/SaleInvoices.controller.ts @@ -39,6 +39,10 @@ import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { SaleInvoiceStateResponseDto } from './dtos/SaleInvoiceState.dto'; import { GenerateSaleInvoiceSharableLinkResponseDto } from './dtos/GenerateSaleInvoiceSharableLinkResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('sale-invoices') @ApiTags('Sale Invoices') @@ -47,9 +51,46 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @ApiExtraModels(SaleInvoiceStateResponseDto) @ApiExtraModels(GenerateSaleInvoiceSharableLinkResponseDto) @ApiCommonHeaders() +@ApiExtraModels(ValidateBulkDeleteResponseDto) export class SaleInvoicesController { constructor(private saleInvoiceApplication: SaleInvoiceApplication) { } + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which sale invoices can be deleted and returns the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable sale invoices.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + validateBulkDeleteSaleInvoices( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.saleInvoiceApplication.validateBulkDeleteSaleInvoices( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple sale invoices.' }) + @ApiResponse({ + status: 200, + description: 'Sale invoices deleted successfully', + }) + bulkDeleteSaleInvoices( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.saleInvoiceApplication.bulkDeleteSaleInvoices( + bulkDeleteDto.ids, + { skipUndeletable: bulkDeleteDto.skipUndeletable ?? false }, + ); + } + @Post() @ApiOperation({ summary: 'Create a new sale invoice.' }) @ApiResponse({ diff --git a/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts b/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts index 50f3e614a..f21766f15 100644 --- a/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts +++ b/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts @@ -60,6 +60,8 @@ import { SaleInvoicesCost } from './SalesInvoicesCost'; import { SaleInvoicesExportable } from './commands/SaleInvoicesExportable'; import { SaleInvoicesImportable } from './commands/SaleInvoicesImportable'; import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module'; +import { BulkDeleteSaleInvoicesService } from './BulkDeleteSaleInvoices.service'; +import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleInvoices.service'; @Module({ imports: [ @@ -126,6 +128,8 @@ import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module'; SaleInvoicesCost, SaleInvoicesExportable, SaleInvoicesImportable, + BulkDeleteSaleInvoicesService, + ValidateBulkDeleteSaleInvoicesService, ], exports: [ GetSaleInvoice, diff --git a/packages/server/src/modules/SaleInvoices/ValidateBulkDeleteSaleInvoices.service.ts b/packages/server/src/modules/SaleInvoices/ValidateBulkDeleteSaleInvoices.service.ts new file mode 100644 index 000000000..167369f83 --- /dev/null +++ b/packages/server/src/modules/SaleInvoices/ValidateBulkDeleteSaleInvoices.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteSaleInvoice } from './commands/DeleteSaleInvoice.service'; + +@Injectable() +export class ValidateBulkDeleteSaleInvoicesService { + constructor( + private readonly deleteSaleInvoiceService: DeleteSaleInvoice, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) { } + + public async validateBulkDeleteSaleInvoices(saleInvoiceIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const saleInvoiceId of saleInvoiceIds) { + try { + await this.deleteSaleInvoiceService.deleteSaleInvoice( + saleInvoiceId, + trx, + ); + deletableIds.push(saleInvoiceId); + } catch (error) { + nonDeletableIds.push(saleInvoiceId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/SaleInvoices/commands/DeleteSaleInvoice.service.ts b/packages/server/src/modules/SaleInvoices/commands/DeleteSaleInvoice.service.ts index 1f3e0c91e..d586f6f9e 100644 --- a/packages/server/src/modules/SaleInvoices/commands/DeleteSaleInvoice.service.ts +++ b/packages/server/src/modules/SaleInvoices/commands/DeleteSaleInvoice.service.ts @@ -47,7 +47,7 @@ export class DeleteSaleInvoice { @Inject(ItemEntry.name) private itemEntryModel: TenantModelProxy, - ) {} + ) { } /** * Validate the sale invoice has no payment entries. @@ -86,9 +86,12 @@ export class DeleteSaleInvoice { * Deletes the given sale invoice with associated entries * and journal transactions. * @param {Number} saleInvoiceId - The given sale invoice id. - * @param {ISystemUser} authorizedUser - + * @param {Knex.Transaction} trx - Database transaction instance. */ - public async deleteSaleInvoice(saleInvoiceId: number): Promise { + public async deleteSaleInvoice( + saleInvoiceId: number, + trx?: Knex.Transaction, + ): Promise { // Retrieve the given sale invoice with associated entries // or throw not found error. const oldSaleInvoice = await this.saleInvoiceModel() @@ -138,6 +141,6 @@ export class DeleteSaleInvoice { saleInvoiceId, trx, } as ISaleInvoiceDeletedPayload); - }); + }, trx); } } diff --git a/packages/server/src/modules/SaleReceipts/BulkDeleteSaleReceipts.service.ts b/packages/server/src/modules/SaleReceipts/BulkDeleteSaleReceipts.service.ts new file mode 100644 index 000000000..04c605608 --- /dev/null +++ b/packages/server/src/modules/SaleReceipts/BulkDeleteSaleReceipts.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteSaleReceipt } from './commands/DeleteSaleReceipt.service'; + +@Injectable() +export class BulkDeleteSaleReceiptsService { + constructor( + private readonly deleteSaleReceiptService: DeleteSaleReceipt, + ) { } + + async bulkDeleteSaleReceipts( + saleReceiptIds: number | number[], + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const receiptIds = uniq(castArray(saleReceiptIds)); + + const results = await PromisePool.withConcurrency(1) + .for(receiptIds) + .process(async (saleReceiptId: number) => { + try { + await this.deleteSaleReceiptService.deleteSaleReceipt(saleReceiptId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + + diff --git a/packages/server/src/modules/SaleReceipts/SaleReceiptApplication.service.ts b/packages/server/src/modules/SaleReceipts/SaleReceiptApplication.service.ts index 33a83ca6e..9b97def9a 100644 --- a/packages/server/src/modules/SaleReceipts/SaleReceiptApplication.service.ts +++ b/packages/server/src/modules/SaleReceipts/SaleReceiptApplication.service.ts @@ -22,6 +22,8 @@ import { EditSaleReceiptDto, } from './dtos/SaleReceipt.dto'; import { GetSaleReceiptMailStateService } from './queries/GetSaleReceiptMailState.service'; +import { BulkDeleteSaleReceiptsService } from './BulkDeleteSaleReceipts.service'; +import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleReceipts.service'; @Injectable() export class SaleReceiptApplication { @@ -36,7 +38,9 @@ export class SaleReceiptApplication { private getSaleReceiptStateService: GetSaleReceiptState, private saleReceiptNotifyByMailService: SaleReceiptMailNotification, private getSaleReceiptMailStateService: GetSaleReceiptMailStateService, - ) {} + private bulkDeleteSaleReceiptsService: BulkDeleteSaleReceiptsService, + private validateBulkDeleteSaleReceiptsService: ValidateBulkDeleteSaleReceiptsService, + ) { } /** * Creates a new sale receipt with associated entries. @@ -85,6 +89,30 @@ export class SaleReceiptApplication { return this.deleteSaleReceiptService.deleteSaleReceipt(saleReceiptId); } + /** + * Deletes multiple sale receipts. + * @param {number[]} saleReceiptIds + */ + public async bulkDeleteSaleReceipts( + saleReceiptIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeleteSaleReceiptsService.bulkDeleteSaleReceipts( + saleReceiptIds, + options, + ); + } + + /** + * Validates which sale receipts can be deleted. + * @param {number[]} saleReceiptIds + */ + public async validateBulkDeleteSaleReceipts(saleReceiptIds: number[]) { + return this.validateBulkDeleteSaleReceiptsService.validateBulkDeleteSaleReceipts( + saleReceiptIds, + ); + } + /** * Retrieve sales receipts paginated and filterable list. * @param {ISalesReceiptsFilter} filterDTO diff --git a/packages/server/src/modules/SaleReceipts/SaleReceipts.controller.ts b/packages/server/src/modules/SaleReceipts/SaleReceipts.controller.ts index c0b1dc959..9b907f7e0 100644 --- a/packages/server/src/modules/SaleReceipts/SaleReceipts.controller.ts +++ b/packages/server/src/modules/SaleReceipts/SaleReceipts.controller.ts @@ -32,6 +32,10 @@ import { SaleReceiptResponseDto } from './dtos/SaleReceiptResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { SaleReceiptStateResponseDto } from './dtos/SaleReceiptState.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('sale-receipts') @ApiTags('Sale Receipts') @@ -39,8 +43,45 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @ApiExtraModels(PaginatedResponseDto) @ApiExtraModels(SaleReceiptStateResponseDto) @ApiCommonHeaders() +@ApiExtraModels(ValidateBulkDeleteResponseDto) export class SaleReceiptsController { - constructor(private saleReceiptApplication: SaleReceiptApplication) {} + constructor(private saleReceiptApplication: SaleReceiptApplication) { } + + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which sale receipts can be deleted and returns the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable sale receipts.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + validateBulkDeleteSaleReceipts( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.saleReceiptApplication.validateBulkDeleteSaleReceipts( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple sale receipts.' }) + @ApiResponse({ + status: 200, + description: 'Sale receipts deleted successfully', + }) + bulkDeleteSaleReceipts( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.saleReceiptApplication.bulkDeleteSaleReceipts( + bulkDeleteDto.ids, + { skipUndeletable: bulkDeleteDto.skipUndeletable ?? false }, + ); + } @Post() @ApiOperation({ summary: 'Create a new sale receipt.' }) diff --git a/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts b/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts index 1deb8aae6..faf83b892 100644 --- a/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts +++ b/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts @@ -40,6 +40,8 @@ import { SaleReceiptsImportable } from './commands/SaleReceiptsImportable'; import { GetSaleReceiptMailStateService } from './queries/GetSaleReceiptMailState.service'; import { GetSaleReceiptMailTemplateService } from './queries/GetSaleReceiptMailTemplate.service'; import { SaleReceiptAutoIncrementSubscriber } from './subscribers/SaleReceiptAutoIncrementSubscriber'; +import { BulkDeleteSaleReceiptsService } from './BulkDeleteSaleReceipts.service'; +import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleReceipts.service'; @Module({ controllers: [SaleReceiptsController], @@ -85,6 +87,8 @@ import { SaleReceiptAutoIncrementSubscriber } from './subscribers/SaleReceiptAut GetSaleReceiptMailStateService, GetSaleReceiptMailTemplateService, SaleReceiptAutoIncrementSubscriber, + BulkDeleteSaleReceiptsService, + ValidateBulkDeleteSaleReceiptsService, ], }) export class SaleReceiptsModule { } diff --git a/packages/server/src/modules/SaleReceipts/ValidateBulkDeleteSaleReceipts.service.ts b/packages/server/src/modules/SaleReceipts/ValidateBulkDeleteSaleReceipts.service.ts new file mode 100644 index 000000000..c824eed91 --- /dev/null +++ b/packages/server/src/modules/SaleReceipts/ValidateBulkDeleteSaleReceipts.service.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteSaleReceipt } from './commands/DeleteSaleReceipt.service'; + +@Injectable() +export class ValidateBulkDeleteSaleReceiptsService { + constructor( + private readonly deleteSaleReceiptService: DeleteSaleReceipt, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) {} + + public async validateBulkDeleteSaleReceipts( + saleReceiptIds: number[], + ): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const saleReceiptId of saleReceiptIds) { + try { + await this.deleteSaleReceiptService.deleteSaleReceipt( + saleReceiptId, + trx, + ); + deletableIds.push(saleReceiptId); + } catch (error) { + nonDeletableIds.push(saleReceiptId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + + diff --git a/packages/server/src/modules/SaleReceipts/commands/DeleteSaleReceipt.service.ts b/packages/server/src/modules/SaleReceipts/commands/DeleteSaleReceipt.service.ts index ef89df19b..72aeb648f 100644 --- a/packages/server/src/modules/SaleReceipts/commands/DeleteSaleReceipt.service.ts +++ b/packages/server/src/modules/SaleReceipts/commands/DeleteSaleReceipt.service.ts @@ -29,9 +29,13 @@ export class DeleteSaleReceipt { /** * Deletes the sale receipt with associated entries. * @param {Integer} saleReceiptId - Sale receipt identifier. + * @param {Knex.Transaction} trx - Database transaction instance. * @return {void} */ - public async deleteSaleReceipt(saleReceiptId: number) { + public async deleteSaleReceipt( + saleReceiptId: number, + trx?: Knex.Transaction, + ) { const oldSaleReceipt = await this.saleReceiptModel() .query() .findById(saleReceiptId) @@ -65,6 +69,6 @@ export class DeleteSaleReceipt { oldSaleReceipt, trx, } as ISaleReceiptEventDeletedPayload); - }); + }, trx); } } diff --git a/packages/server/src/modules/VendorCredit/BulkDeleteVendorCredits.service.ts b/packages/server/src/modules/VendorCredit/BulkDeleteVendorCredits.service.ts new file mode 100644 index 000000000..a77e6b818 --- /dev/null +++ b/packages/server/src/modules/VendorCredit/BulkDeleteVendorCredits.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteVendorCreditService } from './commands/DeleteVendorCredit.service'; + +@Injectable() +export class BulkDeleteVendorCreditsService { + constructor( + private readonly deleteVendorCreditService: DeleteVendorCreditService, + ) { } + + async bulkDeleteVendorCredits( + vendorCreditIds: number | Array, + options?: { skipUndeletable?: boolean }, + trx?: Knex.Transaction, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const creditsIds = uniq(castArray(vendorCreditIds)); + + const results = await PromisePool.withConcurrency(1) + .for(creditsIds) + .process(async (vendorCreditId: number) => { + try { + await this.deleteVendorCreditService.deleteVendorCredit( + vendorCreditId, + ); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw; + } + } +} + diff --git a/packages/server/src/modules/VendorCredit/ValidateBulkDeleteVendorCredits.service.ts b/packages/server/src/modules/VendorCredit/ValidateBulkDeleteVendorCredits.service.ts new file mode 100644 index 000000000..c5a4d1929 --- /dev/null +++ b/packages/server/src/modules/VendorCredit/ValidateBulkDeleteVendorCredits.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteVendorCreditService } from './commands/DeleteVendorCredit.service'; + +@Injectable() +export class ValidateBulkDeleteVendorCreditsService { + constructor( + private readonly deleteVendorCreditService: DeleteVendorCreditService, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) { } + + public async validateBulkDeleteVendorCredits( + vendorCreditIds: number[], + ): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const vendorCreditId of vendorCreditIds) { + try { + await this.deleteVendorCreditService.deleteVendorCredit( + vendorCreditId, + trx, + ); + deletableIds.push(vendorCreditId); + } catch (error) { + nonDeletableIds.push(vendorCreditId); + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/VendorCredit/VendorCredits.controller.ts b/packages/server/src/modules/VendorCredit/VendorCredits.controller.ts index 2f0c85235..a98bcf9b7 100644 --- a/packages/server/src/modules/VendorCredit/VendorCredits.controller.ts +++ b/packages/server/src/modules/VendorCredit/VendorCredits.controller.ts @@ -10,20 +10,67 @@ import { } from '@nestjs/common'; import { VendorCreditsApplicationService } from './VendorCreditsApplication.service'; import { IVendorCreditsQueryDTO } from './types/VendorCredit.types'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiExtraModels, + ApiOperation, + ApiResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; import { CreateVendorCreditDto, EditVendorCreditDto, } from './dtos/VendorCredit.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteDto, + ValidateBulkDeleteResponseDto, +} from '@/common/dtos/BulkDelete.dto'; @Controller('vendor-credits') @ApiTags('Vendor Credits') @ApiCommonHeaders() +@ApiExtraModels(ValidateBulkDeleteResponseDto) export class VendorCreditsController { constructor( private readonly vendorCreditsApplication: VendorCreditsApplicationService, - ) {} + ) { } + + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which vendor credits can be deleted and returns the results.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed with counts and IDs of deletable and non-deletable vendor credits.', + schema: { + $ref: getSchemaPath(ValidateBulkDeleteResponseDto), + }, + }) + async validateBulkDeleteVendorCredits( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.vendorCreditsApplication.validateBulkDeleteVendorCredits( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple vendor credits.' }) + @ApiResponse({ + status: 200, + description: 'Vendor credits deleted successfully', + }) + async bulkDeleteVendorCredits( + @Body() bulkDeleteDto: BulkDeleteDto, + ): Promise { + return this.vendorCreditsApplication.bulkDeleteVendorCredits( + bulkDeleteDto.ids, + { skipUndeletable: bulkDeleteDto.skipUndeletable ?? false }, + ); + } @Post() @ApiOperation({ summary: 'Create a new vendor credit.' }) diff --git a/packages/server/src/modules/VendorCredit/VendorCredits.module.ts b/packages/server/src/modules/VendorCredit/VendorCredits.module.ts index 58338b8d8..ff9e9365a 100644 --- a/packages/server/src/modules/VendorCredit/VendorCredits.module.ts +++ b/packages/server/src/modules/VendorCredit/VendorCredits.module.ts @@ -28,6 +28,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { InventoryCostModule } from '../InventoryCost/InventoryCost.module'; import { VendorCreditsExportable } from './commands/VendorCreditsExportable'; import { VendorCreditsImportable } from './commands/VendorCreditsImportable'; +import { BulkDeleteVendorCreditsService } from './BulkDeleteVendorCredits.service'; +import { ValidateBulkDeleteVendorCreditsService } from './ValidateBulkDeleteVendorCredits.service'; @Module({ imports: [ @@ -61,6 +63,8 @@ import { VendorCreditsImportable } from './commands/VendorCreditsImportable'; VendorCreditAutoSerialSubscriber, VendorCreditsExportable, VendorCreditsImportable, + BulkDeleteVendorCreditsService, + ValidateBulkDeleteVendorCreditsService, ], exports: [ CreateVendorCreditService, @@ -74,6 +78,8 @@ import { VendorCreditsImportable } from './commands/VendorCreditsImportable'; OpenVendorCreditService, VendorCreditsExportable, VendorCreditsImportable, + BulkDeleteVendorCreditsService, + ValidateBulkDeleteVendorCreditsService, ], controllers: [VendorCreditsController], }) diff --git a/packages/server/src/modules/VendorCredit/VendorCreditsApplication.service.ts b/packages/server/src/modules/VendorCredit/VendorCreditsApplication.service.ts index 47b709f2e..6d0a1003d 100644 --- a/packages/server/src/modules/VendorCredit/VendorCreditsApplication.service.ts +++ b/packages/server/src/modules/VendorCredit/VendorCreditsApplication.service.ts @@ -3,12 +3,21 @@ import { CreateVendorCreditService } from './commands/CreateVendorCredit.service import { DeleteVendorCreditService } from './commands/DeleteVendorCredit.service'; import { EditVendorCreditService } from './commands/EditVendorCredit.service'; import { GetVendorCreditService } from './queries/GetVendorCredit.service'; -import { IVendorCreditEditDTO, IVendorCreditsQueryDTO } from './types/VendorCredit.types'; +import { + IVendorCreditEditDTO, + IVendorCreditsQueryDTO, +} from './types/VendorCredit.types'; import { IVendorCreditCreateDTO } from './types/VendorCredit.types'; import { Injectable } from '@nestjs/common'; import { OpenVendorCreditService } from './commands/OpenVendorCredit.service'; import { GetVendorCreditsService } from './queries/GetVendorCredits.service'; -import { CreateVendorCreditDto, EditVendorCreditDto } from './dtos/VendorCredit.dto'; +import { + CreateVendorCreditDto, + EditVendorCreditDto, +} from './dtos/VendorCredit.dto'; +import { BulkDeleteVendorCreditsService } from './BulkDeleteVendorCredits.service'; +import { ValidateBulkDeleteVendorCreditsService } from './ValidateBulkDeleteVendorCredits.service'; +import { ValidateBulkDeleteResponseDto } from '@/common/dtos/BulkDelete.dto'; @Injectable() export class VendorCreditsApplicationService { @@ -25,7 +34,9 @@ export class VendorCreditsApplicationService { private readonly getVendorCreditService: GetVendorCreditService, private readonly openVendorCreditService: OpenVendorCreditService, private readonly getVendorCreditsService: GetVendorCreditsService, - ) {} + private readonly bulkDeleteVendorCreditsService: BulkDeleteVendorCreditsService, + private readonly validateBulkDeleteVendorCreditsService: ValidateBulkDeleteVendorCreditsService, + ) { } /** * Creates a new vendor credit. @@ -90,4 +101,22 @@ export class VendorCreditsApplicationService { getVendorCredits(query: IVendorCreditsQueryDTO) { return this.getVendorCreditsService.getVendorCredits(query); } + + bulkDeleteVendorCredits( + vendorCreditIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeleteVendorCreditsService.bulkDeleteVendorCredits( + vendorCreditIds, + options, + ); + } + + validateBulkDeleteVendorCredits( + vendorCreditIds: number[], + ): Promise { + return this.validateBulkDeleteVendorCreditsService.validateBulkDeleteVendorCredits( + vendorCreditIds, + ); + } } diff --git a/packages/server/src/modules/VendorCredit/commands/DeleteVendorCredit.service.ts b/packages/server/src/modules/VendorCredit/commands/DeleteVendorCredit.service.ts index f50987cc4..8f537633e 100644 --- a/packages/server/src/modules/VendorCredit/commands/DeleteVendorCredit.service.ts +++ b/packages/server/src/modules/VendorCredit/commands/DeleteVendorCredit.service.ts @@ -93,7 +93,7 @@ export class DeleteVendorCreditService { oldVendorCredit, trx, } as IVendorCreditDeletedPayload); - }); + }, trx); }; /** diff --git a/packages/server/src/modules/Vendors/BulkDeleteVendors.service.ts b/packages/server/src/modules/Vendors/BulkDeleteVendors.service.ts new file mode 100644 index 000000000..d25d04176 --- /dev/null +++ b/packages/server/src/modules/Vendors/BulkDeleteVendors.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { castArray, uniq } from 'lodash'; +import { PromisePool } from '@supercharge/promise-pool'; +import { DeleteVendorService } from './commands/DeleteVendor.service'; + +@Injectable() +export class BulkDeleteVendorsService { + constructor(private readonly deleteVendorService: DeleteVendorService) {} + + public async bulkDeleteVendors( + vendorIds: number[], + options?: { skipUndeletable?: boolean }, + ): Promise { + const { skipUndeletable = false } = options ?? {}; + const ids = uniq(castArray(vendorIds)); + + const results = await PromisePool.withConcurrency(1) + .for(ids) + .process(async (vendorId: number) => { + try { + await this.deleteVendorService.deleteVendor(vendorId); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } + }); + + if (!skipUndeletable && results.errors && results.errors.length > 0) { + throw results.errors[0].raw ?? results.errors[0]; + } + } +} + diff --git a/packages/server/src/modules/Vendors/ValidateBulkDeleteVendors.service.ts b/packages/server/src/modules/Vendors/ValidateBulkDeleteVendors.service.ts new file mode 100644 index 000000000..8f51d36de --- /dev/null +++ b/packages/server/src/modules/Vendors/ValidateBulkDeleteVendors.service.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants'; +import { DeleteVendorService } from './commands/DeleteVendor.service'; +import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Injectable() +export class ValidateBulkDeleteVendorsService { + constructor( + private readonly deleteVendorService: DeleteVendorService, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) {} + + public async validateBulkDeleteVendors(vendorIds: number[]): Promise<{ + deletableCount: number; + nonDeletableCount: number; + deletableIds: number[]; + nonDeletableIds: number[]; + }> { + const trx = await this.tenantKnex().transaction({ + isolationLevel: 'read uncommitted', + }); + + try { + const deletableIds: number[] = []; + const nonDeletableIds: number[] = []; + + for (const vendorId of vendorIds) { + try { + await this.deleteVendorService.deleteVendor(vendorId, trx); + deletableIds.push(vendorId); + } catch (error) { + if ( + error instanceof ModelHasRelationsError || + (error instanceof ServiceError && + error.errorType === 'VENDOR_HAS_TRANSACTIONS') + ) { + nonDeletableIds.push(vendorId); + } else { + nonDeletableIds.push(vendorId); + } + } + } + + await trx.rollback(); + + return { + deletableCount: deletableIds.length, + nonDeletableCount: nonDeletableIds.length, + deletableIds, + nonDeletableIds, + }; + } catch (error) { + await trx.rollback(); + throw error; + } + } +} + diff --git a/packages/server/src/modules/Vendors/Vendors.controller.ts b/packages/server/src/modules/Vendors/Vendors.controller.ts index 6e19e2c32..25d105f03 100644 --- a/packages/server/src/modules/Vendors/Vendors.controller.ts +++ b/packages/server/src/modules/Vendors/Vendors.controller.ts @@ -13,11 +13,20 @@ import { IVendorOpeningBalanceEditDTO, IVendorsFilter, } from './types/Vendors.types'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiOperation, + ApiResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; import { CreateVendorDto } from './dtos/CreateVendor.dto'; import { EditVendorDto } from './dtos/EditVendor.dto'; import { GetVendorsQueryDto } from './dtos/GetVendorsQuery.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + BulkDeleteVendorsDto, + ValidateBulkDeleteVendorsResponseDto, +} from './dtos/BulkDeleteVendors.dto'; @Controller('vendors') @ApiTags('Vendors') @@ -66,4 +75,37 @@ export class VendorsController { openingBalanceDTO, ); } + + @Post('validate-bulk-delete') + @ApiOperation({ + summary: + 'Validates which vendors can be deleted and returns counts of deletable and non-deletable vendors.', + }) + @ApiResponse({ + status: 200, + description: + 'Validation completed. Returns counts and IDs of deletable and non-deletable vendors.', + schema: { $ref: getSchemaPath(ValidateBulkDeleteVendorsResponseDto) }, + }) + validateBulkDeleteVendors( + @Body() bulkDeleteDto: BulkDeleteVendorsDto, + ): Promise { + return this.vendorsApplication.validateBulkDeleteVendors( + bulkDeleteDto.ids, + ); + } + + @Post('bulk-delete') + @ApiOperation({ summary: 'Deletes multiple vendors in bulk.' }) + @ApiResponse({ + status: 200, + description: 'The vendors have been successfully deleted.', + }) + async bulkDeleteVendors( + @Body() bulkDeleteDto: BulkDeleteVendorsDto, + ): Promise { + return this.vendorsApplication.bulkDeleteVendors(bulkDeleteDto.ids, { + skipUndeletable: bulkDeleteDto.skipUndeletable ?? false, + }); + } } diff --git a/packages/server/src/modules/Vendors/Vendors.module.ts b/packages/server/src/modules/Vendors/Vendors.module.ts index b014f8535..ae8c095b4 100644 --- a/packages/server/src/modules/Vendors/Vendors.module.ts +++ b/packages/server/src/modules/Vendors/Vendors.module.ts @@ -16,6 +16,8 @@ import { GetVendorsService } from './queries/GetVendors.service'; import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { VendorsExportable } from './VendorsExportable'; import { VendorsImportable } from './VendorsImportable'; +import { BulkDeleteVendorsService } from './BulkDeleteVendors.service'; +import { ValidateBulkDeleteVendorsService } from './ValidateBulkDeleteVendors.service'; @Module({ imports: [TenancyDatabaseModule, DynamicListModule], @@ -31,6 +33,8 @@ import { VendorsImportable } from './VendorsImportable'; VendorValidators, DeleteVendorService, VendorsApplication, + BulkDeleteVendorsService, + ValidateBulkDeleteVendorsService, TransformerInjectable, TenancyContext, VendorsExportable, diff --git a/packages/server/src/modules/Vendors/VendorsApplication.service.ts b/packages/server/src/modules/Vendors/VendorsApplication.service.ts index c382f9083..69e7afd34 100644 --- a/packages/server/src/modules/Vendors/VendorsApplication.service.ts +++ b/packages/server/src/modules/Vendors/VendorsApplication.service.ts @@ -13,6 +13,8 @@ import { GetVendorsService } from './queries/GetVendors.service'; import { CreateVendorDto } from './dtos/CreateVendor.dto'; import { EditVendorDto } from './dtos/EditVendor.dto'; import { GetVendorsQueryDto } from './dtos/GetVendorsQuery.dto'; +import { BulkDeleteVendorsService } from './BulkDeleteVendors.service'; +import { ValidateBulkDeleteVendorsService } from './ValidateBulkDeleteVendors.service'; @Injectable() export class VendorsApplication { @@ -23,6 +25,8 @@ export class VendorsApplication { private editOpeningBalanceService: EditOpeningBalanceVendorService, private getVendorService: GetVendorService, private getVendorsService: GetVendorsService, + private readonly bulkDeleteVendorsService: BulkDeleteVendorsService, + private readonly validateBulkDeleteVendorsService: ValidateBulkDeleteVendorsService, ) {} /** @@ -86,4 +90,20 @@ export class VendorsApplication { public getVendors(filterDTO: GetVendorsQueryDto) { return this.getVendorsService.getVendorsList(filterDTO); } + + public bulkDeleteVendors( + vendorIds: number[], + options?: { skipUndeletable?: boolean }, + ) { + return this.bulkDeleteVendorsService.bulkDeleteVendors( + vendorIds, + options, + ); + } + + public validateBulkDeleteVendors(vendorIds: number[]) { + return this.validateBulkDeleteVendorsService.validateBulkDeleteVendors( + vendorIds, + ); + } } diff --git a/packages/server/src/modules/Vendors/commands/DeleteVendor.service.ts b/packages/server/src/modules/Vendors/commands/DeleteVendor.service.ts index 598299b95..c6ff58111 100644 --- a/packages/server/src/modules/Vendors/commands/DeleteVendor.service.ts +++ b/packages/server/src/modules/Vendors/commands/DeleteVendor.service.ts @@ -29,12 +29,10 @@ export class DeleteVendorService { * @param {number} vendorId * @return {Promise} */ - public async deleteVendor(vendorId: number) { + public async deleteVendor(vendorId: number, trx?: Knex.Transaction) { // Retrieves the old vendor or throw not found service error. - const oldVendor = await this.vendorModel() - .query() - .findById(vendorId) - .throwIfNotFound(); + const query = this.vendorModel().query(trx); + const oldVendor = await query.findById(vendorId).throwIfNotFound(); // Triggers `onVendorDeleting` event. await this.eventPublisher.emitAsync(events.vendors.onDeleting, { @@ -43,10 +41,10 @@ export class DeleteVendorService { } as IVendorEventDeletingPayload); // Deletes vendor contact under unit-of-work. - return this.uow.withTransaction(async (trx: Knex.Transaction) => { + return this.uow.withTransaction(async (transaction: Knex.Transaction) => { // Deletes the vendor contact from the storage. await this.vendorModel() - .query(trx) + .query(transaction) .findById(vendorId) .deleteIfNoRelations({ type: ERRORS.VENDOR_HAS_TRANSACTIONS, @@ -56,8 +54,8 @@ export class DeleteVendorService { await this.eventPublisher.emitAsync(events.vendors.onDeleted, { vendorId, oldVendor, - trx, + trx: transaction, } as IVendorEventDeletedPayload); - }); + }, trx); } } diff --git a/packages/server/src/modules/Vendors/dtos/BulkDeleteVendors.dto.ts b/packages/server/src/modules/Vendors/dtos/BulkDeleteVendors.dto.ts new file mode 100644 index 000000000..f038ca1c6 --- /dev/null +++ b/packages/server/src/modules/Vendors/dtos/BulkDeleteVendors.dto.ts @@ -0,0 +1,62 @@ +import { + ArrayNotEmpty, + IsArray, + IsInt, + IsOptional, + IsBoolean, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { parseBoolean } from '@/utils/parse-boolean'; + +export class BulkDeleteVendorsDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + @ApiProperty({ + description: 'Array of vendor IDs to delete', + type: [Number], + example: [1, 2, 3], + }) + ids: number[]; + + @IsOptional() + @IsBoolean() + @Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false)) + @ApiPropertyOptional({ + description: + 'When true, undeletable vendors will be skipped and only deletable ones removed.', + type: Boolean, + default: false, + }) + skipUndeletable?: boolean; +} + +export class ValidateBulkDeleteVendorsResponseDto { + @ApiProperty({ + description: 'Number of vendors that can be deleted', + example: 2, + }) + deletableCount: number; + + @ApiProperty({ + description: 'Number of vendors that cannot be deleted', + example: 1, + }) + nonDeletableCount: number; + + @ApiProperty({ + description: 'IDs of vendors that can be deleted', + type: [Number], + example: [1, 2], + }) + deletableIds: number[]; + + @ApiProperty({ + description: 'IDs of vendors that cannot be deleted', + type: [Number], + example: [3], + }) + nonDeletableIds: number[]; +} + diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx index 7f7d2729a..c2d208dda 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -50,6 +50,19 @@ import { DisconnectBankAccountDialog } from '@/containers/CashFlow/AccountTransa import { SharePaymentLinkDialog } from '@/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog'; import { SelectPaymentMethodsDialog } from '@/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsDialog'; import ApiKeysGenerateDialog from '@/containers/Dialogs/ApiKeysGenerateDialog'; +import InvoiceBulkDeleteDialog from '@/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog'; +import EstimateBulkDeleteDialog from '@/containers/Dialogs/Estimates/EstimateBulkDeleteDialog'; +import ReceiptBulkDeleteDialog from '@/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog'; +import CreditNoteBulkDeleteDialog from '@/containers/Dialogs/CreditNotes/CreditNoteBulkDeleteDialog'; +import PaymentReceivedBulkDeleteDialog from '@/containers/Dialogs/PaymentsReceived/PaymentReceivedBulkDeleteDialog'; +import BillBulkDeleteDialog from '@/containers/Dialogs/Bills/BillBulkDeleteDialog'; +import VendorCreditBulkDeleteDialog from '@/containers/Dialogs/VendorCredits/VendorCreditBulkDeleteDialog'; +import ManualJournalBulkDeleteDialog from '@/containers/Dialogs/ManualJournals/ManualJournalBulkDeleteDialog'; +import ExpenseBulkDeleteDialog from '@/containers/Dialogs/Expenses/ExpenseBulkDeleteDialog'; +import AccountBulkDeleteDialog from '@/containers/Dialogs/Accounts/AccountBulkDeleteDialog'; +import ItemBulkDeleteDialog from '@/containers/Dialogs/Items/ItemBulkDeleteDialog'; +import CustomerBulkDeleteDialog from '@/containers/Dialogs/Customers/CustomerBulkDeleteDialog'; +import VendorBulkDeleteDialog from '@/containers/Dialogs/Vendors/VendorBulkDeleteDialog'; /** * Dialogs container. @@ -139,6 +152,27 @@ export default function DialogsContainer() { + + + + + + + + + + + + + { history.push('/make-journal-entry'); }; - // Handle delete button click. - const handleBulkDelete = () => {}; + const { + openBulkDeleteDialog, + isValidatingBulkDeleteManualJournals, + } = useBulkDeleteManualJournalsDialog(); + + const handleBulkDelete = () => { + openBulkDeleteDialog(manualJournalsSelectedRows); + }; // Handle tab change. const handleTabChange = (view) => { @@ -100,6 +109,23 @@ function ManualJournalActionsBar({ downloadExportPdf({ resource: 'ManualJournal' }); }; + if (!isEmpty(manualJournalsSelectedRows)) { + return ( + + + + + + + + + ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withAccountsTableActions, +)(AccountBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/Bills/BillBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Bills/BillBulkDeleteDialog.tsx new file mode 100644 index 000000000..12d5d8770 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/Bills/BillBulkDeleteDialog.tsx @@ -0,0 +1,103 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteBills } from '@/hooks/query/bills'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withBillsActions from '@/containers/Purchases/Bills/BillsLanding/withBillsActions'; +import { compose } from '@/utils'; + +function BillBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withBillsActions + setBillsSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteBills, isLoading } = useBulkDeleteBills(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteBills({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_bills_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setBillsSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withBillsActions, +)(BillBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/CreditNotes/CreditNoteBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/CreditNotes/CreditNoteBulkDeleteDialog.tsx new file mode 100644 index 000000000..819f9d258 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/CreditNotes/CreditNoteBulkDeleteDialog.tsx @@ -0,0 +1,104 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteCreditNotes } from '@/hooks/query/creditNote'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withCreditNotesActions from '@/containers/Sales/CreditNotes/CreditNotesLanding/withCreditNotesActions'; +import { compose } from '@/utils'; + +function CreditNoteBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withCreditNotesActions + setCreditNotesSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteCreditNotes, isLoading } = + useBulkDeleteCreditNotes(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteCreditNotes({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_credit_notes_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setCreditNotesSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withCreditNotesActions, +)(CreditNoteBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/Customers/CustomerBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Customers/CustomerBulkDeleteDialog.tsx new file mode 100644 index 000000000..dd978db40 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/Customers/CustomerBulkDeleteDialog.tsx @@ -0,0 +1,104 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteCustomers } from '@/hooks/query/customers'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withCustomersActions from '@/containers/Customers/CustomersLanding/withCustomersActions'; +import { compose } from '@/utils'; + +function CustomerBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withCustomersActions + setCustomersSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteCustomers, isLoading } = + useBulkDeleteCustomers(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteCustomers({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_customers_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setCustomersSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withCustomersActions, +)(CustomerBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/Estimates/EstimateBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Estimates/EstimateBulkDeleteDialog.tsx new file mode 100644 index 000000000..1a85eccf7 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/Estimates/EstimateBulkDeleteDialog.tsx @@ -0,0 +1,104 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteEstimates } from '@/hooks/query/estimates'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withEstimatesActions from '@/containers/Sales/Estimates/EstimatesLanding/withEstimatesActions'; +import { compose } from '@/utils'; + +function EstimateBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withEstimatesActions + setEstimatesSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteEstimates, isLoading } = + useBulkDeleteEstimates(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteEstimates({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_estimates_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setEstimatesSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withEstimatesActions, +)(EstimateBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/Expenses/ExpenseBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Expenses/ExpenseBulkDeleteDialog.tsx new file mode 100644 index 000000000..f5673cb61 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/Expenses/ExpenseBulkDeleteDialog.tsx @@ -0,0 +1,104 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteExpenses } from '@/hooks/query/expenses'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withExpensesActions from '@/containers/Expenses/ExpensesLanding/withExpensesActions'; +import { compose } from '@/utils'; + +function ExpenseBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withExpensesActions + setExpensesSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteExpenses, isLoading } = + useBulkDeleteExpenses(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteExpenses({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_expenses_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setExpensesSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withExpensesActions, +)(ExpenseBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog.tsx new file mode 100644 index 000000000..8633c5367 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog.tsx @@ -0,0 +1,108 @@ +// @ts-nocheck +import React from 'react'; +import { FormattedMessage as T } from '@/components'; +import intl from 'react-intl-universal'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; + +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withInvoiceActions from '@/containers/Sales/Invoices/InvoicesLanding/withInvoiceActions'; +import { useBulkDeleteInvoices } from '@/hooks/query/invoices'; +import { AppToaster } from '@/components'; +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; + +import { compose } from '@/utils'; + +/** + * Invoice bulk delete dialog. + */ +function InvoiceBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withInvoiceActions + resetInvoicesSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteInvoices, isLoading } = + useBulkDeleteInvoices(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteInvoices({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_invoices_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + resetInvoicesSelectedRows(); + closeDialog(dialogName); + }) + .catch((errors) => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withInvoiceActions, +)(InvoiceBulkDeleteDialog); diff --git a/packages/webapp/src/containers/Dialogs/Items/ItemBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Items/ItemBulkDeleteDialog.tsx new file mode 100644 index 000000000..a3cb8ca9d --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/Items/ItemBulkDeleteDialog.tsx @@ -0,0 +1,103 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteItems } from '@/hooks/query/items'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withItemsActions from '@/containers/Items/withItemsActions'; +import { compose } from '@/utils'; + +function ItemBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withItemsActions + setItemsSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteItems, isLoading } = useBulkDeleteItems(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteItems({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_items_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setItemsSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withItemsActions, +)(ItemBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/ManualJournals/ManualJournalBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/ManualJournals/ManualJournalBulkDeleteDialog.tsx new file mode 100644 index 000000000..1bab4a340 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/ManualJournals/ManualJournalBulkDeleteDialog.tsx @@ -0,0 +1,106 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteManualJournals } from '@/hooks/query/manualJournals'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withManualJournalsActions from '@/containers/Accounting/JournalsLanding/withManualJournalsActions'; +import { compose } from '@/utils'; + +function ManualJournalBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withManualJournalsActions + setManualJournalsSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteManualJournals, isLoading } = + useBulkDeleteManualJournals(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteManualJournals({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_journals_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setManualJournalsSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withManualJournalsActions, +)(ManualJournalBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/PaymentsReceived/PaymentReceivedBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/PaymentsReceived/PaymentReceivedBulkDeleteDialog.tsx new file mode 100644 index 000000000..63bcd88a6 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/PaymentsReceived/PaymentReceivedBulkDeleteDialog.tsx @@ -0,0 +1,108 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeletePaymentReceives } from '@/hooks/query/paymentReceives'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withPaymentsReceivedActions from '@/containers/Sales/PaymentsReceived/PaymentsLanding/withPaymentsReceivedActions'; +import { compose } from '@/utils'; + +function PaymentReceivedBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withPaymentsReceivedActions + setPaymentReceivesSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeletePaymentReceives, isLoading } = + useBulkDeletePaymentReceives(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeletePaymentReceives({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get( + 'the_payments_received_has_been_deleted_successfully', + ), + intent: Intent.SUCCESS, + }); + setPaymentReceivesSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withPaymentsReceivedActions, +)(PaymentReceivedBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog.tsx new file mode 100644 index 000000000..d942b4044 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog.tsx @@ -0,0 +1,104 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteReceipts } from '@/hooks/query/receipts'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withReceiptsActions from '@/containers/Sales/Receipts/ReceiptsLanding/withReceiptsActions'; +import { compose } from '@/utils'; + +function ReceiptBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withReceiptsActions + setReceiptsSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteReceipts, isLoading } = + useBulkDeleteReceipts(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteReceipts({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_receipts_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setReceiptsSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withReceiptsActions, +)(ReceiptBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/VendorCredits/VendorCreditBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/VendorCredits/VendorCreditBulkDeleteDialog.tsx new file mode 100644 index 000000000..253da9607 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/VendorCredits/VendorCreditBulkDeleteDialog.tsx @@ -0,0 +1,106 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteVendorCredits } from '@/hooks/query/vendorCredit'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withVendorsCreditNotesActions from '@/containers/Purchases/CreditNotes/CreditNotesLanding/withVendorsCreditNotesActions'; +import { compose } from '@/utils'; + +function VendorCreditBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withVendorsCreditNotesActions + setVendorsCreditNoteSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteVendorCredits, isLoading } = + useBulkDeleteVendorCredits(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteVendorCredits({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get( + 'the_vendor_credits_has_been_deleted_successfully', + ), + intent: Intent.SUCCESS, + }); + setVendorsCreditNoteSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withVendorsCreditNotesActions, +)(VendorCreditBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/Vendors/VendorBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Vendors/VendorBulkDeleteDialog.tsx new file mode 100644 index 000000000..bcfc13d78 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/Vendors/VendorBulkDeleteDialog.tsx @@ -0,0 +1,103 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T, AppToaster } from '@/components'; +import intl from 'react-intl-universal'; + +import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; +import { useBulkDeleteVendors } from '@/hooks/query/vendors'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withVendorsActions from '@/containers/Vendors/VendorsLanding/withVendorsActions'; +import { compose } from '@/utils'; + +function VendorBulkDeleteDialog({ + dialogName, + isOpen, + payload: { + ids = [], + deletableCount = 0, + undeletableCount = 0, + totalSelected = ids.length, + } = {}, + + // #withVendorsActions + setVendorsSelectedRows, + + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: bulkDeleteVendors, isLoading } = useBulkDeleteVendors(); + + const handleCancel = () => { + closeDialog(dialogName); + }; + + const handleConfirmBulkDelete = () => { + bulkDeleteVendors({ + ids, + skipUndeletable: true, + }) + .then(() => { + AppToaster.show({ + message: intl.get('the_vendors_has_been_deleted_successfully'), + intent: Intent.SUCCESS, + }); + setVendorsSelectedRows([]); + closeDialog(dialogName); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }; + + return ( + + } + isOpen={isOpen} + onClose={handleCancel} + canEscapeKeyClose={!isLoading} + canOutsideClickClose={!isLoading} + > + + +
+
+ + + +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, + withVendorsActions, +)(VendorBulkDeleteDialog); + diff --git a/packages/webapp/src/containers/Dialogs/components/BulkDeleteDialogContent.tsx b/packages/webapp/src/containers/Dialogs/components/BulkDeleteDialogContent.tsx new file mode 100644 index 000000000..6f3068abd --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/components/BulkDeleteDialogContent.tsx @@ -0,0 +1,89 @@ +// @ts-nocheck +import React from 'react'; +import { Classes, Intent, Tag } from '@blueprintjs/core'; +import { FormattedMessage as T } from '@/components'; +import { x } from '@xstyled/emotion'; + +interface BulkDeleteDialogContentProps { + totalSelected: number; + deletableCount: number; + undeletableCount: number; + resourceSingularLabel: string; + resourcePluralLabel: string; +} + +function BulkDeleteDialogContent({ + totalSelected, + deletableCount, + undeletableCount, + resourceSingularLabel, + resourcePluralLabel, +}: BulkDeleteDialogContentProps) { + return ( +
+ + + + + + + {deletableCount} + + + {' '} + + + + + + + + + {undeletableCount} + + + {' '} + + + + + + + + + + + + + + {':'} + + + + + +
+ ); +} + +export default BulkDeleteDialogContent; + diff --git a/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseForm.tsx b/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseForm.tsx index c6f106248..4231a96cc 100644 --- a/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseForm.tsx +++ b/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseForm.tsx @@ -61,13 +61,13 @@ function ExpenseForm({ () => ({ ...(!isEmpty(expense) ? { - ...transformToEditForm(expense, defaultExpense), - } + ...transformToEditForm(expense, defaultExpense), + } : { - ...defaultExpense, - currency_code: base_currency, - payment_account_id: defaultTo(preferredPaymentAccount, ''), - }), + ...defaultExpense, + currency_code: base_currency, + payment_account_id: defaultTo(preferredPaymentAccount, ''), + }), }), [expense, base_currency, preferredPaymentAccount], ); @@ -82,6 +82,7 @@ function ExpenseForm({ message: intl.get('amount_cannot_be_zero_or_empty'), intent: Intent.DANGER, }); + setSubmitting(false); return; } diff --git a/packages/webapp/src/containers/Expenses/ExpensesLanding/ExpenseActionsBar.tsx b/packages/webapp/src/containers/Expenses/ExpensesLanding/ExpenseActionsBar.tsx index f99edb3df..3aaa86217 100644 --- a/packages/webapp/src/containers/Expenses/ExpensesLanding/ExpenseActionsBar.tsx +++ b/packages/webapp/src/containers/Expenses/ExpensesLanding/ExpenseActionsBar.tsx @@ -36,6 +36,8 @@ import withDialogActions from '@/containers/Dialog/withDialogActions'; import withSettings from '@/containers/Settings/withSettings'; import { compose } from '@/utils'; +import { isEmpty } from 'lodash'; +import { useBulkDeleteExpensesDialog } from './hooks/use-bulk-delete-expenses-dialog'; /** * Expenses actions bar. @@ -46,6 +48,7 @@ function ExpensesActionsBar({ // #withExpenses expensesFilterConditions, + expensesSelectedRows = [], // #withSettings expensesTableSize, @@ -72,8 +75,15 @@ function ExpensesActionsBar({ const onClickNewExpense = () => { history.push('/expenses/new'); }; + const { + openBulkDeleteDialog, + isValidatingBulkDeleteExpenses, + } = useBulkDeleteExpensesDialog(); + // Handle delete button click. - const handleBulkDelete = () => {}; + const handleBulkDelete = () => { + openBulkDeleteDialog(expensesSelectedRows); + }; // Handles the tab chaning. const handleTabChange = (view) => { @@ -102,6 +112,23 @@ function ExpensesActionsBar({ downloadExportPdf({ resource: 'Expense' }); }; + if (!isEmpty(expensesSelectedRows)) { + return ( + + +