From 56e00d254b8dee80aa18fe45f5ed974d3279f98b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 20 Nov 2025 17:41:16 +0200 Subject: [PATCH] wip --- .../server/src/common/dtos/BulkDelete.dto.ts | 2 +- .../Customers/BulkDeleteCustomers.service.ts | 34 ++++++ .../modules/Customers/Customers.controller.ts | 39 ++++++- .../src/modules/Customers/Customers.module.ts | 4 + .../Customers/CustomersApplication.service.ts | 20 ++++ .../ValidateBulkDeleteCustomers.service.ts | 61 ++++++++++ .../commands/DeleteCustomer.service.ts | 19 ++-- .../Customers/dtos/BulkDeleteCustomers.dto.ts | 62 +++++++++++ .../modules/Items/BulkDeleteItems.service.ts | 21 ++-- .../src/modules/Items/Item.controller.ts | 4 +- .../modules/Items/ItemsApplication.service.ts | 7 +- .../modules/Items/dtos/BulkDeleteItems.dto.ts | 23 +++- .../Vendors/BulkDeleteVendors.service.ts | 34 ++++++ .../ValidateBulkDeleteVendors.service.ts | 61 ++++++++++ .../src/modules/Vendors/Vendors.controller.ts | 44 +++++++- .../src/modules/Vendors/Vendors.module.ts | 4 + .../Vendors/VendorsApplication.service.ts | 20 ++++ .../Vendors/commands/DeleteVendor.service.ts | 16 ++- .../Vendors/dtos/BulkDeleteVendors.dto.ts | 62 +++++++++++ .../src/components/DialogsContainer.tsx | 6 + packages/webapp/src/constants/dialogs.ts | 3 + .../Alerts/Items/ItemBulkDeleteAlert.tsx | 68 ------------ .../CustomersLanding/CustomersActionsBar.tsx | 42 ++++--- .../CustomersLanding/CustomersList.tsx | 4 +- .../CustomersLanding/CustomersTable.tsx | 11 ++ .../hooks/use-bulk-delete-customers-dialog.ts | 14 +++ .../CustomersLanding/withCustomers.tsx | 1 + .../CustomersLanding/withCustomersActions.tsx | 7 +- .../Accounts/AccountBulkDeleteDialog.tsx | 2 - .../Dialogs/Bills/BillBulkDeleteDialog.tsx | 2 - .../CreditNoteBulkDeleteDialog.tsx | 2 - .../Customers/CustomerBulkDeleteDialog.tsx | 104 ++++++++++++++++++ .../Estimates/EstimateBulkDeleteDialog.tsx | 2 - .../Expenses/ExpenseBulkDeleteDialog.tsx | 2 - .../Invoices/InvoiceBulkDeleteDialog.tsx | 2 - .../Dialogs/Items/ItemBulkDeleteDialog.tsx | 103 +++++++++++++++++ .../ManualJournalBulkDeleteDialog.tsx | 2 - .../PaymentReceivedBulkDeleteDialog.tsx | 2 - .../Receipts/ReceiptBulkDeleteDialog.tsx | 2 - .../VendorCreditBulkDeleteDialog.tsx | 2 - .../Vendors/VendorBulkDeleteDialog.tsx | 103 +++++++++++++++++ .../src/containers/Items/ItemsActionsBar.tsx | 12 +- .../src/containers/Items/ItemsAlerts.tsx | 8 -- .../hooks/use-bulk-delete-items-dialog.ts | 14 +++ .../VendorsLanding/VendorActionsBar.tsx | 42 +++++-- .../Vendors/VendorsLanding/VendorsList.tsx | 4 +- .../Vendors/VendorsLanding/VendorsTable.tsx | 11 ++ .../hooks/use-bulk-delete-vendors-dialog.ts | 14 +++ .../Vendors/VendorsLanding/withVendors.tsx | 1 + .../VendorsLanding/withVendorsActions.tsx | 5 + .../src/hooks/dialogs/useBulkDeleteDialog.ts | 1 + packages/webapp/src/hooks/query/accounts.tsx | 2 +- .../webapp/src/hooks/query/creditNote.tsx | 2 +- packages/webapp/src/hooks/query/customers.tsx | 45 +++++++- packages/webapp/src/hooks/query/estimates.tsx | 2 +- packages/webapp/src/hooks/query/expenses.tsx | 2 +- packages/webapp/src/hooks/query/invoices.tsx | 2 +- packages/webapp/src/hooks/query/items.tsx | 29 ++++- .../webapp/src/hooks/query/manualJournals.tsx | 2 +- .../src/hooks/query/paymentReceives.tsx | 2 +- packages/webapp/src/hooks/query/receipts.tsx | 2 +- .../webapp/src/hooks/query/vendorCredit.tsx | 2 +- packages/webapp/src/hooks/query/vendors.tsx | 45 +++++++- packages/webapp/src/lang/ar/index.json | 7 ++ packages/webapp/src/lang/en/index.json | 7 ++ packages/webapp/src/lang/es/index.json | 7 ++ packages/webapp/src/lang/sv/index.json | 7 ++ .../src/store/customers/customers.actions.tsx | 15 ++- .../src/store/customers/customers.reducer.tsx | 12 ++ .../src/store/vendors/vendors.actions.tsx | 15 ++- .../src/store/vendors/vendors.reducer.tsx | 10 ++ 71 files changed, 1167 insertions(+), 185 deletions(-) create mode 100644 packages/server/src/modules/Customers/BulkDeleteCustomers.service.ts create mode 100644 packages/server/src/modules/Customers/ValidateBulkDeleteCustomers.service.ts create mode 100644 packages/server/src/modules/Customers/dtos/BulkDeleteCustomers.dto.ts create mode 100644 packages/server/src/modules/Vendors/BulkDeleteVendors.service.ts create mode 100644 packages/server/src/modules/Vendors/ValidateBulkDeleteVendors.service.ts create mode 100644 packages/server/src/modules/Vendors/dtos/BulkDeleteVendors.dto.ts delete mode 100644 packages/webapp/src/containers/Alerts/Items/ItemBulkDeleteAlert.tsx create mode 100644 packages/webapp/src/containers/Customers/CustomersLanding/hooks/use-bulk-delete-customers-dialog.ts create mode 100644 packages/webapp/src/containers/Dialogs/Customers/CustomerBulkDeleteDialog.tsx create mode 100644 packages/webapp/src/containers/Dialogs/Items/ItemBulkDeleteDialog.tsx create mode 100644 packages/webapp/src/containers/Dialogs/Vendors/VendorBulkDeleteDialog.tsx create mode 100644 packages/webapp/src/containers/Items/hooks/use-bulk-delete-items-dialog.ts create mode 100644 packages/webapp/src/containers/Vendors/VendorsLanding/hooks/use-bulk-delete-vendors-dialog.ts diff --git a/packages/server/src/common/dtos/BulkDelete.dto.ts b/packages/server/src/common/dtos/BulkDelete.dto.ts index 84edab2bf..244475c8a 100644 --- a/packages/server/src/common/dtos/BulkDelete.dto.ts +++ b/packages/server/src/common/dtos/BulkDelete.dto.ts @@ -16,7 +16,7 @@ export class BulkDeleteDto { @IsOptional() @IsBoolean() - @Transform(({ value }) => parseBoolean(value, false)) + @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, 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/Items/BulkDeleteItems.service.ts b/packages/server/src/modules/Items/BulkDeleteItems.service.ts index e5ca88a3c..083296332 100644 --- a/packages/server/src/modules/Items/BulkDeleteItems.service.ts +++ b/packages/server/src/modules/Items/BulkDeleteItems.service.ts @@ -6,7 +6,7 @@ import { DeleteItemService } from './DeleteItem.service'; @Injectable() export class BulkDeleteItemsService { - constructor(private readonly deleteItemService: DeleteItemService) { } + constructor(private readonly deleteItemService: DeleteItemService) {} /** * Deletes multiple items. @@ -15,23 +15,26 @@ export class BulkDeleteItemsService { */ async bulkDeleteItems( itemIds: number | Array, + options?: { skipUndeletable?: boolean }, trx?: Knex.Transaction, ): Promise { + const { skipUndeletable = false } = options ?? {}; const itemsIds = uniq(castArray(itemIds)); - // Use PromisePool to delete items sequentially (concurrency: 1) - // to avoid potential database locks and maintain transaction integrity const results = await PromisePool.withConcurrency(1) .for(itemsIds) .process(async (itemId: number) => { - await this.deleteItemService.deleteItem(itemId, trx); + try { + await this.deleteItemService.deleteItem(itemId, trx); + } catch (error) { + if (!skipUndeletable) { + throw error; + } + } }); - // Check if there were any errors - if (results.errors && results.errors.length > 0) { - // If needed, you can throw an error here or handle errors individually - // For now, we'll let individual errors bubble up - throw results.errors[0].raw; + 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 eed9ef503..b55f8f1d4 100644 --- a/packages/server/src/modules/Items/Item.controller.ts +++ b/packages/server/src/modules/Items/Item.controller.ts @@ -374,6 +374,8 @@ export class ItemsController extends TenantController { async bulkDeleteItems( @Body() bulkDeleteDto: BulkDeleteItemsDto, ): Promise { - return this.itemsApplication.bulkDeleteItems(bulkDeleteDto.ids); + return this.itemsApplication.bulkDeleteItems(bulkDeleteDto.ids, { + skipUndeletable: bulkDeleteDto.skipUndeletable ?? false, + }); } } diff --git a/packages/server/src/modules/Items/ItemsApplication.service.ts b/packages/server/src/modules/Items/ItemsApplication.service.ts index f8b347a5d..6e41d7b53 100644 --- a/packages/server/src/modules/Items/ItemsApplication.service.ts +++ b/packages/server/src/modules/Items/ItemsApplication.service.ts @@ -158,7 +158,10 @@ export class ItemsApplicationService { * @param {number[]} itemIds - Array of item IDs to delete * @returns {Promise} */ - async bulkDeleteItems(itemIds: number[]): Promise { - return this.bulkDeleteItemsService.bulkDeleteItems(itemIds); + async bulkDeleteItems( + itemIds: number[], + options?: { skipUndeletable?: boolean }, + ): Promise { + return this.bulkDeleteItemsService.bulkDeleteItems(itemIds, options); } } diff --git a/packages/server/src/modules/Items/dtos/BulkDeleteItems.dto.ts b/packages/server/src/modules/Items/dtos/BulkDeleteItems.dto.ts index 64217cadb..9a1a471a8 100644 --- a/packages/server/src/modules/Items/dtos/BulkDeleteItems.dto.ts +++ b/packages/server/src/modules/Items/dtos/BulkDeleteItems.dto.ts @@ -1,5 +1,13 @@ -import { IsArray, IsInt, ArrayNotEmpty } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +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() @@ -11,6 +19,17 @@ export class BulkDeleteItemsDto { 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 { 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 3789a974e..c2d208dda 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -60,6 +60,9 @@ import VendorCreditBulkDeleteDialog from '@/containers/Dialogs/VendorCredits/Ven 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. @@ -167,6 +170,9 @@ export default function DialogsContainer() { /> + + + { - closeAlert(name); - }; - // Handle confirm items bulk delete. - const handleConfirmBulkDelete = () => { - bulkDeleteItems(itemsIds) - .then(() => { - AppToaster.show({ - message: intl.get('the_items_has_been_deleted_successfully'), - intent: Intent.SUCCESS, - }); - closeAlert(name); - }) - .catch((errors) => { }); - }; - return ( - } - confirmButtonText={ - - } - icon="trash" - intent={Intent.DANGER} - isOpen={isOpen} - onCancel={handleCancelBulkDelete} - onConfirm={handleConfirmBulkDelete} - loading={isLoading} - > -

- -

-
- ); -} - -export default compose( - withAlertStoreConnect(), - withAlertActions, -)(ItemBulkDeleteAlert); diff --git a/packages/webapp/src/containers/Customers/CustomersLanding/CustomersActionsBar.tsx b/packages/webapp/src/containers/Customers/CustomersLanding/CustomersActionsBar.tsx index f357813f7..1a6c689ea 100644 --- a/packages/webapp/src/containers/Customers/CustomersLanding/CustomersActionsBar.tsx +++ b/packages/webapp/src/containers/Customers/CustomersLanding/CustomersActionsBar.tsx @@ -12,7 +12,6 @@ import { import { useHistory } from 'react-router-dom'; import { - If, Icon, Can, FormattedMessage as T, @@ -29,7 +28,6 @@ import { useDownloadExportPdf } from '@/hooks/query/FinancialReports/use-export- import withCustomers from './withCustomers'; import withCustomersActions from './withCustomersActions'; -import withAlertActions from '@/containers/Alert/withAlertActions'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import withSettings from '@/containers/Settings/withSettings'; import withDialogActions from '@/containers/Dialog/withDialogActions'; @@ -37,6 +35,8 @@ import withDialogActions from '@/containers/Dialog/withDialogActions'; import { CustomerAction, AbilitySubject } from '@/constants/abilityOption'; import { compose } from '@/utils'; import { DialogsName } from '@/constants/dialogs'; +import { isEmpty } from 'lodash'; +import { useBulkDeleteCustomersDialog } from './hooks/use-bulk-delete-customers-dialog'; /** * Customers actions bar. @@ -50,9 +50,6 @@ function CustomerActionsBar({ setCustomersTableState, accountsInactiveMode, - // #withAlertActions - openAlert, - // #withSettings customersTableSize, @@ -62,6 +59,9 @@ function CustomerActionsBar({ // #withDialogActions openDialog, }) { + const { openBulkDeleteDialog, isValidatingBulkDeleteCustomers } = + useBulkDeleteCustomersDialog(); + // History context. const history = useHistory(); @@ -80,7 +80,7 @@ function CustomerActionsBar({ // Handle Customers bulk delete button click., const handleBulkDelete = () => { - openAlert('customers-bulk-delete', { customersIds: customersSelectedRows }); + openBulkDeleteDialog(customersSelectedRows); }; const handleTabChange = (view) => { @@ -118,6 +118,23 @@ function CustomerActionsBar({ downloadExportPdf({ resource: 'Customer' }); }; + if (!isEmpty(customersSelectedRows)) { + return ( + + + + + + + + + ); +} + +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 index 767cfa8bd..1a85eccf7 100644 --- a/packages/webapp/src/containers/Dialogs/Estimates/EstimateBulkDeleteDialog.tsx +++ b/packages/webapp/src/containers/Dialogs/Estimates/EstimateBulkDeleteDialog.tsx @@ -3,7 +3,6 @@ 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 { queryCache } from 'react-query'; import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; import { useBulkDeleteEstimates } from '@/hooks/query/estimates'; @@ -45,7 +44,6 @@ function EstimateBulkDeleteDialog({ message: intl.get('the_estimates_has_been_deleted_successfully'), intent: Intent.SUCCESS, }); - queryCache.invalidateQueries('estimates-table'); setEstimatesSelectedRows([]); closeDialog(dialogName); }) diff --git a/packages/webapp/src/containers/Dialogs/Expenses/ExpenseBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Expenses/ExpenseBulkDeleteDialog.tsx index a22c832da..f5673cb61 100644 --- a/packages/webapp/src/containers/Dialogs/Expenses/ExpenseBulkDeleteDialog.tsx +++ b/packages/webapp/src/containers/Dialogs/Expenses/ExpenseBulkDeleteDialog.tsx @@ -3,7 +3,6 @@ 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 { queryCache } from 'react-query'; import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; import { useBulkDeleteExpenses } from '@/hooks/query/expenses'; @@ -45,7 +44,6 @@ function ExpenseBulkDeleteDialog({ message: intl.get('the_expenses_has_been_deleted_successfully'), intent: Intent.SUCCESS, }); - queryCache.invalidateQueries('expenses-table'); setExpensesSelectedRows([]); closeDialog(dialogName); }) diff --git a/packages/webapp/src/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog.tsx index cdc44db34..8633c5367 100644 --- a/packages/webapp/src/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog.tsx +++ b/packages/webapp/src/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog.tsx @@ -3,7 +3,6 @@ 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 { queryCache } from 'react-query'; import withDialogRedux from '@/components/DialogReduxConnect'; import withDialogActions from '@/containers/Dialog/withDialogActions'; @@ -50,7 +49,6 @@ function InvoiceBulkDeleteDialog({ message: intl.get('the_invoices_has_been_deleted_successfully'), intent: Intent.SUCCESS, }); - queryCache.invalidateQueries('invoices-table'); resetInvoicesSelectedRows(); closeDialog(dialogName); }) 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 index a18e9c93f..1bab4a340 100644 --- a/packages/webapp/src/containers/Dialogs/ManualJournals/ManualJournalBulkDeleteDialog.tsx +++ b/packages/webapp/src/containers/Dialogs/ManualJournals/ManualJournalBulkDeleteDialog.tsx @@ -3,7 +3,6 @@ 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 { queryCache } from 'react-query'; import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; import { useBulkDeleteManualJournals } from '@/hooks/query/manualJournals'; @@ -45,7 +44,6 @@ function ManualJournalBulkDeleteDialog({ message: intl.get('the_journals_has_been_deleted_successfully'), intent: Intent.SUCCESS, }); - queryCache.invalidateQueries('manual-journals-table'); setManualJournalsSelectedRows([]); closeDialog(dialogName); }) diff --git a/packages/webapp/src/containers/Dialogs/PaymentsReceived/PaymentReceivedBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/PaymentsReceived/PaymentReceivedBulkDeleteDialog.tsx index 73aa0cee1..63bcd88a6 100644 --- a/packages/webapp/src/containers/Dialogs/PaymentsReceived/PaymentReceivedBulkDeleteDialog.tsx +++ b/packages/webapp/src/containers/Dialogs/PaymentsReceived/PaymentReceivedBulkDeleteDialog.tsx @@ -3,7 +3,6 @@ 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 { queryCache } from 'react-query'; import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; import { useBulkDeletePaymentReceives } from '@/hooks/query/paymentReceives'; @@ -47,7 +46,6 @@ function PaymentReceivedBulkDeleteDialog({ ), intent: Intent.SUCCESS, }); - queryCache.invalidateQueries('payments-received-table'); setPaymentReceivesSelectedRows([]); closeDialog(dialogName); }) diff --git a/packages/webapp/src/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog.tsx index b975e1647..d942b4044 100644 --- a/packages/webapp/src/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog.tsx +++ b/packages/webapp/src/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog.tsx @@ -3,7 +3,6 @@ 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 { queryCache } from 'react-query'; import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; import { useBulkDeleteReceipts } from '@/hooks/query/receipts'; @@ -45,7 +44,6 @@ function ReceiptBulkDeleteDialog({ message: intl.get('the_receipts_has_been_deleted_successfully'), intent: Intent.SUCCESS, }); - queryCache.invalidateQueries('sale-receipts-table'); setReceiptsSelectedRows([]); closeDialog(dialogName); }) diff --git a/packages/webapp/src/containers/Dialogs/VendorCredits/VendorCreditBulkDeleteDialog.tsx b/packages/webapp/src/containers/Dialogs/VendorCredits/VendorCreditBulkDeleteDialog.tsx index a79b2ae70..253da9607 100644 --- a/packages/webapp/src/containers/Dialogs/VendorCredits/VendorCreditBulkDeleteDialog.tsx +++ b/packages/webapp/src/containers/Dialogs/VendorCredits/VendorCreditBulkDeleteDialog.tsx @@ -3,7 +3,6 @@ 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 { queryCache } from 'react-query'; import BulkDeleteDialogContent from '@/containers/Dialogs/components/BulkDeleteDialogContent'; import { useBulkDeleteVendorCredits } from '@/hooks/query/vendorCredit'; @@ -47,7 +46,6 @@ function VendorCreditBulkDeleteDialog({ ), intent: Intent.SUCCESS, }); - queryCache.invalidateQueries('vendor-credits-table'); setVendorsCreditNoteSelectedRows([]); closeDialog(dialogName); }) 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/Items/ItemsActionsBar.tsx b/packages/webapp/src/containers/Items/ItemsActionsBar.tsx index 3c9f69f39..3c97aa3bb 100644 --- a/packages/webapp/src/containers/Items/ItemsActionsBar.tsx +++ b/packages/webapp/src/containers/Items/ItemsActionsBar.tsx @@ -31,7 +31,6 @@ import { useDownloadExportPdf } from '@/hooks/query/FinancialReports/use-export- import withItems from './withItems'; import withItemsActions from './withItemsActions'; -import withAlertActions from '@/containers/Alert/withAlertActions'; import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import withDialogActions from '../Dialog/withDialogActions'; @@ -39,6 +38,7 @@ import withDialogActions from '../Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; import { compose } from '@/utils'; import { isEmpty } from 'lodash'; +import { useBulkDeleteItemsDialog } from './hooks/use-bulk-delete-items-dialog'; /** * Items actions bar. @@ -52,9 +52,6 @@ function ItemsActionsBar({ setItemsTableState, itemsInactiveMode, - // #withAlertActions - openAlert, - // #withSettings itemsTableSize, @@ -64,6 +61,9 @@ function ItemsActionsBar({ // #withDialogActions openDialog, }) { + const { openBulkDeleteDialog, isValidatingBulkDeleteItems } = + useBulkDeleteItemsDialog(); + // Items list context. const { itemsViews, fields } = useItemsListContext(); @@ -88,7 +88,7 @@ function ItemsActionsBar({ // Handle cancel/confirm items bulk. const handleBulkDelete = () => { - openAlert('items-bulk-delete', { itemsIds: itemsSelectedRows }); + openBulkDeleteDialog(itemsSelectedRows); }; // Handle inactive switch changing. @@ -129,6 +129,7 @@ function ItemsActionsBar({ text={} intent={Intent.DANGER} onClick={handleBulkDelete} + disabled={isValidatingBulkDeleteItems} />
@@ -224,6 +225,5 @@ export default compose( itemsTableSize: itemsSettings.tableSize, })), withItemsActions, - withAlertActions, withDialogActions, )(ItemsActionsBar); diff --git a/packages/webapp/src/containers/Items/ItemsAlerts.tsx b/packages/webapp/src/containers/Items/ItemsAlerts.tsx index 045e09cc4..9b1978730 100644 --- a/packages/webapp/src/containers/Items/ItemsAlerts.tsx +++ b/packages/webapp/src/containers/Items/ItemsAlerts.tsx @@ -13,10 +13,6 @@ const ItemActivateAlert = React.lazy( () => import('@/containers/Alerts/Items/ItemActivateAlert'), ); -const ItemBulkDeleteAlert = React.lazy( - () => import('@/containers/Alerts/Items/ItemBulkDeleteAlert'), -); - const cancelUnlockingPartialAlert = React.lazy( () => import( @@ -40,8 +36,4 @@ export default [ name: 'item-activate', component: ItemActivateAlert, }, - { - name: 'items-bulk-delete', - component: ItemBulkDeleteAlert, - }, ]; diff --git a/packages/webapp/src/containers/Items/hooks/use-bulk-delete-items-dialog.ts b/packages/webapp/src/containers/Items/hooks/use-bulk-delete-items-dialog.ts new file mode 100644 index 000000000..4c24a3e8b --- /dev/null +++ b/packages/webapp/src/containers/Items/hooks/use-bulk-delete-items-dialog.ts @@ -0,0 +1,14 @@ +// @ts-nocheck +import { DialogsName } from '@/constants/dialogs'; +import { useValidateBulkDeleteItems } from '@/hooks/query/items'; +import { useBulkDeleteDialog } from '@/hooks/dialogs/useBulkDeleteDialog'; + +export const useBulkDeleteItemsDialog = () => { + const validateBulkDeleteMutation = useValidateBulkDeleteItems(); + + return useBulkDeleteDialog( + DialogsName.ItemBulkDelete, + validateBulkDeleteMutation, + ); +}; + diff --git a/packages/webapp/src/containers/Vendors/VendorsLanding/VendorActionsBar.tsx b/packages/webapp/src/containers/Vendors/VendorsLanding/VendorActionsBar.tsx index 8ca8a3592..825842270 100644 --- a/packages/webapp/src/containers/Vendors/VendorsLanding/VendorActionsBar.tsx +++ b/packages/webapp/src/containers/Vendors/VendorsLanding/VendorActionsBar.tsx @@ -11,7 +11,6 @@ import { } from '@blueprintjs/core'; import { - If, Can, Icon, FormattedMessage as T, @@ -28,6 +27,8 @@ import { useRefreshVendors } from '@/hooks/query/vendors'; import { useVendorsListContext } from './VendorsListProvider'; import { useHistory } from 'react-router-dom'; import { useDownloadExportPdf } from '@/hooks/query/FinancialReports/use-export-pdf'; +import { useBulkDeleteVendorsDialog } from './hooks/use-bulk-delete-vendors-dialog'; +import { isEmpty } from 'lodash'; import withVendors from './withVendors'; import withVendorsActions from './withVendorsActions'; @@ -43,6 +44,7 @@ import { DialogsName } from '@/constants/dialogs'; */ function VendorActionsBar({ // #withVendors + vendorsSelectedRows = [], vendorsFilterConditions, // #withVendorActions @@ -59,6 +61,9 @@ function VendorActionsBar({ openDialog, }) { const history = useHistory(); + const { openBulkDeleteDialog, isValidatingBulkDeleteVendors } = + useBulkDeleteVendorsDialog(); + // Vendors list context. const { vendorsViews, fields } = useVendorsListContext(); @@ -102,6 +107,27 @@ function VendorActionsBar({ downloadExportPdf({ resource: 'Vendor' }); }; + const handleBulkDelete = () => { + openBulkDeleteDialog(vendorsSelectedRows); + }; + + if (!isEmpty(vendorsSelectedRows)) { + return ( + + +