From 2ec3ca8d33e03f78328f2864076a320d3cbca431 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 18 Apr 2026 01:46:57 +0200 Subject: [PATCH] feat(custom-fields): add custom fields support --- packages/server/src/common/events/events.ts | 14 + ...250417000001_create_custom_fields_table.ts | 26 + ...000002_create_custom_field_values_table.ts | 26 + packages/server/src/interfaces/Item.ts | 2 + packages/server/src/modules/App/App.module.ts | 2 + .../modules/CreditNotes/CreditNotes.module.ts | 2 + .../commands/CreateCreditNote.service.ts | 13 + .../commands/EditCreditNote.service.ts | 13 + .../CreditNotes/dtos/CreditNote.dto.ts | 10 + .../dtos/CreditNoteResponse.dto.ts | 7 + .../queries/CreditNoteTransformer.ts | 9 + .../queries/GetCreditNote.service.ts | 16 +- .../CustomFields/CustomFields.application.ts | 52 ++ .../CustomFields/CustomFields.controller.ts | 139 ++++ .../CustomFields/CustomFields.module.ts | 46 + .../commands/CreateCustomField.service.ts | 37 + .../commands/DeleteCustomField.service.ts | 35 + .../commands/EditCustomField.service.ts | 44 + .../commands/ReorderCustomFields.service.ts | 30 + .../commands/UpdateFieldStatus.service.ts | 29 + .../CustomFields/dtos/CustomField.dto.ts | 63 ++ .../dtos/CustomFieldResponse.dto.ts | 39 + .../dtos/ReorderCustomFields.dto.ts | 14 + .../dtos/UpdateFieldStatus.dto.ts | 9 + .../CustomFields/models/CustomField.ts | 40 + .../CustomFields/models/CustomFieldValue.ts | 35 + .../queries/GetCustomField.service.ts | 19 + .../queries/GetCustomFields.service.ts | 23 + .../GetResourceCustomFields.service.ts | 66 ++ .../queries/SaveCustomFieldValues.service.ts | 80 ++ .../CustomFieldsEntityEventsSubscriber.ts | 186 +++++ .../CustomFields/types/CustomFields.types.ts | 6 + .../src/modules/Customers/Customers.module.ts | 2 + .../commands/CreateCustomer.service.ts | 13 + .../commands/EditCustomer.service.ts | 13 + .../Customers/dtos/CreateCustomer.dto.ts | 13 +- .../Customers/dtos/CustomerResponse.dto.ts | 7 + .../Customers/dtos/EditCustomer.dto.ts | 8 + .../Customers/queries/CustomerTransformer.ts | 9 + .../Customers/queries/GetCustomer.service.ts | 16 +- .../src/modules/Import/ImportFileCommon.ts | 2 +- .../src/modules/Import/ImportFileMapping.ts | 6 +- .../src/modules/Import/ImportFileProcess.ts | 2 +- .../src/modules/Import/ImportFileUpload.ts | 2 +- .../src/modules/Items/CreateItem.service.ts | 14 + .../src/modules/Items/EditItem.service.ts | 13 + .../src/modules/Items/GetItem.service.ts | 10 + .../src/modules/Items/Item.transformer.ts | 9 + .../server/src/modules/Items/Items.module.ts | 2 + .../server/src/modules/Items/dtos/Item.dto.ts | 13 +- .../modules/Items/dtos/itemResponse.dto.ts | 7 + .../PaymentsReceived.module.ts | 2 + .../commands/CreatePaymentReceived.serivce.ts | 13 + .../commands/EditPaymentReceived.service.ts | 13 + .../dtos/PaymentReceived.dto.ts | 10 + .../dtos/PaymentReceivedResponse.dto.ts | 7 + .../queries/GetPaymentReceived.service.ts | 13 +- .../queries/PaymentReceivedTransformer.ts | 9 + .../src/modules/Resource/Resource.module.ts | 3 +- .../src/modules/Resource/ResourceService.ts | 57 +- .../server/src/modules/Roles/Roles.types.ts | 3 +- .../SaleEstimates/SaleEstimates.module.ts | 2 + .../commands/CreateSaleEstimate.service.ts | 1 + .../SaleEstimates/dtos/SaleEstimate.dto.ts | 13 +- .../dtos/SaleEstimateResponse.dto.ts | 7 + .../queries/GetSaleEstimate.service.ts | 10 + .../queries/SaleEstimate.transformer.ts | 9 + .../SaleInvoices/SaleInvoices.module.ts | 2 + .../commands/EditSaleInvoice.service.ts | 1 + .../SaleInvoices/dtos/SaleInvoice.dto.ts | 13 +- .../dtos/SaleInvoiceResponse.dto.ts | 7 + .../queries/GetSaleInvoice.service.ts | 10 + .../SaleInvoices/queries/GetSaleInvoices.ts | 14 + .../queries/SaleInvoice.transformer.ts | 9 + .../SaleReceipts/SaleReceipts.module.ts | 2 + .../commands/EditSaleReceipt.service.ts | 13 + .../SaleReceipts/dtos/SaleReceipt.dto.ts | 10 + .../dtos/SaleReceiptResponse.dto.ts | 7 + .../queries/GetSaleReceipt.service.ts | 13 +- .../queries/SaleReceiptTransformer.ts | 9 + .../server/test/custom-fields.e2e-spec.ts | 309 +++++++ packages/server/test/jest-e2e.json | 2 +- .../webapp/src/constants/preferencesMenu.tsx | 5 + .../CustomFields/CustomFieldDeleteAlert.tsx | 81 ++ .../CustomFields/CustomFieldsAlerts.tsx | 11 + .../containers/AlertsContainer/registered.tsx | 2 + .../CustomFieldsForm.schema.tsx | 15 + .../CustomFieldsForm/CustomFieldsForm.tsx | 108 +++ .../CustomFieldsFormContent.tsx | 19 + .../CustomFieldsFormFloatingActions.tsx | 50 ++ .../CustomFieldsFormHeader.tsx | 267 ++++++ .../CustomFieldsForm/CustomFieldsFormPage.tsx | 20 + .../CustomFieldsFormProvider.tsx | 57 ++ .../CustomFieldsDataTable.tsx | 80 ++ .../CustomFieldsList/CustomFieldsList.tsx | 18 + .../CustomFieldsListProvider.tsx | 40 + .../CustomFieldsList/components.tsx | 99 +++ .../webapp/src/hooks/query/customFields.tsx | 117 +++ packages/webapp/src/hooks/query/index.tsx | 1 + packages/webapp/src/hooks/query/types.tsx | 6 + packages/webapp/src/routes/preferences.tsx | 30 + shared/sdk-ts/openapi.json | 784 ++++++++++++++++++ shared/sdk-ts/src/custom-fields.ts | 78 ++ shared/sdk-ts/src/index.ts | 1 + shared/sdk-ts/src/schema.ts | 618 ++++++++++++++ 105 files changed, 4373 insertions(+), 20 deletions(-) create mode 100644 packages/server/src/database/tenant/migrations/20250417000001_create_custom_fields_table.ts create mode 100644 packages/server/src/database/tenant/migrations/20250417000002_create_custom_field_values_table.ts create mode 100644 packages/server/src/modules/CustomFields/CustomFields.application.ts create mode 100644 packages/server/src/modules/CustomFields/CustomFields.controller.ts create mode 100644 packages/server/src/modules/CustomFields/CustomFields.module.ts create mode 100644 packages/server/src/modules/CustomFields/commands/CreateCustomField.service.ts create mode 100644 packages/server/src/modules/CustomFields/commands/DeleteCustomField.service.ts create mode 100644 packages/server/src/modules/CustomFields/commands/EditCustomField.service.ts create mode 100644 packages/server/src/modules/CustomFields/commands/ReorderCustomFields.service.ts create mode 100644 packages/server/src/modules/CustomFields/commands/UpdateFieldStatus.service.ts create mode 100644 packages/server/src/modules/CustomFields/dtos/CustomField.dto.ts create mode 100644 packages/server/src/modules/CustomFields/dtos/CustomFieldResponse.dto.ts create mode 100644 packages/server/src/modules/CustomFields/dtos/ReorderCustomFields.dto.ts create mode 100644 packages/server/src/modules/CustomFields/dtos/UpdateFieldStatus.dto.ts create mode 100644 packages/server/src/modules/CustomFields/models/CustomField.ts create mode 100644 packages/server/src/modules/CustomFields/models/CustomFieldValue.ts create mode 100644 packages/server/src/modules/CustomFields/queries/GetCustomField.service.ts create mode 100644 packages/server/src/modules/CustomFields/queries/GetCustomFields.service.ts create mode 100644 packages/server/src/modules/CustomFields/queries/GetResourceCustomFields.service.ts create mode 100644 packages/server/src/modules/CustomFields/queries/SaveCustomFieldValues.service.ts create mode 100644 packages/server/src/modules/CustomFields/subscribers/CustomFieldsEntityEventsSubscriber.ts create mode 100644 packages/server/src/modules/CustomFields/types/CustomFields.types.ts create mode 100644 packages/server/test/custom-fields.e2e-spec.ts create mode 100644 packages/webapp/src/containers/Alerts/CustomFields/CustomFieldDeleteAlert.tsx create mode 100644 packages/webapp/src/containers/Alerts/CustomFields/CustomFieldsAlerts.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsForm.schema.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsForm.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormContent.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormFloatingActions.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormHeader.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormPage.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormProvider.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsDataTable.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsList.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsListProvider.tsx create mode 100644 packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/components.tsx create mode 100644 packages/webapp/src/hooks/query/customFields.tsx create mode 100644 shared/sdk-ts/src/custom-fields.ts diff --git a/packages/server/src/common/events/events.ts b/packages/server/src/common/events/events.ts index 22c770b71..446044f67 100644 --- a/packages/server/src/common/events/events.ts +++ b/packages/server/src/common/events/events.ts @@ -705,6 +705,20 @@ export const events = { onDisconnected: 'onBankAccountDisconnected', }, + /** + * Custom fields service. + */ + customField: { + onCreating: 'onCustomFieldCreating', + onCreated: 'onCustomFieldCreated', + + onEditing: 'onCustomFieldEditing', + onEdited: 'onCustomFieldEdited', + + onDeleting: 'onCustomFieldDeleting', + onDeleted: 'onCustomFieldDeleted', + }, + // Import files. import: { onImportCommitted: 'onImportFileCommitted', diff --git a/packages/server/src/database/tenant/migrations/20250417000001_create_custom_fields_table.ts b/packages/server/src/database/tenant/migrations/20250417000001_create_custom_fields_table.ts new file mode 100644 index 000000000..33900b889 --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20250417000001_create_custom_fields_table.ts @@ -0,0 +1,26 @@ + +exports.up = function(knex) { + return knex.schema.createTable('custom_fields', table => { + table.increments(); + + table.string('resource_name').notNullable(); + table.string('field_name').notNullable(); + table.string('label').notNullable(); + table.string('field_type').notNullable(); + table.json('options').nullable(); + table.boolean('required').defaultTo(false); + table.integer('order').defaultTo(0); + table.boolean('active').defaultTo(true); + table.string('default_value').nullable(); + + table.timestamps(); + + table.index(['resource_name']); + table.index(['resource_name', 'active']); + table.unique(['resource_name', 'field_name']); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('custom_fields'); +}; diff --git a/packages/server/src/database/tenant/migrations/20250417000002_create_custom_field_values_table.ts b/packages/server/src/database/tenant/migrations/20250417000002_create_custom_field_values_table.ts new file mode 100644 index 000000000..0231dba4a --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20250417000002_create_custom_field_values_table.ts @@ -0,0 +1,26 @@ + +exports.up = function(knex) { + return knex.schema.createTable('custom_field_values', table => { + table.increments(); + + table.integer('custom_field_id').unsigned().notNullable(); + table.string('resource_type').notNullable(); + table.integer('resource_id').unsigned().notNullable(); + table.text('value').nullable(); + + table.timestamps(); + + table.index(['resource_type', 'resource_id'], 'cfv_resource_idx'); + table.index(['custom_field_id'], 'cfv_field_idx'); + table.unique(['custom_field_id', 'resource_type', 'resource_id'], 'cfv_unique_value'); + + table.foreign('custom_field_id') + .references('id') + .inTable('custom_fields') + .onDelete('CASCADE'); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('custom_field_values'); +}; diff --git a/packages/server/src/interfaces/Item.ts b/packages/server/src/interfaces/Item.ts index 63816bdff..0b10d5a08 100644 --- a/packages/server/src/interfaces/Item.ts +++ b/packages/server/src/interfaces/Item.ts @@ -132,6 +132,7 @@ export interface IItemEventCreatedPayload { // tenantId: number; item: Item; itemId: number; + itemDTO: any; trx: Knex.Transaction; } @@ -139,6 +140,7 @@ export interface IItemEventEditedPayload { item: Item; oldItem: Item; itemId: number; + itemDTO: any; trx: Knex.Transaction; } diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 8ae530926..f53a942e6 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -98,6 +98,7 @@ import { MiscellaneousModule } from '../Miscellaneous/Miscellaneous.module'; import { UsersModule } from '../UsersModule/Users.module'; import { ContactsModule } from '../Contacts/Contacts.module'; import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module'; import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module'; import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module'; @@ -256,6 +257,7 @@ import { AppThrottleModule } from './AppThrottle.module'; MiscellaneousModule, UsersModule, ContactsModule, + CustomFieldsModule, SocketModule, ExchangeRatesModule, ], diff --git a/packages/server/src/modules/CreditNotes/CreditNotes.module.ts b/packages/server/src/modules/CreditNotes/CreditNotes.module.ts index 26a44e004..78d58efb5 100644 --- a/packages/server/src/modules/CreditNotes/CreditNotes.module.ts +++ b/packages/server/src/modules/CreditNotes/CreditNotes.module.ts @@ -36,6 +36,7 @@ import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds. import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module'; import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service'; import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; @Module({ imports: [ @@ -50,6 +51,7 @@ import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCredit AccountsModule, DynamicListModule, InventoryCostModule, + CustomFieldsModule, forwardRef(() => CreditNoteRefundsModule), forwardRef(() => CreditNotesApplyInvoiceModule) ], diff --git a/packages/server/src/modules/CreditNotes/commands/CreateCreditNote.service.ts b/packages/server/src/modules/CreditNotes/commands/CreateCreditNote.service.ts index 4968447bd..07b05aed0 100644 --- a/packages/server/src/modules/CreditNotes/commands/CreateCreditNote.service.ts +++ b/packages/server/src/modules/CreditNotes/commands/CreateCreditNote.service.ts @@ -13,6 +13,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { events } from '@/common/events/events'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { CreateCreditNoteDto } from '../dtos/CreditNote.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable() export class CreateCreditNoteService { @@ -29,6 +30,7 @@ export class CreateCreditNoteService { private readonly itemsEntriesService: ItemsEntriesService, private readonly eventPublisher: EventEmitter2, private readonly commandCreditNoteDTOTransform: CommandCreditNoteDTOTransform, + private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService, @Inject(CreditNote.name) private readonly creditNoteModel: TenantModelProxy, @@ -84,6 +86,17 @@ export class CreateCreditNoteService { .upsertGraph({ ...creditNoteModel, }); + + // Save custom field values. + if (creditNoteDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'CreditNote', + creditNote.id, + creditNoteDTO.customFields, + trx, + ); + } + // Triggers `onCreditNoteCreated` event. await this.eventPublisher.emitAsync(events.creditNote.onCreated, { creditNoteDTO, diff --git a/packages/server/src/modules/CreditNotes/commands/EditCreditNote.service.ts b/packages/server/src/modules/CreditNotes/commands/EditCreditNote.service.ts index 11b1caee0..2a6abd264 100644 --- a/packages/server/src/modules/CreditNotes/commands/EditCreditNote.service.ts +++ b/packages/server/src/modules/CreditNotes/commands/EditCreditNote.service.ts @@ -13,6 +13,7 @@ import { events } from '@/common/events/events'; import { CommandCreditNoteDTOTransform } from './CommandCreditNoteDTOTransform.service'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { EditCreditNoteDto } from '../dtos/CreditNote.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable() export class EditCreditNoteService { @@ -35,6 +36,7 @@ export class EditCreditNoteService { private itemsEntriesService: ItemsEntriesService, private eventPublisher: EventEmitter2, private uow: UnitOfWork, + private saveCustomFieldValuesService: SaveCustomFieldValuesService, ) {} /** @@ -93,6 +95,17 @@ export class EditCreditNoteService { id: creditNoteId, ...creditNoteModel, }); + + // Save custom field values. + if (creditNoteEditDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'CreditNote', + creditNoteId, + creditNoteEditDTO.customFields, + trx, + ); + } + // Triggers `onCreditNoteEdited` event. await this.eventPublisher.emitAsync(events.creditNote.onEdited, { trx, diff --git a/packages/server/src/modules/CreditNotes/dtos/CreditNote.dto.ts b/packages/server/src/modules/CreditNotes/dtos/CreditNote.dto.ts index 56ef9027b..be1034998 100644 --- a/packages/server/src/modules/CreditNotes/dtos/CreditNote.dto.ts +++ b/packages/server/src/modules/CreditNotes/dtos/CreditNote.dto.ts @@ -11,6 +11,7 @@ import { IsInt, IsNotEmpty, IsNumber, + IsObject, IsOptional, IsPositive, IsString, @@ -126,6 +127,15 @@ export class CommandCreditNoteDto { @ToNumber() @IsNumber() adjustment?: number; + + @IsOptional() + @IsObject() + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } export class CreateCreditNoteDto extends CommandCreditNoteDto {} diff --git a/packages/server/src/modules/CreditNotes/dtos/CreditNoteResponse.dto.ts b/packages/server/src/modules/CreditNotes/dtos/CreditNoteResponse.dto.ts index d1c05132b..b72d1e7b3 100644 --- a/packages/server/src/modules/CreditNotes/dtos/CreditNoteResponse.dto.ts +++ b/packages/server/src/modules/CreditNotes/dtos/CreditNoteResponse.dto.ts @@ -263,4 +263,11 @@ export class CreditNoteResponseDto { example: '$1,000.00', }) totalLocalFormatted: string; + + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } diff --git a/packages/server/src/modules/CreditNotes/queries/CreditNoteTransformer.ts b/packages/server/src/modules/CreditNotes/queries/CreditNoteTransformer.ts index 4bd148eb5..322e4ef29 100644 --- a/packages/server/src/modules/CreditNotes/queries/CreditNoteTransformer.ts +++ b/packages/server/src/modules/CreditNotes/queries/CreditNoteTransformer.ts @@ -30,9 +30,18 @@ export class CreditNoteTransformer extends Transformer { 'entries', 'attachments', + 'customFields', ]; }; + /** + * Retrieve custom fields from options. + * @returns {Record} + */ + public customFields(): Record { + return this.options?.customFields; + } + /** * Retrieve formatted credit note date. * @param {ICreditNote} credit diff --git a/packages/server/src/modules/CreditNotes/queries/GetCreditNote.service.ts b/packages/server/src/modules/CreditNotes/queries/GetCreditNote.service.ts index 4c0ad5b65..5e63772f4 100644 --- a/packages/server/src/modules/CreditNotes/queries/GetCreditNote.service.ts +++ b/packages/server/src/modules/CreditNotes/queries/GetCreditNote.service.ts @@ -5,11 +5,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { CreditNote } from '../models/CreditNote'; import { ServiceError } from '@/modules/Items/ServiceError'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service'; @Injectable() export class GetCreditNoteService { constructor( private readonly transformer: TransformerInjectable, + private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService, @Inject(CreditNote.name) private readonly creditNoteModel: TenantModelProxy, @@ -32,7 +34,19 @@ export class GetCreditNoteService { if (!creditNote) { throw new ServiceError(ERRORS.CREDIT_NOTE_NOT_FOUND); } + // Load custom field values. + const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields( + 'CreditNote', + creditNoteId, + ); + // Transforms the credit note model to POJO. - return this.transformer.transform(creditNote, new CreditNoteTransformer()); + const transformed = await this.transformer.transform( + creditNote, + new CreditNoteTransformer(), + { customFields }, + ); + + return transformed; } } diff --git a/packages/server/src/modules/CustomFields/CustomFields.application.ts b/packages/server/src/modules/CustomFields/CustomFields.application.ts new file mode 100644 index 000000000..b69419e76 --- /dev/null +++ b/packages/server/src/modules/CustomFields/CustomFields.application.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { CreateCustomFieldService } from './commands/CreateCustomField.service'; +import { EditCustomFieldService } from './commands/EditCustomField.service'; +import { DeleteCustomFieldService } from './commands/DeleteCustomField.service'; +import { ReorderCustomFieldsService } from './commands/ReorderCustomFields.service'; +import { UpdateFieldStatusService } from './commands/UpdateFieldStatus.service'; +import { GetCustomFieldService } from './queries/GetCustomField.service'; +import { GetCustomFieldsService } from './queries/GetCustomFields.service'; +import { CreateCustomFieldDto, EditCustomFieldDto } from './dtos/CustomField.dto'; +import { ReorderCustomFieldsDto } from './dtos/ReorderCustomFields.dto'; +import { UpdateFieldStatusDto } from './dtos/UpdateFieldStatus.dto'; + +@Injectable() +export class CustomFieldsApplication { + constructor( + private createCustomFieldService: CreateCustomFieldService, + private editCustomFieldService: EditCustomFieldService, + private deleteCustomFieldService: DeleteCustomFieldService, + private reorderCustomFieldsService: ReorderCustomFieldsService, + private updateFieldStatusService: UpdateFieldStatusService, + private getCustomFieldService: GetCustomFieldService, + private getCustomFieldsService: GetCustomFieldsService, + ) {} + + async createCustomField(dto: CreateCustomFieldDto) { + return this.createCustomFieldService.createCustomField(dto); + } + + async editCustomField(fieldId: number, dto: EditCustomFieldDto) { + return this.editCustomFieldService.editCustomField(fieldId, dto); + } + + async deleteCustomField(fieldId: number) { + return this.deleteCustomFieldService.deleteCustomField(fieldId); + } + + async getCustomField(fieldId: number) { + return this.getCustomFieldService.getCustomField(fieldId); + } + + async getCustomFields(resourceName?: string) { + return this.getCustomFieldsService.getCustomFields(resourceName); + } + + async reorderCustomFields(dto: ReorderCustomFieldsDto) { + return this.reorderCustomFieldsService.reorderCustomFields(dto); + } + + async updateFieldStatus(fieldId: number, dto: UpdateFieldStatusDto) { + return this.updateFieldStatusService.updateFieldStatus(fieldId, dto); + } +} diff --git a/packages/server/src/modules/CustomFields/CustomFields.controller.ts b/packages/server/src/modules/CustomFields/CustomFields.controller.ts new file mode 100644 index 000000000..3881c4909 --- /dev/null +++ b/packages/server/src/modules/CustomFields/CustomFields.controller.ts @@ -0,0 +1,139 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { CustomFieldsApplication } from './CustomFields.application'; +import { + ApiExtraModels, + ApiOperation, + ApiResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; +import { CreateCustomFieldDto, EditCustomFieldDto } from './dtos/CustomField.dto'; +import { CustomFieldResponseDto } from './dtos/CustomFieldResponse.dto'; +import { ReorderCustomFieldsDto } from './dtos/ReorderCustomFields.dto'; +import { UpdateFieldStatusDto } from './dtos/UpdateFieldStatus.dto'; +import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { CustomFieldAction } from './types/CustomFields.types'; + +@Controller('custom-fields') +@ApiTags('Custom Fields') +@ApiExtraModels(CustomFieldResponseDto) +@ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +export class CustomFieldsController { + constructor(private readonly customFieldsApplication: CustomFieldsApplication) {} + + @Post() + @RequirePermission(CustomFieldAction.CREATE, AbilitySubject.CustomField) + @ApiOperation({ summary: 'Create a new custom field.' }) + @ApiResponse({ + status: 201, + description: 'The custom field has been successfully created.', + schema: { $ref: getSchemaPath(CustomFieldResponseDto) }, + }) + public createCustomField(@Body() createCustomFieldDTO: CreateCustomFieldDto) { + return this.customFieldsApplication.createCustomField(createCustomFieldDTO); + } + + @Put(':id') + @RequirePermission(CustomFieldAction.EDIT, AbilitySubject.CustomField) + @ApiOperation({ summary: 'Edit the given custom field.' }) + @ApiResponse({ + status: 200, + description: 'The custom field has been successfully updated.', + schema: { $ref: getSchemaPath(CustomFieldResponseDto) }, + }) + public editCustomField( + @Param('id') fieldId: number, + @Body() editCustomFieldDTO: EditCustomFieldDto, + ) { + return this.customFieldsApplication.editCustomField(fieldId, editCustomFieldDTO); + } + + @Delete(':id') + @RequirePermission(CustomFieldAction.DELETE, AbilitySubject.CustomField) + @ApiOperation({ summary: 'Delete the given custom field.' }) + @ApiResponse({ + status: 200, + description: 'The custom field has been successfully deleted.', + schema: { $ref: getSchemaPath(CustomFieldResponseDto) }, + }) + public deleteCustomField(@Param('id') fieldId: number) { + return this.customFieldsApplication.deleteCustomField(fieldId); + } + + @Get(':id') + @RequirePermission(CustomFieldAction.VIEW, AbilitySubject.CustomField) + @ApiOperation({ summary: 'Retrieves the custom field details.' }) + @ApiResponse({ + status: 200, + description: 'The custom field details have been successfully retrieved.', + schema: { $ref: getSchemaPath(CustomFieldResponseDto) }, + }) + public getCustomField(@Param('id') fieldId: number) { + return this.customFieldsApplication.getCustomField(fieldId); + } + + @Get() + @RequirePermission(CustomFieldAction.VIEW, AbilitySubject.CustomField) + @ApiOperation({ summary: 'Retrieves the custom fields.' }) + @ApiResponse({ + status: 200, + description: 'The custom fields have been successfully retrieved.', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(CustomFieldResponseDto) }, + }, + }, + }, + }) + public getCustomFields(@Query('resource') resourceName?: string) { + return this.customFieldsApplication.getCustomFields(resourceName); + } + + @Post('reorder') + @RequirePermission(CustomFieldAction.EDIT, AbilitySubject.CustomField) + @ApiOperation({ summary: 'Reorder custom fields.' }) + @ApiResponse({ + status: 200, + description: 'The custom fields have been successfully reordered.', + schema: { + type: 'array', + items: { $ref: getSchemaPath(CustomFieldResponseDto) }, + }, + }) + public reorderCustomFields(@Body() reorderDTO: ReorderCustomFieldsDto) { + return this.customFieldsApplication.reorderCustomFields(reorderDTO); + } + + @Put(':id/status') + @RequirePermission(CustomFieldAction.EDIT, AbilitySubject.CustomField) + @ApiOperation({ summary: 'Update custom field status.' }) + @ApiResponse({ + status: 200, + description: 'The custom field status has been successfully updated.', + schema: { $ref: getSchemaPath(CustomFieldResponseDto) }, + }) + public updateFieldStatus( + @Param('id') fieldId: number, + @Body() statusDTO: UpdateFieldStatusDto, + ) { + return this.customFieldsApplication.updateFieldStatus(fieldId, statusDTO); + } +} diff --git a/packages/server/src/modules/CustomFields/CustomFields.module.ts b/packages/server/src/modules/CustomFields/CustomFields.module.ts new file mode 100644 index 000000000..56f5cd4a5 --- /dev/null +++ b/packages/server/src/modules/CustomFields/CustomFields.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { CustomFieldsController } from './CustomFields.controller'; +import { CustomFieldsApplication } from './CustomFields.application'; +import { CreateCustomFieldService } from './commands/CreateCustomField.service'; +import { EditCustomFieldService } from './commands/EditCustomField.service'; +import { DeleteCustomFieldService } from './commands/DeleteCustomField.service'; +import { ReorderCustomFieldsService } from './commands/ReorderCustomFields.service'; +import { UpdateFieldStatusService } from './commands/UpdateFieldStatus.service'; +import { GetCustomFieldService } from './queries/GetCustomField.service'; +import { GetCustomFieldsService } from './queries/GetCustomFields.service'; +import { GetResourceCustomFieldsService } from './queries/GetResourceCustomFields.service'; +import { SaveCustomFieldValuesService } from './queries/SaveCustomFieldValues.service'; +import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module'; +import { CustomField } from './models/CustomField'; +import { CustomFieldValue } from './models/CustomFieldValue'; +import { CustomFieldsEntityEventsSubscriber } from './subscribers/CustomFieldsEntityEventsSubscriber'; + +const models = [ + RegisterTenancyModel(CustomField), + RegisterTenancyModel(CustomFieldValue), +]; + +@Module({ + imports: [...models], + controllers: [CustomFieldsController], + providers: [ + CustomFieldsApplication, + CreateCustomFieldService, + EditCustomFieldService, + DeleteCustomFieldService, + ReorderCustomFieldsService, + UpdateFieldStatusService, + GetCustomFieldService, + GetCustomFieldsService, + GetResourceCustomFieldsService, + SaveCustomFieldValuesService, + CustomFieldsEntityEventsSubscriber, + ], + exports: [ + GetResourceCustomFieldsService, + SaveCustomFieldValuesService, + GetCustomFieldsService, + ...models, + ], +}) +export class CustomFieldsModule {} diff --git a/packages/server/src/modules/CustomFields/commands/CreateCustomField.service.ts b/packages/server/src/modules/CustomFields/commands/CreateCustomField.service.ts new file mode 100644 index 000000000..5afe0d86e --- /dev/null +++ b/packages/server/src/modules/CustomFields/commands/CreateCustomField.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { CreateCustomFieldDto } from '../dtos/CustomField.dto'; +import { CustomField } from '../models/CustomField'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; + +@Injectable() +export class CreateCustomFieldService { + constructor( + private readonly uow: UnitOfWork, + private readonly eventEmitter: EventEmitter2, + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + ) {} + + async createCustomField(dto: CreateCustomFieldDto) { + return this.uow.withTransaction(async (trx) => { + const customField = await this.customFieldModel().query(trx).insert({ + resourceName: dto.resourceName, + fieldName: dto.fieldName, + label: dto.label, + fieldType: dto.fieldType, + options: dto.options, + required: dto.required, + order: dto.order, + active: dto.active, + defaultValue: dto.defaultValue, + }); + + this.eventEmitter.emit(events.customField.onCreated, { customField }); + + return customField; + }); + } +} diff --git a/packages/server/src/modules/CustomFields/commands/DeleteCustomField.service.ts b/packages/server/src/modules/CustomFields/commands/DeleteCustomField.service.ts new file mode 100644 index 000000000..40d975a8c --- /dev/null +++ b/packages/server/src/modules/CustomFields/commands/DeleteCustomField.service.ts @@ -0,0 +1,35 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { CustomField } from '../models/CustomField'; +import { CustomFieldValue } from '../models/CustomFieldValue'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; + +@Injectable() +export class DeleteCustomFieldService { + constructor( + private readonly uow: UnitOfWork, + private readonly eventEmitter: EventEmitter2, + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + @Inject(CustomFieldValue.name) + private readonly customFieldValueModel: TenantModelProxy, + ) {} + + async deleteCustomField(fieldId: number) { + return this.uow.withTransaction(async (trx) => { + const customField = await this.customFieldModel().query(trx).findById(fieldId); + if (!customField) { + throw new NotFoundException('Custom field not found.'); + } + + await this.customFieldValueModel().query(trx).where('custom_field_id', fieldId).delete(); + await this.customFieldModel().query(trx).findById(fieldId).delete(); + + this.eventEmitter.emit(events.customField.onDeleted, { customField }); + + return customField; + }); + } +} diff --git a/packages/server/src/modules/CustomFields/commands/EditCustomField.service.ts b/packages/server/src/modules/CustomFields/commands/EditCustomField.service.ts new file mode 100644 index 000000000..c7e39f5a5 --- /dev/null +++ b/packages/server/src/modules/CustomFields/commands/EditCustomField.service.ts @@ -0,0 +1,44 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { EditCustomFieldDto } from '../dtos/CustomField.dto'; +import { CustomField } from '../models/CustomField'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; + +@Injectable() +export class EditCustomFieldService { + constructor( + private readonly uow: UnitOfWork, + private readonly eventEmitter: EventEmitter2, + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + ) {} + + async editCustomField(fieldId: number, dto: EditCustomFieldDto) { + return this.uow.withTransaction(async (trx) => { + const oldCustomField = await this.customFieldModel().query(trx).findById(fieldId); + if (!oldCustomField) { + throw new NotFoundException('Custom field not found.'); + } + + const customField = await this.customFieldModel() + .query(trx) + .patchAndFetchById(fieldId, { + resourceName: dto.resourceName, + fieldName: dto.fieldName, + label: dto.label, + fieldType: dto.fieldType, + options: dto.options, + required: dto.required, + order: dto.order, + active: dto.active, + defaultValue: dto.defaultValue, + }); + + this.eventEmitter.emit(events.customField.onEdited, { customField, oldCustomField }); + + return customField; + }); + } +} diff --git a/packages/server/src/modules/CustomFields/commands/ReorderCustomFields.service.ts b/packages/server/src/modules/CustomFields/commands/ReorderCustomFields.service.ts new file mode 100644 index 000000000..1ab9baafa --- /dev/null +++ b/packages/server/src/modules/CustomFields/commands/ReorderCustomFields.service.ts @@ -0,0 +1,30 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { ReorderCustomFieldsDto } from '../dtos/ReorderCustomFields.dto'; +import { CustomField } from '../models/CustomField'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; + +@Injectable() +export class ReorderCustomFieldsService { + constructor( + private readonly uow: UnitOfWork, + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + ) {} + + async reorderCustomFields(dto: ReorderCustomFieldsDto) { + return this.uow.withTransaction(async (trx) => { + for (let i = 0; i < dto.fieldIds.length; i++) { + await this.customFieldModel() + .query(trx) + .findById(dto.fieldIds[i]) + .patch({ order: i + 1 }); + } + + return this.customFieldModel() + .query(trx) + .where('resource_name', dto.resourceName) + .orderBy('order', 'ASC'); + }); + } +} diff --git a/packages/server/src/modules/CustomFields/commands/UpdateFieldStatus.service.ts b/packages/server/src/modules/CustomFields/commands/UpdateFieldStatus.service.ts new file mode 100644 index 000000000..27d0c72c3 --- /dev/null +++ b/packages/server/src/modules/CustomFields/commands/UpdateFieldStatus.service.ts @@ -0,0 +1,29 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { UpdateFieldStatusDto } from '../dtos/UpdateFieldStatus.dto'; +import { CustomField } from '../models/CustomField'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; + +@Injectable() +export class UpdateFieldStatusService { + constructor( + private readonly uow: UnitOfWork, + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + ) {} + + async updateFieldStatus(fieldId: number, dto: UpdateFieldStatusDto) { + return this.uow.withTransaction(async (trx) => { + const customField = await this.customFieldModel().query(trx).findById(fieldId); + if (!customField) { + throw new NotFoundException('Custom field not found.'); + } + + return this.customFieldModel() + .query(trx) + .patchAndFetchById(fieldId, { + active: dto.active, + }); + }); + } +} diff --git a/packages/server/src/modules/CustomFields/dtos/CustomField.dto.ts b/packages/server/src/modules/CustomFields/dtos/CustomField.dto.ts new file mode 100644 index 000000000..632e1e07d --- /dev/null +++ b/packages/server/src/modules/CustomFields/dtos/CustomField.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsString, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class CommandCustomFieldDto { + @IsString() + @IsNotEmpty() + @ApiProperty({ description: 'Resource name the custom field belongs to.', example: 'SaleInvoice' }) + resourceName: string; + + @IsString() + @IsNotEmpty() + @ApiProperty({ description: 'Internal field name.', example: 'cf_priority' }) + fieldName: string; + + @IsString() + @IsNotEmpty() + @ApiProperty({ description: 'Display label.', example: 'Priority' }) + label: string; + + @IsString() + @IsNotEmpty() + @ApiProperty({ description: 'Field type.', example: 'dropdown' }) + fieldType: string; + + @IsObject() + @IsOptional() + @ApiProperty({ description: 'Field options (e.g., dropdown choices).', example: { choices: ['Low', 'Medium', 'High'] } }) + options?: Record; + + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value ?? false) + @ApiProperty({ description: 'Whether the field is required.', example: false }) + required?: boolean; + + @IsNumber() + @IsOptional() + @Transform(({ value }) => value ?? 0) + @ApiProperty({ description: 'Display order.', example: 1 }) + order?: number; + + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value ?? true) + @ApiProperty({ description: 'Whether the field is active.', example: true }) + active?: boolean; + + @IsString() + @IsOptional() + @ApiProperty({ description: 'Default value.', example: 'Medium' }) + defaultValue?: string; +} + +export class CreateCustomFieldDto extends CommandCustomFieldDto {} +export class EditCustomFieldDto extends CommandCustomFieldDto {} diff --git a/packages/server/src/modules/CustomFields/dtos/CustomFieldResponse.dto.ts b/packages/server/src/modules/CustomFields/dtos/CustomFieldResponse.dto.ts new file mode 100644 index 000000000..508e531a0 --- /dev/null +++ b/packages/server/src/modules/CustomFields/dtos/CustomFieldResponse.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CustomFieldResponseDto { + @ApiProperty({ description: 'Custom field ID.', example: 1 }) + id: number; + + @ApiProperty({ description: 'Resource name.', example: 'SaleInvoice' }) + resourceName: string; + + @ApiProperty({ description: 'Field name.', example: 'cf_priority' }) + fieldName: string; + + @ApiProperty({ description: 'Display label.', example: 'Priority' }) + label: string; + + @ApiProperty({ description: 'Field type.', example: 'dropdown' }) + fieldType: string; + + @ApiProperty({ description: 'Field options.', example: { choices: ['Low', 'Medium', 'High'] } }) + options?: Record; + + @ApiProperty({ description: 'Whether the field is required.', example: false }) + required: boolean; + + @ApiProperty({ description: 'Display order.', example: 1 }) + order: number; + + @ApiProperty({ description: 'Whether the field is active.', example: true }) + active: boolean; + + @ApiProperty({ description: 'Default value.', example: 'Medium' }) + defaultValue?: string; + + @ApiProperty({ description: 'Created at timestamp.' }) + createdAt: Date; + + @ApiProperty({ description: 'Updated at timestamp.' }) + updatedAt: Date; +} diff --git a/packages/server/src/modules/CustomFields/dtos/ReorderCustomFields.dto.ts b/packages/server/src/modules/CustomFields/dtos/ReorderCustomFields.dto.ts new file mode 100644 index 000000000..3eb036654 --- /dev/null +++ b/packages/server/src/modules/CustomFields/dtos/ReorderCustomFields.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsNotEmpty, IsString } from 'class-validator'; + +export class ReorderCustomFieldsDto { + @IsString() + @IsNotEmpty() + @ApiProperty({ description: 'Resource name.', example: 'SaleInvoice' }) + resourceName: string; + + @IsArray() + @IsNotEmpty() + @ApiProperty({ description: 'Ordered array of custom field IDs.', example: [3, 1, 2] }) + fieldIds: number[]; +} diff --git a/packages/server/src/modules/CustomFields/dtos/UpdateFieldStatus.dto.ts b/packages/server/src/modules/CustomFields/dtos/UpdateFieldStatus.dto.ts new file mode 100644 index 000000000..ae65116c6 --- /dev/null +++ b/packages/server/src/modules/CustomFields/dtos/UpdateFieldStatus.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty } from 'class-validator'; + +export class UpdateFieldStatusDto { + @IsBoolean() + @IsNotEmpty() + @ApiProperty({ description: 'New active status.', example: true }) + active: boolean; +} diff --git a/packages/server/src/modules/CustomFields/models/CustomField.ts b/packages/server/src/modules/CustomFields/models/CustomField.ts new file mode 100644 index 000000000..690e36489 --- /dev/null +++ b/packages/server/src/modules/CustomFields/models/CustomField.ts @@ -0,0 +1,40 @@ +import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; + +export class CustomField extends TenantBaseModel { + public resourceName!: string; + public fieldName!: string; + public label!: string; + public fieldType!: string; + public options?: Record; + public required!: boolean; + public order!: number; + public active!: boolean; + public defaultValue?: string; + + static get tableName() { + return 'custom_fields'; + } + + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + static get virtualAttributes() { + return []; + } + + static get modifiers() { + return { + active(query) { + query.where('active', true); + }, + resource(query, resourceName: string) { + query.where('resource_name', resourceName); + }, + }; + } + + static get relationMappings() { + return {}; + } +} diff --git a/packages/server/src/modules/CustomFields/models/CustomFieldValue.ts b/packages/server/src/modules/CustomFields/models/CustomFieldValue.ts new file mode 100644 index 000000000..54b1cc668 --- /dev/null +++ b/packages/server/src/modules/CustomFields/models/CustomFieldValue.ts @@ -0,0 +1,35 @@ +import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; + +export class CustomFieldValue extends TenantBaseModel { + public customFieldId!: number; + public resourceType!: string; + public resourceId!: number; + public value?: string; + + static get tableName() { + return 'custom_field_values'; + } + + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + static get virtualAttributes() { + return []; + } + + static get modifiers() { + return { + resource(query, resourceType: string, resourceId: number) { + query.where('resource_type', resourceType).where('resource_id', resourceId); + }, + customField(query, customFieldId: number) { + query.where('custom_field_id', customFieldId); + }, + }; + } + + static get relationMappings() { + return {}; + } +} diff --git a/packages/server/src/modules/CustomFields/queries/GetCustomField.service.ts b/packages/server/src/modules/CustomFields/queries/GetCustomField.service.ts new file mode 100644 index 000000000..165ef38d4 --- /dev/null +++ b/packages/server/src/modules/CustomFields/queries/GetCustomField.service.ts @@ -0,0 +1,19 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { CustomField } from '../models/CustomField'; + +@Injectable() +export class GetCustomFieldService { + constructor( + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + ) {} + + async getCustomField(fieldId: number) { + const customField = await this.customFieldModel().query().findById(fieldId); + if (!customField) { + throw new NotFoundException('Custom field not found.'); + } + return customField; + } +} diff --git a/packages/server/src/modules/CustomFields/queries/GetCustomFields.service.ts b/packages/server/src/modules/CustomFields/queries/GetCustomFields.service.ts new file mode 100644 index 000000000..5ca62eff6 --- /dev/null +++ b/packages/server/src/modules/CustomFields/queries/GetCustomFields.service.ts @@ -0,0 +1,23 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { CustomField } from '../models/CustomField'; + +@Injectable() +export class GetCustomFieldsService { + constructor( + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + ) {} + + async getCustomFields(resourceName?: string) { + let query = this.customFieldModel() + .query() + .orderBy('order', 'ASC'); + + if (resourceName) { + query = query.where('resource_name', resourceName); + } + + return query; + } +} diff --git a/packages/server/src/modules/CustomFields/queries/GetResourceCustomFields.service.ts b/packages/server/src/modules/CustomFields/queries/GetResourceCustomFields.service.ts new file mode 100644 index 000000000..e2fbdfdb8 --- /dev/null +++ b/packages/server/src/modules/CustomFields/queries/GetResourceCustomFields.service.ts @@ -0,0 +1,66 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { CustomField } from '../models/CustomField'; +import { CustomFieldValue } from '../models/CustomFieldValue'; + +@Injectable() +export class GetResourceCustomFieldsService { + constructor( + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + @Inject(CustomFieldValue.name) + private readonly customFieldValueModel: TenantModelProxy, + ) {} + + async getResourceCustomFields(resourceType: string, resourceId: number) { + const customFields = await this.customFieldModel() + .query() + .where('resource_name', resourceType) + .where('active', true) + .orderBy('order', 'ASC'); + + const values = await this.customFieldValueModel() + .query() + .where('resource_type', resourceType) + .where('resource_id', resourceId); + + const valuesMap = new Map(values.map((v) => [v.customFieldId, v.value])); + + const result: Record = {}; + for (const field of customFields) { + const value = valuesMap.get(field.id); + result[field.fieldName] = value !== undefined ? value : field.defaultValue; + } + + return result; + } + + async getResourceCustomFieldsBulk(resourceType: string, resourceIds: number[]) { + const customFields = await this.customFieldModel() + .query() + .where('resource_name', resourceType) + .where('active', true) + .orderBy('order', 'ASC'); + + const values = await this.customFieldValueModel() + .query() + .where('resource_type', resourceType) + .whereIn('resource_id', resourceIds); + + const valuesMap = new Map( + values.map((v) => [`${v.customFieldId}:${v.resourceId}`, v.value]), + ); + + const result: Record> = {}; + for (const resourceId of resourceIds) { + const resourceValues: Record = {}; + for (const field of customFields) { + const value = valuesMap.get(`${field.id}:${resourceId}`); + resourceValues[field.fieldName] = value !== undefined ? value : field.defaultValue; + } + result[resourceId] = resourceValues; + } + + return result; + } +} diff --git a/packages/server/src/modules/CustomFields/queries/SaveCustomFieldValues.service.ts b/packages/server/src/modules/CustomFields/queries/SaveCustomFieldValues.service.ts new file mode 100644 index 000000000..ceefd56c0 --- /dev/null +++ b/packages/server/src/modules/CustomFields/queries/SaveCustomFieldValues.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { CustomField } from '../models/CustomField'; +import { CustomFieldValue } from '../models/CustomFieldValue'; + +@Injectable() +export class SaveCustomFieldValuesService { + constructor( + private readonly uow: UnitOfWork, + @Inject(CustomField.name) + private readonly customFieldModel: TenantModelProxy, + @Inject(CustomFieldValue.name) + private readonly customFieldValueModel: TenantModelProxy, + ) {} + + async saveValues( + resourceType: string, + resourceId: number, + customFields: Record, + trx?: any, + ) { + const execute = async (transaction) => { + const fieldDefinitions = await this.customFieldModel() + .query(transaction) + .where('resource_name', resourceType) + .where('active', true); + + const fieldMap = new Map(fieldDefinitions.map((f) => [f.fieldName, f])); + + for (const [fieldName, value] of Object.entries(customFields)) { + const fieldDef = fieldMap.get(fieldName); + if (!fieldDef) continue; + + const existing = await this.customFieldValueModel() + .query(transaction) + .where('custom_field_id', fieldDef.id) + .where('resource_type', resourceType) + .where('resource_id', resourceId) + .first(); + + if (existing) { + await this.customFieldValueModel() + .query(transaction) + .findById(existing.id) + .patch({ value: value != null ? String(value) : null }); + } else { + await this.customFieldValueModel() + .query(transaction) + .insert({ + customFieldId: fieldDef.id, + resourceType, + resourceId, + value: value != null ? String(value) : null, + }); + } + } + }; + + if (trx) { + return execute(trx); + } + return this.uow.withTransaction(execute); + } + + async deleteValues(resourceType: string, resourceId: number, trx?: any) { + const execute = async (transaction) => { + await this.customFieldValueModel() + .query(transaction) + .where('resource_type', resourceType) + .where('resource_id', resourceId) + .delete(); + }; + + if (trx) { + return execute(trx); + } + return this.uow.withTransaction(execute); + } +} diff --git a/packages/server/src/modules/CustomFields/subscribers/CustomFieldsEntityEventsSubscriber.ts b/packages/server/src/modules/CustomFields/subscribers/CustomFieldsEntityEventsSubscriber.ts new file mode 100644 index 000000000..35643de0a --- /dev/null +++ b/packages/server/src/modules/CustomFields/subscribers/CustomFieldsEntityEventsSubscriber.ts @@ -0,0 +1,186 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { SaveCustomFieldValuesService } from '../queries/SaveCustomFieldValues.service'; +import { events } from '@/common/events/events'; + +@Injectable() +export class CustomFieldsEntityEventsSubscriber { + constructor( + private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService, + ) {} + + // --- Sale Invoices --- + @OnEvent(events.saleInvoice.onCreated) + async handleSaleInvoiceCreated(payload: any) { + if (payload.saleInvoiceDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'SaleInvoice', + payload.saleInvoiceId, + payload.saleInvoiceDTO.customFields, + payload.trx, + ); + } + } + + @OnEvent(events.saleInvoice.onEdited) + async handleSaleInvoiceEdited(payload: any) { + if (payload.saleInvoiceDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'SaleInvoice', + payload.saleInvoiceId, + payload.saleInvoiceDTO.customFields, + payload.trx, + ); + } + } + + // --- Sale Estimates --- + @OnEvent(events.saleEstimate.onCreated) + async handleSaleEstimateCreated(payload: any) { + if (payload.saleEstimateDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'SaleEstimate', + payload.saleEstimateId, + payload.saleEstimateDTO.customFields, + payload.trx, + ); + } + } + + @OnEvent(events.saleEstimate.onEdited) + async handleSaleEstimateEdited(payload: any) { + if (payload.estimateDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'SaleEstimate', + payload.estimateId, + payload.estimateDTO.customFields, + payload.trx, + ); + } + } + + // --- Sale Receipts --- + @OnEvent(events.saleReceipt.onCreated) + async handleSaleReceiptCreated(payload: any) { + if (payload.saleReceiptDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'SaleReceipt', + payload.saleReceiptId, + payload.saleReceiptDTO.customFields, + payload.trx, + ); + } + } + + @OnEvent(events.saleReceipt.onEdited) + async handleSaleReceiptEdited(payload: any) { + if (payload.saleReceiptDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'SaleReceipt', + payload.saleReceipt.id, + payload.saleReceiptDTO.customFields, + payload.trx, + ); + } + } + + // --- Customers --- + @OnEvent(events.customers.onCreated) + async handleCustomerCreated(payload: any) { + if (payload.customerDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'Customer', + payload.customerId, + payload.customerDTO.customFields, + payload.trx, + ); + } + } + + @OnEvent(events.customers.onEdited) + async handleCustomerEdited(payload: any) { + if (payload.customerDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'Customer', + payload.customerId, + payload.customerDTO.customFields, + payload.trx, + ); + } + } + + // --- Items --- + @OnEvent(events.item.onCreated) + async handleItemCreated(payload: any) { + if (payload.itemDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'Item', + payload.itemId, + payload.itemDTO.customFields, + payload.trx, + ); + } + } + + @OnEvent(events.item.onEdited) + async handleItemEdited(payload: any) { + if (payload.itemDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'Item', + payload.itemId, + payload.itemDTO.customFields, + payload.trx, + ); + } + } + + // --- Credit Notes --- + @OnEvent(events.creditNote.onCreated) + async handleCreditNoteCreated(payload: any) { + if (payload.creditNoteDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'CreditNote', + payload.creditNote.id, + payload.creditNoteDTO.customFields, + payload.trx, + ); + } + } + + @OnEvent(events.creditNote.onEdited) + async handleCreditNoteEdited(payload: any) { + if (payload.creditNoteEditDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'CreditNote', + payload.creditNoteId, + payload.creditNoteEditDTO.customFields, + payload.trx, + ); + } + } + + // --- Payment Received --- + @OnEvent(events.paymentReceive.onCreated) + async handlePaymentReceiveCreated(payload: any) { + if (payload.paymentReceiveDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'PaymentReceive', + payload.paymentReceiveId, + payload.paymentReceiveDTO.customFields, + payload.trx, + ); + } + } + + @OnEvent(events.paymentReceive.onEdited) + async handlePaymentReceiveEdited(payload: any) { + if (payload.paymentReceiveDTO?.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'PaymentReceive', + payload.paymentReceiveId, + payload.paymentReceiveDTO.customFields, + payload.trx, + ); + } + } +} diff --git a/packages/server/src/modules/CustomFields/types/CustomFields.types.ts b/packages/server/src/modules/CustomFields/types/CustomFields.types.ts new file mode 100644 index 000000000..12b56d88f --- /dev/null +++ b/packages/server/src/modules/CustomFields/types/CustomFields.types.ts @@ -0,0 +1,6 @@ +export enum CustomFieldAction { + CREATE = 'Create', + EDIT = 'Edit', + DELETE = 'Delete', + VIEW = 'View', +} diff --git a/packages/server/src/modules/Customers/Customers.module.ts b/packages/server/src/modules/Customers/Customers.module.ts index c63260d0a..2365233bb 100644 --- a/packages/server/src/modules/Customers/Customers.module.ts +++ b/packages/server/src/modules/Customers/Customers.module.ts @@ -20,6 +20,7 @@ import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service'; import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service'; import { LedgerModule } from '../Ledger/Ledger.module'; import { AccountsModule } from '../Accounts/Accounts.module'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; import { CustomerGLEntries } from './CustomerGLEntries'; import { CustomerGLEntriesStorage } from './CustomerGLEntriesStorage'; import { CustomerWriteGLOpeningBalanceSubscriber } from './subscribers/CustomerGLEntriesSubscriber'; @@ -30,6 +31,7 @@ import { CustomerWriteGLOpeningBalanceSubscriber } from './subscribers/CustomerG DynamicListModule, LedgerModule, AccountsModule, + CustomFieldsModule, ], controllers: [CustomersController], providers: [ diff --git a/packages/server/src/modules/Customers/commands/CreateCustomer.service.ts b/packages/server/src/modules/Customers/commands/CreateCustomer.service.ts index 246c02548..e63e269aa 100644 --- a/packages/server/src/modules/Customers/commands/CreateCustomer.service.ts +++ b/packages/server/src/modules/Customers/commands/CreateCustomer.service.ts @@ -11,6 +11,7 @@ import { } from '../types/Customers.types'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { CreateCustomerDto } from '../dtos/CreateCustomer.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable() export class CreateCustomer { @@ -24,6 +25,7 @@ export class CreateCustomer { private readonly uow: UnitOfWork, private readonly eventPublisher: EventEmitter2, private readonly customerDTO: CreateEditCustomerDTO, + private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService, @Inject(Customer.name) private readonly customerModel: TenantModelProxy, @@ -55,6 +57,17 @@ export class CreateCustomer { .insertAndFetch({ ...customerObj, }); + + // Save custom field values. + if (customerDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'Customer', + customer.id, + customerDTO.customFields, + trx, + ); + } + // Triggers `onCustomerCreated` event. await this.eventPublisher.emitAsync(events.customers.onCreated, { customer, diff --git a/packages/server/src/modules/Customers/commands/EditCustomer.service.ts b/packages/server/src/modules/Customers/commands/EditCustomer.service.ts index 8e6b83800..65f63b3cd 100644 --- a/packages/server/src/modules/Customers/commands/EditCustomer.service.ts +++ b/packages/server/src/modules/Customers/commands/EditCustomer.service.ts @@ -12,6 +12,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { EditCustomerDto } from '../dtos/EditCustomer.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable() export class EditCustomer { @@ -25,6 +26,7 @@ export class EditCustomer { private uow: UnitOfWork, private eventPublisher: EventEmitter2, private customerDTO: CreateEditCustomerDTO, + private saveCustomFieldValuesService: SaveCustomFieldValuesService, @Inject(Customer.name) private customerModel: TenantModelProxy, @@ -64,6 +66,17 @@ export class EditCustomer { .updateAndFetchById(customerId, { ...customerObj, }); + + // Save custom field values. + if (customerDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'Customer', + customerId, + customerDTO.customFields, + trx, + ); + } + // Triggers `onCustomerEdited` event. await this.eventPublisher.emitAsync(events.customers.onEdited, { customerId, diff --git a/packages/server/src/modules/Customers/dtos/CreateCustomer.dto.ts b/packages/server/src/modules/Customers/dtos/CreateCustomer.dto.ts index fa1032e56..31dcc9a17 100644 --- a/packages/server/src/modules/Customers/dtos/CreateCustomer.dto.ts +++ b/packages/server/src/modules/Customers/dtos/CreateCustomer.dto.ts @@ -3,11 +3,13 @@ import { IsEmail, IsNotEmpty, IsNumber, + IsObject, + IsOptional, IsString, ValidateIf, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, ToNumber } from '@/common/decorators/Validators'; +import { ToNumber } from '@/common/decorators/Validators'; import { ContactAddressDto } from './ContactAddress.dto'; export class CreateCustomerDto extends ContactAddressDto { @@ -164,4 +166,13 @@ export class CreateCustomerDto extends ContactAddressDto { @IsOptional() @IsString() code?: string; + + @ApiProperty({ + required: false, + description: 'Custom fields values', + example: { cf_priority: 'High' }, + }) + @IsOptional() + @IsObject() + customFields?: Record; } diff --git a/packages/server/src/modules/Customers/dtos/CustomerResponse.dto.ts b/packages/server/src/modules/Customers/dtos/CustomerResponse.dto.ts index 556b6cafb..7ca90cd03 100644 --- a/packages/server/src/modules/Customers/dtos/CustomerResponse.dto.ts +++ b/packages/server/src/modules/Customers/dtos/CustomerResponse.dto.ts @@ -115,4 +115,11 @@ export class CustomerResponseDto { @ApiProperty({ example: 1500.0 }) closingBalance: number; + + @ApiProperty({ + required: false, + description: 'Custom fields values', + example: { cf_priority: 'High' }, + }) + customFields?: Record; } diff --git a/packages/server/src/modules/Customers/dtos/EditCustomer.dto.ts b/packages/server/src/modules/Customers/dtos/EditCustomer.dto.ts index f94ea4032..a5c2a74de 100644 --- a/packages/server/src/modules/Customers/dtos/EditCustomer.dto.ts +++ b/packages/server/src/modules/Customers/dtos/EditCustomer.dto.ts @@ -68,4 +68,12 @@ export class EditCustomerDto extends ContactAddressDto { @IsOptional() @IsString() code?: string; + + @ApiProperty({ + required: false, + description: 'Custom fields values', + example: { cf_priority: 'High' }, + }) + @IsOptional() + customFields?: Record; } diff --git a/packages/server/src/modules/Customers/queries/CustomerTransformer.ts b/packages/server/src/modules/Customers/queries/CustomerTransformer.ts index aa5e1f132..04ef12571 100644 --- a/packages/server/src/modules/Customers/queries/CustomerTransformer.ts +++ b/packages/server/src/modules/Customers/queries/CustomerTransformer.ts @@ -12,9 +12,18 @@ export class CustomerTransfromer extends ContactTransfromer { 'formattedOpeningBalanceAt', 'customerType', 'formattedCustomerType', + 'customFields', ]; }; + /** + * Retrieve custom fields from options. + * @returns {Record} + */ + public customFields(): Record { + return this.options?.customFields; + } + /** * Retrieve customer type. * @returns {string} diff --git a/packages/server/src/modules/Customers/queries/GetCustomer.service.ts b/packages/server/src/modules/Customers/queries/GetCustomer.service.ts index 061d0e77e..820184a63 100644 --- a/packages/server/src/modules/Customers/queries/GetCustomer.service.ts +++ b/packages/server/src/modules/Customers/queries/GetCustomer.service.ts @@ -3,11 +3,13 @@ import { CustomerTransfromer } from './CustomerTransformer'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { Customer } from '../models/Customer'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service'; @Injectable() export class GetCustomerService { constructor( private transformer: TransformerInjectable, + private getResourceCustomFieldsService: GetResourceCustomFieldsService, @Inject(Customer.name) private customerModel: TenantModelProxy, @@ -24,7 +26,19 @@ export class GetCustomerService { .findById(customerId) .throwIfNotFound(); + // Load custom field values. + const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields( + 'Customer', + customerId, + ); + // Retrieves the transformered customers. - return this.transformer.transform(customer, new CustomerTransfromer()); + const transformed = await this.transformer.transform( + customer, + new CustomerTransfromer(), + { customFields }, + ); + + return transformed; } } diff --git a/packages/server/src/modules/Import/ImportFileCommon.ts b/packages/server/src/modules/Import/ImportFileCommon.ts index aa5aef661..d02e8412d 100644 --- a/packages/server/src/modules/Import/ImportFileCommon.ts +++ b/packages/server/src/modules/Import/ImportFileCommon.ts @@ -36,7 +36,7 @@ export class ImportFileCommon { parsedData: Record[], trx?: Knex.Transaction, ): Promise<[ImportOperSuccess[], ImportOperError[]]> { - const resourceFields = this.resource.getResourceFields2( + const resourceFields = await this.resource.getResourceFields2( importFile.resource, ); const importable = await this.importableRegistry.getImportable( diff --git a/packages/server/src/modules/Import/ImportFileMapping.ts b/packages/server/src/modules/Import/ImportFileMapping.ts index 01a822d85..4d92d188c 100644 --- a/packages/server/src/modules/Import/ImportFileMapping.ts +++ b/packages/server/src/modules/Import/ImportFileMapping.ts @@ -34,7 +34,7 @@ export class ImportFileMapping { .throwIfNotFound(); // Invalidate the from/to map attributes. - this.validateMapsAttrs(importFile, maps); + await this.validateMapsAttrs(importFile, maps); // @todo validate the required fields. @@ -63,8 +63,8 @@ export class ImportFileMapping { * @param {ImportMappingAttr[]} maps * @throws {ServiceError(ERRORS.INVALID_MAP_ATTRS)} */ - private validateMapsAttrs(importFile: any, maps: ImportMappingAttr[]) { - const fields = this.resource.getResourceFields2(importFile.resource); + private async validateMapsAttrs(importFile: any, maps: ImportMappingAttr[]) { + const fields = await this.resource.getResourceFields2(importFile.resource); const columnsMap = fromPairs( importFile.columnsParsed.map((field) => [field, '']), ); diff --git a/packages/server/src/modules/Import/ImportFileProcess.ts b/packages/server/src/modules/Import/ImportFileProcess.ts index b9cdf049d..5e045f21b 100644 --- a/packages/server/src/modules/Import/ImportFileProcess.ts +++ b/packages/server/src/modules/Import/ImportFileProcess.ts @@ -53,7 +53,7 @@ export class ImportFileProcess { const [sheetData, sheetColumns] = parseSheetData(buffer); const resource = importFile.resource; - const resourceFields = this.resource.getResourceFields2(resource); + const resourceFields = await this.resource.getResourceFields2(resource); // Runs the importing operation with ability to return errors that will happen. const [successedImport, failedImport, allData] = diff --git a/packages/server/src/modules/Import/ImportFileUpload.ts b/packages/server/src/modules/Import/ImportFileUpload.ts index c8c309282..d4bf7986b 100644 --- a/packages/server/src/modules/Import/ImportFileUpload.ts +++ b/packages/server/src/modules/Import/ImportFileUpload.ts @@ -100,7 +100,7 @@ export class ImportFileUploadService { params: paramsStringified, }); const resourceColumnsMap = - this.resourceService.getResourceFields2(resource); + await this.resourceService.getResourceFields2(resource); const resourceColumns = getResourceColumns(resourceColumnsMap); return { diff --git a/packages/server/src/modules/Items/CreateItem.service.ts b/packages/server/src/modules/Items/CreateItem.service.ts index 0b58ad272..3e4943754 100644 --- a/packages/server/src/modules/Items/CreateItem.service.ts +++ b/packages/server/src/modules/Items/CreateItem.service.ts @@ -9,6 +9,7 @@ import { Item } from './models/Item'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { TenantModelProxy } from '../System/models/TenantBaseModel'; import { CreateItemDto } from './dtos/Item.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable({ scope: Scope.REQUEST }) export class CreateItemService { @@ -23,6 +24,7 @@ export class CreateItemService { private readonly eventEmitter: EventEmitter2, private readonly uow: UnitOfWork, private readonly validators: ItemsValidators, + private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService, @Inject(Item.name) private readonly itemModel: TenantModelProxy, @@ -110,10 +112,22 @@ export class CreateItemService { .insertAndFetch({ ...itemInsert, }); + + // Save custom field values. + if (itemDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'Item', + item.id, + itemDTO.customFields, + trx, + ); + } + // Triggers `onItemCreated` event. await this.eventEmitter.emitAsync(events.item.onCreated, { item, itemId: item.id, + itemDTO, trx, } as IItemEventCreatedPayload); diff --git a/packages/server/src/modules/Items/EditItem.service.ts b/packages/server/src/modules/Items/EditItem.service.ts index d6e443c55..4c6f0a47e 100644 --- a/packages/server/src/modules/Items/EditItem.service.ts +++ b/packages/server/src/modules/Items/EditItem.service.ts @@ -8,6 +8,7 @@ import { Item } from './models/Item'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { TenantModelProxy } from '../System/models/TenantBaseModel'; import { EditItemDto } from './dtos/Item.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable() export class EditItemService { @@ -22,6 +23,7 @@ export class EditItemService { private readonly eventEmitter: EventEmitter2, private readonly uow: UnitOfWork, private readonly validators: ItemsValidators, + private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService, @Inject(Item.name) private readonly itemModel: TenantModelProxy, @@ -130,11 +132,22 @@ export class EditItemService { .query(trx) .patchAndFetchById(itemId, itemModel); + // Save custom field values. + if (itemDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'Item', + itemId, + itemDTO.customFields, + trx, + ); + } + // Edit event payload. const eventPayload: IItemEventEditedPayload = { item: newItem, oldItem, itemId: newItem.id, + itemDTO, trx, }; // Triggers `onItemEdited` event. diff --git a/packages/server/src/modules/Items/GetItem.service.ts b/packages/server/src/modules/Items/GetItem.service.ts index d035cc0ac..2ad79ad1e 100644 --- a/packages/server/src/modules/Items/GetItem.service.ts +++ b/packages/server/src/modules/Items/GetItem.service.ts @@ -6,6 +6,7 @@ import { TransformerInjectable } from '../Transformer/TransformerInjectable.serv import { ItemTransformer } from './Item.transformer'; import { TenantModelProxy } from '../System/models/TenantBaseModel'; import { ClsService } from 'nestjs-cls'; +import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service'; @Injectable() export class GetItemService { @@ -15,6 +16,7 @@ export class GetItemService { private readonly eventEmitter2: EventEmitter2, private readonly transformerInjectable: TransformerInjectable, private readonly clsService: ClsService, + private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService, ) {} /** @@ -35,10 +37,18 @@ export class GetItemService { .withGraphFetched('purchaseTaxRate') .throwIfNotFound(); + // Load custom field values. + const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields( + 'Item', + itemId, + ); + const transformed = await this.transformerInjectable.transform( item, new ItemTransformer(), + { customFields }, ); + const eventPayload = { itemId }; // Triggers the `onItemViewed` event. diff --git a/packages/server/src/modules/Items/Item.transformer.ts b/packages/server/src/modules/Items/Item.transformer.ts index 6ab6fb909..6e065be15 100644 --- a/packages/server/src/modules/Items/Item.transformer.ts +++ b/packages/server/src/modules/Items/Item.transformer.ts @@ -13,9 +13,18 @@ export class ItemTransformer extends Transformer { 'sellPriceFormatted', 'costPriceFormatted', 'itemWarehouses', + 'customFields', ]; }; + /** + * Retrieve custom fields from options. + * @returns {Record} + */ + public customFields(): Record { + return this.options?.customFields; + } + /** * Formatted item type. * @param {IItem} item diff --git a/packages/server/src/modules/Items/Items.module.ts b/packages/server/src/modules/Items/Items.module.ts index b18e8dad8..b8e217a28 100644 --- a/packages/server/src/modules/Items/Items.module.ts +++ b/packages/server/src/modules/Items/Items.module.ts @@ -20,12 +20,14 @@ import { ItemsExportable } from './ItemsExportable.service'; import { ItemsImportable } from './ItemsImportable.service'; import { BulkDeleteItemsService } from './BulkDeleteItems.service'; import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; @Module({ imports: [ TenancyDatabaseModule, DynamicListModule, InventoryAdjustmentsModule, + CustomFieldsModule, ], controllers: [ItemsController], providers: [ diff --git a/packages/server/src/modules/Items/dtos/Item.dto.ts b/packages/server/src/modules/Items/dtos/Item.dto.ts index d85f49c7b..03da7d0cf 100644 --- a/packages/server/src/modules/Items/dtos/Item.dto.ts +++ b/packages/server/src/modules/Items/dtos/Item.dto.ts @@ -9,9 +9,11 @@ import { MaxLength, Min, IsNotEmpty, + IsObject, + IsOptional, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, ToNumber } from '@/common/decorators/Validators'; +import { ToNumber } from '@/common/decorators/Validators'; export class CommandItemDto { @IsString() @@ -209,6 +211,15 @@ export class CommandItemDto { example: [1, 2, 3], }) mediaIds?: number[]; + + @IsOptional() + @IsObject() + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } export class CreateItemDto extends CommandItemDto {} diff --git a/packages/server/src/modules/Items/dtos/itemResponse.dto.ts b/packages/server/src/modules/Items/dtos/itemResponse.dto.ts index 5fee5fe50..0f29f1665 100644 --- a/packages/server/src/modules/Items/dtos/itemResponse.dto.ts +++ b/packages/server/src/modules/Items/dtos/itemResponse.dto.ts @@ -240,4 +240,11 @@ export class ItemResponseDto { example: '2024-03-20T10:00:00Z', }) updatedAt: Date; + + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } diff --git a/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts b/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts index e087e027e..4d7819764 100644 --- a/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts +++ b/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts @@ -43,6 +43,7 @@ import { GetPaymentReceivedMailTemplate } from './queries/GetPaymentReceivedMail import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service'; import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service'; import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; @Module({ controllers: [PaymentReceivesController], @@ -95,6 +96,7 @@ import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePa AccountsModule, MailNotificationModule, DynamicListModule, + CustomFieldsModule, MailModule, BullModule.registerQueue({ name: SEND_PAYMENT_RECEIVED_MAIL_QUEUE }), BullBoardModule.forFeature({ diff --git a/packages/server/src/modules/PaymentReceived/commands/CreatePaymentReceived.serivce.ts b/packages/server/src/modules/PaymentReceived/commands/CreatePaymentReceived.serivce.ts index a15b39939..6c4827b86 100644 --- a/packages/server/src/modules/PaymentReceived/commands/CreatePaymentReceived.serivce.ts +++ b/packages/server/src/modules/PaymentReceived/commands/CreatePaymentReceived.serivce.ts @@ -15,6 +15,7 @@ import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; import { Inject, Injectable } from '@nestjs/common'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { CreatePaymentReceivedDto } from '../dtos/PaymentReceived.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable() export class CreatePaymentReceivedService { @@ -24,6 +25,7 @@ export class CreatePaymentReceivedService { private uow: UnitOfWork, private transformer: PaymentReceiveDTOTransformer, private tenancyContext: TenancyContext, + private saveCustomFieldValuesService: SaveCustomFieldValuesService, @Inject(PaymentReceived.name) private paymentReceived: TenantModelProxy, @@ -92,6 +94,17 @@ export class CreatePaymentReceivedService { .insertGraphAndFetch({ ...paymentReceiveObj, }); + + // Save custom field values. + if (paymentReceiveDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'PaymentReceive', + paymentReceive.id, + paymentReceiveDTO.customFields, + trx, + ); + } + // Triggers `onPaymentReceiveCreated` event. await this.eventPublisher.emitAsync(events.paymentReceive.onCreated, { paymentReceive, diff --git a/packages/server/src/modules/PaymentReceived/commands/EditPaymentReceived.service.ts b/packages/server/src/modules/PaymentReceived/commands/EditPaymentReceived.service.ts index f74ad6bd7..4773ae3e8 100644 --- a/packages/server/src/modules/PaymentReceived/commands/EditPaymentReceived.service.ts +++ b/packages/server/src/modules/PaymentReceived/commands/EditPaymentReceived.service.ts @@ -15,6 +15,7 @@ import { Customer } from '@/modules/Customers/models/Customer'; import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { EditPaymentReceivedDto } from '../dtos/PaymentReceived.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable() export class EditPaymentReceivedService { @@ -24,6 +25,7 @@ export class EditPaymentReceivedService { private readonly eventPublisher: EventEmitter2, private readonly uow: UnitOfWork, private readonly tenancyContext: TenancyContext, + private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService, @Inject(PaymentReceived.name) private readonly paymentReceiveModel: TenantModelProxy< @@ -130,6 +132,17 @@ export class EditPaymentReceivedService { id: paymentReceiveId, ...paymentReceiveObj, }); + + // Save custom field values. + if (paymentReceiveDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'PaymentReceive', + paymentReceiveId, + paymentReceiveDTO.customFields, + trx, + ); + } + // Triggers `onPaymentReceiveEdited` event. await this.eventPublisher.emitAsync(events.paymentReceive.onEdited, { paymentReceiveId, diff --git a/packages/server/src/modules/PaymentReceived/dtos/PaymentReceived.dto.ts b/packages/server/src/modules/PaymentReceived/dtos/PaymentReceived.dto.ts index 0b57ad24f..cc8251604 100644 --- a/packages/server/src/modules/PaymentReceived/dtos/PaymentReceived.dto.ts +++ b/packages/server/src/modules/PaymentReceived/dtos/PaymentReceived.dto.ts @@ -8,6 +8,7 @@ import { IsArray, IsNotEmpty, IsInt, + IsObject, ValidateNested, } from 'class-validator'; import { ToNumber } from '@/common/decorators/Validators'; @@ -138,6 +139,15 @@ export class CommandPaymentReceivedDto { ], }) attachments?: AttachmentLinkDto[]; + + @IsOptional() + @IsObject() + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } export class CreatePaymentReceivedDto extends CommandPaymentReceivedDto {} diff --git a/packages/server/src/modules/PaymentReceived/dtos/PaymentReceivedResponse.dto.ts b/packages/server/src/modules/PaymentReceived/dtos/PaymentReceivedResponse.dto.ts index 1860486e9..3a720a69c 100644 --- a/packages/server/src/modules/PaymentReceived/dtos/PaymentReceivedResponse.dto.ts +++ b/packages/server/src/modules/PaymentReceived/dtos/PaymentReceivedResponse.dto.ts @@ -199,4 +199,11 @@ export class PaymentReceivedResponseDto { }) @Type(() => AttachmentLinkDto) attachments?: AttachmentLinkDto[]; + + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } diff --git a/packages/server/src/modules/PaymentReceived/queries/GetPaymentReceived.service.ts b/packages/server/src/modules/PaymentReceived/queries/GetPaymentReceived.service.ts index 6da696b41..4edb318d3 100644 --- a/packages/server/src/modules/PaymentReceived/queries/GetPaymentReceived.service.ts +++ b/packages/server/src/modules/PaymentReceived/queries/GetPaymentReceived.service.ts @@ -5,11 +5,13 @@ import { PaymentReceived } from '../models/PaymentReceived'; import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service'; import { ServiceError } from '../../Items/ServiceError'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service'; @Injectable() export class GetPaymentReceivedService { constructor( private readonly transformer: TransformerInjectable, + private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService, @Inject(PaymentReceived.name) private readonly paymentReceiveModel: TenantModelProxy< @@ -38,9 +40,18 @@ export class GetPaymentReceivedService { if (!paymentReceive) { throw new ServiceError(ERRORS.PAYMENT_RECEIVE_NOT_EXISTS); } - return this.transformer.transform( + // Load custom field values. + const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields( + 'PaymentReceive', + paymentReceiveId, + ); + + const transformed = await this.transformer.transform( paymentReceive, new PaymentReceiveTransfromer(), + { customFields }, ); + + return transformed; } } diff --git a/packages/server/src/modules/PaymentReceived/queries/PaymentReceivedTransformer.ts b/packages/server/src/modules/PaymentReceived/queries/PaymentReceivedTransformer.ts index 427a4c634..f6deb8b8a 100644 --- a/packages/server/src/modules/PaymentReceived/queries/PaymentReceivedTransformer.ts +++ b/packages/server/src/modules/PaymentReceived/queries/PaymentReceivedTransformer.ts @@ -19,9 +19,18 @@ export class PaymentReceiveTransfromer extends Transformer { 'formattedExchangeRate', 'entries', 'attachments', + 'customFields', ]; }; + /** + * Retrieve custom fields from options. + * @returns {Record} + */ + public customFields(): Record { + return this.options?.customFields; + } + /** * Retrieve formatted payment receive date. * @param {PaymentReceived} invoice diff --git a/packages/server/src/modules/Resource/Resource.module.ts b/packages/server/src/modules/Resource/Resource.module.ts index f919a8dc1..d96aff7f6 100644 --- a/packages/server/src/modules/Resource/Resource.module.ts +++ b/packages/server/src/modules/Resource/Resource.module.ts @@ -5,9 +5,10 @@ import { WarehousesModule } from '../Warehouses/Warehouses.module'; import { AccountsExportable } from '../Accounts/AccountsExportable.service'; import { AccountsModule } from '../Accounts/Accounts.module'; import { ResourceController } from './Resource.controller'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; @Module({ - imports: [BranchesModule, WarehousesModule, AccountsModule], + imports: [BranchesModule, WarehousesModule, AccountsModule, CustomFieldsModule], providers: [ResourceService], exports: [ResourceService], controllers: [ResourceController] diff --git a/packages/server/src/modules/Resource/ResourceService.ts b/packages/server/src/modules/Resource/ResourceService.ts index c888e2076..883ac2d9b 100644 --- a/packages/server/src/modules/Resource/ResourceService.ts +++ b/packages/server/src/modules/Resource/ResourceService.ts @@ -10,6 +10,7 @@ import { IModelMeta } from '@/interfaces/Model'; import { IModelMetaField } from '@/interfaces/Model'; import { Features } from '@/common/types/Features'; import { resourceToModelName } from './_utils'; +import { GetCustomFieldsService } from '../CustomFields/queries/GetCustomFields.service'; const ERRORS = { RESOURCE_MODEL_NOT_FOUND: 'RESOURCE_MODEL_NOT_FOUND', @@ -22,6 +23,7 @@ export class ResourceService { private readonly warehousesSettings: WarehousesSettings, private readonly moduleRef: ModuleRef, private readonly i18nService: I18nService, + private readonly getCustomFieldsService: GetCustomFieldsService, ) { } /** @@ -135,20 +137,69 @@ export class ResourceService { return mapValues(fields, (field) => this.localizeField(field)); } + /** + * Maps model name to custom field resource name. + * @param {string} modelName - Model name. + * @returns {string | null} + */ + private getCustomFieldResourceName(modelName: string): string | null { + const resourceMap: Record = { + 'SaleInvoice': 'SaleInvoice', + 'SaleEstimate': 'SaleEstimate', + 'SaleReceipt': 'SaleReceipt', + 'Customer': 'Customer', + 'Item': 'Item', + 'CreditNote': 'CreditNote', + 'PaymentReceive': 'PaymentReceive', + }; + return resourceMap[modelName] || null; + } + /** * Retrieve the resource fields with localized names and hints. * @param {string} modelName * @returns {IModelMetaField2} */ - public getResourceFields2(modelName: string): { + public async getResourceFields2(modelName: string): Promise<{ [key: string]: IModelMetaField2; - } { + }> { const meta = this.getResourceMeta(modelName); const filteredFields = this.filterSupportFeatures(meta.fields2); - return this.localizeFields( + const localizedFields = this.localizeFields( filteredFields as Record, ); + + // Inject custom fields from the database. + const customFieldResource = this.getCustomFieldResourceName(modelName); + if (customFieldResource) { + const customFields = await this.getCustomFieldsService.getCustomFields(customFieldResource); + for (const customField of customFields) { + const fieldTypeMap: Record = { + 'text': 'text', + 'number': 'number', + 'date': 'date', + 'checkbox': 'boolean', + 'dropdown': 'enumeration', + 'url': 'url', + 'textarea': 'text', + 'autonumber': 'text', + 'lookup': 'relation', + 'formula': 'text', + }; + + localizedFields[customField.fieldName] = { + name: customField.label, + fieldType: fieldTypeMap[customField.fieldType] || 'text', + required: customField.required, + ...(customField.fieldType === 'dropdown' && customField.options?.choices + ? { options: customField.options.choices.map((choice: string) => ({ label: choice, key: choice })) } + : {}), + } as IModelMetaField2; + } + } + + return localizedFields; } /** diff --git a/packages/server/src/modules/Roles/Roles.types.ts b/packages/server/src/modules/Roles/Roles.types.ts index 258b76509..29984802e 100644 --- a/packages/server/src/modules/Roles/Roles.types.ts +++ b/packages/server/src/modules/Roles/Roles.types.ts @@ -60,7 +60,8 @@ export enum AbilitySubject { CreditNote = 'CreditNode', VendorCredit = 'VendorCredit', Project = 'Project', - TaxRate = 'TaxRate' + TaxRate = 'TaxRate', + CustomField = 'CustomField', } export interface IRoleCreatedPayload { diff --git a/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts b/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts index ed96c467b..808ff61f3 100644 --- a/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts +++ b/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts @@ -45,6 +45,7 @@ import { SaleEstimateAutoIncrementSubscriber } from './subscribers/SaleEstimateA import { BulkDeleteSaleEstimatesService } from './BulkDeleteSaleEstimates.service'; import { ValidateBulkDeleteSaleEstimatesService } from './ValidateBulkDeleteSaleEstimates.service'; import { SendSaleEstimateMailProcess } from './processes/SendSaleEstimateMail.process'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; @Module({ imports: [ @@ -55,6 +56,7 @@ import { SendSaleEstimateMailProcess } from './processes/SendSaleEstimateMail.pr ChromiumlyTenancyModule, TemplateInjectableModule, PdfTemplatesModule, + CustomFieldsModule, BullModule.registerQueue({ name: SendSaleEstimateMailQueue }), BullBoardModule.forFeature({ name: SendSaleEstimateMailQueue, diff --git a/packages/server/src/modules/SaleEstimates/commands/CreateSaleEstimate.service.ts b/packages/server/src/modules/SaleEstimates/commands/CreateSaleEstimate.service.ts index b66cb6e1d..5be2bd7f6 100644 --- a/packages/server/src/modules/SaleEstimates/commands/CreateSaleEstimate.service.ts +++ b/packages/server/src/modules/SaleEstimates/commands/CreateSaleEstimate.service.ts @@ -78,6 +78,7 @@ export class CreateSaleEstimate { .upsertGraphAndFetch({ ...estimateObj, }); + // Triggers `onSaleEstimateCreated` event. await this.eventPublisher.emitAsync(events.saleEstimate.onCreated, { saleEstimate, diff --git a/packages/server/src/modules/SaleEstimates/dtos/SaleEstimate.dto.ts b/packages/server/src/modules/SaleEstimates/dtos/SaleEstimate.dto.ts index 544ca2260..4e5bfd029 100644 --- a/packages/server/src/modules/SaleEstimates/dtos/SaleEstimate.dto.ts +++ b/packages/server/src/modules/SaleEstimates/dtos/SaleEstimate.dto.ts @@ -10,11 +10,13 @@ import { IsEnum, IsNotEmpty, IsNumber, + IsObject, + IsOptional, IsString, Min, ValidateNested, } from 'class-validator'; -import { IsOptional, ToNumber } from '@/common/decorators/Validators'; +import { ToNumber } from '@/common/decorators/Validators'; enum DiscountType { Percentage = 'percentage', @@ -176,6 +178,15 @@ export class CommandSaleEstimateDto { example: 1, }) adjustment?: number; + + @IsOptional() + @IsObject() + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } export class CreateSaleEstimateDto extends CommandSaleEstimateDto { } diff --git a/packages/server/src/modules/SaleEstimates/dtos/SaleEstimateResponse.dto.ts b/packages/server/src/modules/SaleEstimates/dtos/SaleEstimateResponse.dto.ts index 72dde351c..056043752 100644 --- a/packages/server/src/modules/SaleEstimates/dtos/SaleEstimateResponse.dto.ts +++ b/packages/server/src/modules/SaleEstimates/dtos/SaleEstimateResponse.dto.ts @@ -203,4 +203,11 @@ export class SaleEstimateResponseDto { }) @Type(() => AttachmentLinkDto) attachments: AttachmentLinkDto[]; + + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } diff --git a/packages/server/src/modules/SaleEstimates/queries/GetSaleEstimate.service.ts b/packages/server/src/modules/SaleEstimates/queries/GetSaleEstimate.service.ts index b151720d6..cd45b488d 100644 --- a/packages/server/src/modules/SaleEstimates/queries/GetSaleEstimate.service.ts +++ b/packages/server/src/modules/SaleEstimates/queries/GetSaleEstimate.service.ts @@ -6,6 +6,7 @@ import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectab import { SaleEstimate } from '../models/SaleEstimate'; import { events } from '@/common/events/events'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service'; @Injectable() export class GetSaleEstimate { @@ -16,6 +17,7 @@ export class GetSaleEstimate { private readonly transformer: TransformerInjectable, private readonly validators: SaleEstimateValidators, private readonly eventPublisher: EventEmitter2, + private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService, ) {} /** @@ -35,11 +37,19 @@ export class GetSaleEstimate { // Validates the estimate existance. this.validators.validateEstimateExistance(estimate); + // Load custom field values. + const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields( + 'SaleEstimate', + estimateId, + ); + // Transformes sale estimate model to POJO. const transformed = await this.transformer.transform( estimate, new SaleEstimateTransfromer(), + { customFields }, ); + const eventPayload = { saleEstimateId: estimateId }; // Triggers `onSaleEstimateViewed` event. diff --git a/packages/server/src/modules/SaleEstimates/queries/SaleEstimate.transformer.ts b/packages/server/src/modules/SaleEstimates/queries/SaleEstimate.transformer.ts index 379a6d6d4..ed4659685 100644 --- a/packages/server/src/modules/SaleEstimates/queries/SaleEstimate.transformer.ts +++ b/packages/server/src/modules/SaleEstimates/queries/SaleEstimate.transformer.ts @@ -27,9 +27,18 @@ export class SaleEstimateTransfromer extends Transformer { 'formattedCreatedAt', 'entries', 'attachments', + 'customFields', ]; }; + /** + * Retrieve custom fields from options. + * @returns {Record} + */ + public customFields(): Record { + return this.options?.customFields; + } + /** * Retrieve formatted estimate date. * @param {ISaleEstimate} invoice diff --git a/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts b/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts index 0c66e1111..171f7678e 100644 --- a/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts +++ b/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts @@ -62,6 +62,7 @@ import { SaleInvoicesCost } from './SalesInvoicesCost'; import { SaleInvoicesExportable } from './commands/SaleInvoicesExportable'; import { SaleInvoicesImportable } from './commands/SaleInvoicesImportable'; import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; import { BulkDeleteSaleInvoicesService } from './BulkDeleteSaleInvoices.service'; import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleInvoices.service'; @@ -82,6 +83,7 @@ import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleI forwardRef(() => InventoryCostModule), forwardRef(() => PaymentLinksModule), DynamicListModule, + CustomFieldsModule, BullModule.registerQueue({ name: SendSaleInvoiceQueue }), BullBoardModule.forFeature({ name: SendSaleInvoiceQueue, diff --git a/packages/server/src/modules/SaleInvoices/commands/EditSaleInvoice.service.ts b/packages/server/src/modules/SaleInvoices/commands/EditSaleInvoice.service.ts index 2d7b0629c..ba76e6dc0 100644 --- a/packages/server/src/modules/SaleInvoices/commands/EditSaleInvoice.service.ts +++ b/packages/server/src/modules/SaleInvoices/commands/EditSaleInvoice.service.ts @@ -115,6 +115,7 @@ export class EditSaleInvoice { id: saleInvoiceId, ...saleInvoiceObj, }); + // Edit event payload. const editEventPayload: ISaleInvoiceEditedPayload = { saleInvoiceId, diff --git a/packages/server/src/modules/SaleInvoices/dtos/SaleInvoice.dto.ts b/packages/server/src/modules/SaleInvoices/dtos/SaleInvoice.dto.ts index 4627475a6..c60a0d611 100644 --- a/packages/server/src/modules/SaleInvoices/dtos/SaleInvoice.dto.ts +++ b/packages/server/src/modules/SaleInvoices/dtos/SaleInvoice.dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, ToNumber } from '@/common/decorators/Validators'; +import { ToNumber } from '@/common/decorators/Validators'; import { ItemEntryDto } from '@/modules/TransactionItemEntry/dto/ItemEntry.dto'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -11,6 +11,8 @@ import { IsInt, IsNotEmpty, IsNumber, + IsObject, + IsOptional, IsString, Min, ValidateNested, @@ -216,6 +218,15 @@ class CommandSaleInvoiceDto { example: [{ key: '123456' }], }) attachments?: AttachmentDto[]; + + @IsOptional() + @IsObject() + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } export class CreateSaleInvoiceDto extends CommandSaleInvoiceDto {} diff --git a/packages/server/src/modules/SaleInvoices/dtos/SaleInvoiceResponse.dto.ts b/packages/server/src/modules/SaleInvoices/dtos/SaleInvoiceResponse.dto.ts index 29ec568c6..cf3c23fb8 100644 --- a/packages/server/src/modules/SaleInvoices/dtos/SaleInvoiceResponse.dto.ts +++ b/packages/server/src/modules/SaleInvoices/dtos/SaleInvoiceResponse.dto.ts @@ -234,4 +234,11 @@ export class SaleInvoiceResponseDto { required: false, }) updatedAt?: Date; + + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } diff --git a/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoice.service.ts b/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoice.service.ts index 4bfcfaa94..b32db8ad0 100644 --- a/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoice.service.ts +++ b/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoice.service.ts @@ -8,6 +8,7 @@ import { CommandSaleInvoiceValidators } from '../commands/CommandSaleInvoiceVali import { events } from '@/common/events/events'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { SaleInvoiceResponseDto } from '../dtos/SaleInvoiceResponse.dto'; +import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service'; @Injectable() export class GetSaleInvoice { @@ -15,6 +16,7 @@ export class GetSaleInvoice { private transformer: TransformerInjectable, private validators: CommandSaleInvoiceValidators, private eventPublisher: EventEmitter2, + private getResourceCustomFieldsService: GetResourceCustomFieldsService, @Inject(SaleInvoice.name) private saleInvoiceModel: TenantModelProxy, @@ -44,10 +46,18 @@ export class GetSaleInvoice { // Validates the given sale invoice existance. this.validators.validateInvoiceExistance(saleInvoice); + // Load custom field values. + const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields( + 'SaleInvoice', + saleInvoiceId, + ); + const transformed = await this.transformer.transform( saleInvoice, new SaleInvoiceTransformer(), + { customFields }, ); + const eventPayload = { saleInvoiceId, }; diff --git a/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoices.ts b/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoices.ts index e0cb1536f..f7db2901c 100644 --- a/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoices.ts +++ b/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoices.ts @@ -8,12 +8,14 @@ import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model'; import { SaleInvoice } from '../models/SaleInvoice'; import { GetSaleInvoicesQueryDto } from '../dtos/GetSaleInvoicesQuery.dto'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service'; @Injectable() export class GetSaleInvoicesService { constructor( private readonly dynamicListService: DynamicListService, private readonly transformer: TransformerInjectable, + private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService, @Inject(SaleInvoice.name) private readonly saleInvoiceModel: TenantModelProxy, @@ -63,6 +65,18 @@ export class GetSaleInvoicesService { new SaleInvoiceTransformer(), ); + // Load custom field values for all invoices. + const invoiceIds = salesInvoices.map((invoice) => invoice.id); + if (invoiceIds.length > 0) { + const customFieldsMap = await this.getResourceCustomFieldsService.getResourceCustomFieldsBulk( + 'SaleInvoice', + invoiceIds, + ); + salesInvoices.forEach((invoice) => { + invoice.customFields = customFieldsMap[invoice.id] || {}; + }); + } + return { salesInvoices, pagination, diff --git a/packages/server/src/modules/SaleInvoices/queries/SaleInvoice.transformer.ts b/packages/server/src/modules/SaleInvoices/queries/SaleInvoice.transformer.ts index 2ab529f42..c9a00c318 100644 --- a/packages/server/src/modules/SaleInvoices/queries/SaleInvoice.transformer.ts +++ b/packages/server/src/modules/SaleInvoices/queries/SaleInvoice.transformer.ts @@ -32,9 +32,18 @@ export class SaleInvoiceTransformer extends Transformer { 'taxes', 'entries', 'attachments', + 'customFields', ]; }; + /** + * Retrieve custom fields from options. + * @returns {Record} + */ + public customFields(): Record { + return this.options?.customFields; + } + /** * Retrieve formatted invoice date. * @param {ISaleInvoice} invoice diff --git a/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts b/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts index 480cc102c..dbc8d6dc0 100644 --- a/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts +++ b/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts @@ -46,6 +46,7 @@ import { SaleReceiptCostGLEntriesSubscriber } from './subscribers/SaleReceiptCos import { SaleReceiptCostGLEntries } from './SaleReceiptCostGLEntries'; import { BulkDeleteSaleReceiptsService } from './BulkDeleteSaleReceipts.service'; import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleReceipts.service'; +import { CustomFieldsModule } from '../CustomFields/CustomFields.module'; @Module({ controllers: [SaleReceiptsController], @@ -61,6 +62,7 @@ import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleR AccountsModule, InventoryCostModule, DynamicListModule, + CustomFieldsModule, MailModule, MailNotificationModule, BullModule.registerQueue({ name: SendSaleReceiptMailQueue }), diff --git a/packages/server/src/modules/SaleReceipts/commands/EditSaleReceipt.service.ts b/packages/server/src/modules/SaleReceipts/commands/EditSaleReceipt.service.ts index 87eaf19b7..9c656494e 100644 --- a/packages/server/src/modules/SaleReceipts/commands/EditSaleReceipt.service.ts +++ b/packages/server/src/modules/SaleReceipts/commands/EditSaleReceipt.service.ts @@ -15,6 +15,7 @@ import { events } from '@/common/events/events'; import { Customer } from '@/modules/Customers/models/Customer'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { EditSaleReceiptDto } from '../dtos/SaleReceipt.dto'; +import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service'; @Injectable() export class EditSaleReceipt { @@ -24,6 +25,7 @@ export class EditSaleReceipt { private readonly uow: UnitOfWork, private readonly validators: SaleReceiptValidators, private readonly dtoTransformer: SaleReceiptDTOTransformer, + private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService, @Inject(SaleReceipt.name) private readonly saleReceiptModel: TenantModelProxy, @@ -96,6 +98,17 @@ export class EditSaleReceipt { id: saleReceiptId, ...saleReceiptObj, }); + + // Save custom field values. + if (saleReceiptDTO.customFields) { + await this.saveCustomFieldValuesService.saveValues( + 'SaleReceipt', + saleReceiptId, + saleReceiptDTO.customFields, + trx, + ); + } + // Triggers `onSaleReceiptEdited` event. await this.eventPublisher.emitAsync(events.saleReceipt.onEdited, { oldSaleReceipt, diff --git a/packages/server/src/modules/SaleReceipts/dtos/SaleReceipt.dto.ts b/packages/server/src/modules/SaleReceipts/dtos/SaleReceipt.dto.ts index 123999c48..2ce1a2ac0 100644 --- a/packages/server/src/modules/SaleReceipts/dtos/SaleReceipt.dto.ts +++ b/packages/server/src/modules/SaleReceipts/dtos/SaleReceipt.dto.ts @@ -11,6 +11,7 @@ import { IsEnum, IsNotEmpty, IsNumber, + IsObject, IsOptional, IsPositive, IsString, @@ -175,6 +176,15 @@ export class CommandSaleReceiptDto { example: 1, }) adjustment?: number; + + @IsOptional() + @IsObject() + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } export class CreateSaleReceiptDto extends CommandSaleReceiptDto {} diff --git a/packages/server/src/modules/SaleReceipts/dtos/SaleReceiptResponse.dto.ts b/packages/server/src/modules/SaleReceipts/dtos/SaleReceiptResponse.dto.ts index a5029a000..affd83e35 100644 --- a/packages/server/src/modules/SaleReceipts/dtos/SaleReceiptResponse.dto.ts +++ b/packages/server/src/modules/SaleReceipts/dtos/SaleReceiptResponse.dto.ts @@ -243,4 +243,11 @@ export class SaleReceiptResponseDto { required: false, }) updatedAt?: Date; + + @ApiProperty({ + description: 'Custom fields values', + required: false, + example: { cf_priority: 'High' }, + }) + customFields?: Record; } diff --git a/packages/server/src/modules/SaleReceipts/queries/GetSaleReceipt.service.ts b/packages/server/src/modules/SaleReceipts/queries/GetSaleReceipt.service.ts index aa88d1d19..3a564cb37 100644 --- a/packages/server/src/modules/SaleReceipts/queries/GetSaleReceipt.service.ts +++ b/packages/server/src/modules/SaleReceipts/queries/GetSaleReceipt.service.ts @@ -4,6 +4,7 @@ import { SaleReceiptValidators } from '../commands/SaleReceiptValidators.service import { SaleReceipt } from '../models/SaleReceipt'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service'; @Injectable() export class GetSaleReceipt { @@ -11,6 +12,7 @@ export class GetSaleReceipt { @Inject(SaleReceipt.name) private readonly saleReceiptModel: TenantModelProxy, private readonly transformer: TransformerInjectable, + private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService, ) {} /** @@ -29,9 +31,18 @@ export class GetSaleReceipt { .withGraphFetched('attachments') .throwIfNotFound(); - return this.transformer.transform( + // Load custom field values. + const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields( + 'SaleReceipt', + saleReceiptId, + ); + + const transformed = await this.transformer.transform( saleReceipt, new SaleReceiptTransformer(), + { customFields }, ); + + return transformed; } } diff --git a/packages/server/src/modules/SaleReceipts/queries/SaleReceiptTransformer.ts b/packages/server/src/modules/SaleReceipts/queries/SaleReceiptTransformer.ts index 25b2ab529..3fb83ed84 100644 --- a/packages/server/src/modules/SaleReceipts/queries/SaleReceiptTransformer.ts +++ b/packages/server/src/modules/SaleReceipts/queries/SaleReceiptTransformer.ts @@ -26,9 +26,18 @@ export class SaleReceiptTransformer extends Transformer { 'entries', 'attachments', + 'customFields', ]; }; + /** + * Retrieve custom fields from options. + * @returns {Record} + */ + public customFields(): Record { + return this.options?.customFields; + } + /** * Retrieve formatted receipt date. * @param {ISaleReceipt} invoice diff --git a/packages/server/test/custom-fields.e2e-spec.ts b/packages/server/test/custom-fields.e2e-spec.ts new file mode 100644 index 000000000..969c8b02d --- /dev/null +++ b/packages/server/test/custom-fields.e2e-spec.ts @@ -0,0 +1,309 @@ +import * as request from 'supertest'; +import { faker } from '@faker-js/faker'; +import { + app, + AuthorizationHeader, + orgainzationId, +} from './init-app-test'; + +const makeCustomFieldRequest = (overrides = {}) => ({ + resourceName: 'Item', + fieldName: `cf_${faker.string.alphanumeric({ length: 8 }).toLowerCase()}`, + label: faker.commerce.productAdjective(), + fieldType: 'text', + required: false, + order: 1, + active: true, + ...overrides, +}); + +const makeItemRequest = () => ({ + name: faker.commerce.productName(), + type: 'service', +}); + +describe('Custom Fields (e2e)', () => { + it('/custom-fields (POST) - should create a custom field', () => { + return request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest()) + .expect(201) + .expect((res) => { + expect(res.body.id).toBeDefined(); + expect(res.body.fieldName).toBeDefined(); + expect(res.body.label).toBeDefined(); + }); + }); + + it('/custom-fields (GET) - should retrieve custom fields', async () => { + const field = await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest()); + + return request(app.getHttpServer()) + .get('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(Array.isArray(res.body.data)).toBe(true); + }); + }); + + it('/custom-fields?resource=Item (GET) - should retrieve custom fields by resource', async () => { + const field = await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest({ resourceName: 'Item' })); + + return request(app.getHttpServer()) + .get('/custom-fields') + .query({ resource: 'Item' }) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.some((f: any) => f.id === field.body.id)).toBe(true); + }); + }); + + it('/custom-fields/:id (GET) - should retrieve a single custom field', async () => { + const field = await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest()); + + return request(app.getHttpServer()) + .get(`/custom-fields/${field.body.id}`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200) + .expect((res) => { + expect(res.body.id).toBe(field.body.id); + expect(res.body.fieldName).toBe(field.body.fieldName); + }); + }); + + it('/custom-fields/:id (PUT) - should edit a custom field', async () => { + const field = await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest()); + + const newLabel = faker.commerce.productAdjective(); + + return request(app.getHttpServer()) + .put(`/custom-fields/${field.body.id}`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send({ + resourceName: field.body.resourceName, + fieldName: field.body.fieldName, + label: newLabel, + fieldType: field.body.fieldType, + }) + .expect(200) + .expect((res) => { + expect(res.body.id).toBe(field.body.id); + expect(res.body.label).toBe(newLabel); + }); + }); + + it('/custom-fields/:id/status (PUT) - should update field status', async () => { + const field = await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest({ active: true })); + + await request(app.getHttpServer()) + .put(`/custom-fields/${field.body.id}/status`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send({ active: false }) + .expect(200); + + const updated = await request(app.getHttpServer()) + .get(`/custom-fields/${field.body.id}`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200); + + expect(updated.body.active).toBe(false); + }); + + it('/custom-fields/reorder (POST) - should reorder custom fields', async () => { + const field1 = await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest({ order: 1 })); + + const field2 = await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest({ order: 2 })); + + return request(app.getHttpServer()) + .post('/custom-fields/reorder') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send({ + orders: [ + { id: field1.body.id, order: 2 }, + { id: field2.body.id, order: 1 }, + ], + }) + .expect(200) + .expect((res) => { + expect(Array.isArray(res.body)).toBe(true); + }); + }); + + it('/custom-fields/:id (DELETE) - should delete a custom field', async () => { + const field = await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest()); + + await request(app.getHttpServer()) + .delete(`/custom-fields/${field.body.id}`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200); + + return request(app.getHttpServer()) + .get(`/custom-fields/${field.body.id}`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(404); + }); + + it('should save custom field values when creating an item', async () => { + const fieldName = `cf_${faker.string.alphanumeric({ length: 8 }).toLowerCase()}`; + + await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest({ + resourceName: 'Item', + fieldName, + label: 'Test Field', + fieldType: 'text', + })); + + const customFieldValue = faker.commerce.productMaterial(); + + const item = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send({ + ...makeItemRequest(), + customFields: { + [fieldName]: customFieldValue, + }, + }) + .expect(201); + + const fetchedItem = await request(app.getHttpServer()) + .get(`/items/${item.body.id}`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200); + + expect(fetchedItem.body.customFields).toBeDefined(); + expect(fetchedItem.body.customFields[fieldName]).toBe(customFieldValue); + }); + + it('should update custom field values when editing an item', async () => { + const fieldName = `cf_${faker.string.alphanumeric({ length: 8 }).toLowerCase()}`; + + await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest({ + resourceName: 'Item', + fieldName, + label: 'Test Field', + fieldType: 'text', + })); + + const initialValue = faker.commerce.productMaterial(); + const updatedValue = faker.commerce.productMaterial(); + + const item = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send({ + ...makeItemRequest(), + customFields: { + [fieldName]: initialValue, + }, + }) + .expect(201); + + await request(app.getHttpServer()) + .put(`/items/${item.body.id}`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send({ + ...makeItemRequest(), + customFields: { + [fieldName]: updatedValue, + }, + }) + .expect(200); + + const fetchedItem = await request(app.getHttpServer()) + .get(`/items/${item.body.id}`) + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200); + + expect(fetchedItem.body.customFields).toBeDefined(); + expect(fetchedItem.body.customFields[fieldName]).toBe(updatedValue); + }); + + it('should inject custom fields into resource metadata', async () => { + const fieldName = `cf_${faker.string.alphanumeric({ length: 8 }).toLowerCase()}`; + const label = faker.commerce.productAdjective(); + + await request(app.getHttpServer()) + .post('/custom-fields') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .send(makeCustomFieldRequest({ + resourceName: 'Item', + fieldName, + label, + fieldType: 'text', + })); + + const meta = await request(app.getHttpServer()) + .get('/resources/Item/meta') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200); + + expect(meta.body.fields2).toBeDefined(); + expect(meta.body.fields2[fieldName]).toBeDefined(); + expect(meta.body.fields2[fieldName].name).toBe(label); + expect(meta.body.fields2[fieldName].fieldType).toBe('text'); + }); +}); diff --git a/packages/server/test/jest-e2e.json b/packages/server/test/jest-e2e.json index 78a1c028e..f4eee7917 100644 --- a/packages/server/test/jest-e2e.json +++ b/packages/server/test/jest-e2e.json @@ -2,7 +2,7 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", - "testRegex": "auth.e2e-spec.ts$", + "testRegex": "(auth|custom-fields)\\.e2e-spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/packages/webapp/src/constants/preferencesMenu.tsx b/packages/webapp/src/constants/preferencesMenu.tsx index 36296eb4e..2fd9b8a4f 100644 --- a/packages/webapp/src/constants/preferencesMenu.tsx +++ b/packages/webapp/src/constants/preferencesMenu.tsx @@ -63,6 +63,11 @@ export const PreferencesMenu = [ disabled: false, href: '/preferences/items', }, + { + text: 'Custom Fields', + disabled: false, + href: '/preferences/custom-fields', + }, // { // text: 'Integrations', // disabled: false, diff --git a/packages/webapp/src/containers/Alerts/CustomFields/CustomFieldDeleteAlert.tsx b/packages/webapp/src/containers/Alerts/CustomFields/CustomFieldDeleteAlert.tsx new file mode 100644 index 000000000..c938f4b17 --- /dev/null +++ b/packages/webapp/src/containers/Alerts/CustomFields/CustomFieldDeleteAlert.tsx @@ -0,0 +1,81 @@ +// @ts-nocheck +import React from 'react'; +import intl from 'react-intl-universal'; +import { + AppToaster, + FormattedMessage as T, + FormattedHTMLMessage, +} from '@/components'; +import { Intent, Alert } from '@blueprintjs/core'; + +import { useDeleteCustomField } from '@/hooks/query'; + +import { withAlertStoreConnect } from '@/containers/Alert/withAlertStoreConnect'; +import { withAlertActions } from '@/containers/Alert/withAlertActions'; + +import { compose } from '@/utils'; + +/** + * Custom field delete alert. + */ +function CustomFieldDeleteAlert({ + name, + + // #withAlertStoreConnect + isOpen, + payload: { customFieldId }, + + // #withAlertActions + closeAlert, +}) { + const { mutateAsync: deleteCustomField, isLoading } = useDeleteCustomField(); + + // Handle cancel delete alert. + const handleCancelDelete = () => { + closeAlert(name); + }; + + // Handle confirm delete custom field. + const handleConfirmDelete = () => { + deleteCustomField(customFieldId) + .then(() => { + AppToaster.show({ + message: intl.get('custom_fields.delete_success_message'), + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: intl.get('custom_fields.delete_error_message'), + intent: Intent.DANGER, + }); + }) + .finally(() => { + closeAlert(name); + }); + }; + + return ( + } + confirmButtonText={} + icon="trash" + intent={Intent.DANGER} + isOpen={isOpen} + onCancel={handleCancelDelete} + onConfirm={handleConfirmDelete} + loading={isLoading} + > +

+ +

+
+ ); +} + +export default compose( + withAlertStoreConnect(), + withAlertActions, +)(CustomFieldDeleteAlert); diff --git a/packages/webapp/src/containers/Alerts/CustomFields/CustomFieldsAlerts.tsx b/packages/webapp/src/containers/Alerts/CustomFields/CustomFieldsAlerts.tsx new file mode 100644 index 000000000..acb0cb65e --- /dev/null +++ b/packages/webapp/src/containers/Alerts/CustomFields/CustomFieldsAlerts.tsx @@ -0,0 +1,11 @@ +// @ts-nocheck +import React from 'react'; + +const CustomFieldDeleteAlert = React.lazy( + () => import('@/containers/Alerts/CustomFields/CustomFieldDeleteAlert'), +); + +/** + * Custom fields alerts + */ +export default [{ name: 'custom-field-delete', component: CustomFieldDeleteAlert }]; diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx index 8f503746f..7a0595b93 100644 --- a/packages/webapp/src/containers/AlertsContainer/registered.tsx +++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx @@ -31,6 +31,7 @@ import { SubscriptionAlerts } from '../Subscriptions/alerts/alerts'; import { BankAccountAlerts } from '@/containers/CashFlow/AccountTransactions/alerts'; import { BrandingTemplatesAlerts } from '../BrandingTemplates/alerts/BrandingTemplatesAlerts'; import { PaymentMethodsAlerts } from '../Preferences/PaymentMethods/alerts/PaymentMethodsAlerts'; +import CustomFieldsAlerts from '@/containers/Alerts/CustomFields/CustomFieldsAlerts'; export default [ ...AccountsAlerts, @@ -65,4 +66,5 @@ export default [ ...BankAccountAlerts, ...BrandingTemplatesAlerts, ...PaymentMethodsAlerts, + ...CustomFieldsAlerts, ]; diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsForm.schema.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsForm.schema.tsx new file mode 100644 index 000000000..dddd4801a --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsForm.schema.tsx @@ -0,0 +1,15 @@ +// @ts-nocheck +import * as Yup from 'yup'; +import intl from 'react-intl-universal'; +import { DATATYPES_LENGTH } from '@/constants/dataTypes'; + +export const CustomFieldsFormSchema = Yup.object().shape({ + resourceName: Yup.string().required().label(intl.get('custom_fields.label.resource')), + fieldName: Yup.string().required().max(DATATYPES_LENGTH.STRING).label(intl.get('custom_fields.label.field_name')), + label: Yup.string().required().max(DATATYPES_LENGTH.STRING).label(intl.get('custom_fields.label.label')), + fieldType: Yup.string().required().label(intl.get('custom_fields.label.type')), + required: Yup.boolean(), + order: Yup.number().integer().min(0), + active: Yup.boolean(), + defaultValue: Yup.string().nullable().max(DATATYPES_LENGTH.STRING), +}); diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsForm.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsForm.tsx new file mode 100644 index 000000000..8608cd22e --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsForm.tsx @@ -0,0 +1,108 @@ +// @ts-nocheck +import React from 'react'; +import intl from 'react-intl-universal'; +import { useHistory } from 'react-router-dom'; +import { Formik } from 'formik'; +import { isEmpty } from 'lodash'; +import { Intent } from '@blueprintjs/core'; + +import { AppToaster, FormattedMessage as T } from '@/components'; +import { CustomFieldsFormSchema } from './CustomFieldsForm.schema'; +import { useCustomFieldsFormContext } from './CustomFieldsFormProvider'; +import { withDashboardActions } from '@/containers/Dashboard/withDashboardActions'; +import CustomFieldsFormContent from './CustomFieldsFormContent'; +import { compose, transformToForm } from '@/utils'; + +const defaultValues = { + resourceName: 'Item', + fieldName: '', + label: '', + fieldType: 'text', + options: {}, + required: false, + order: 0, + active: true, + defaultValue: '', +}; + +/** + * Preferences - Custom Fields Form. + */ +function CustomFieldsForm({ + // #withDashboardActions + changePreferencesPageTitle, +}) { + // History context. + const history = useHistory(); + + // Custom fields form context. + const { + isNewMode, + createCustomFieldMutate, + editCustomFieldMutate, + customField, + customFieldId, + } = useCustomFieldsFormContext(); + + // Initial values. + const initialValues = { + ...defaultValues, + ...(!isEmpty(customField) + ? transformToForm(customField, defaultValues) + : {}), + }; + + React.useEffect(() => { + changePreferencesPageTitle( + isNewMode ? ( + + ) : ( + + ), + ); + }, [changePreferencesPageTitle, isNewMode]); + + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + setSubmitting(true); + + const onSuccess = () => { + AppToaster.show({ + message: intl.get( + isNewMode + ? 'custom_fields.create_success_message' + : 'custom_fields.edit_success_message', + ), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + history.push('/preferences/custom-fields'); + }; + + const onError = (error) => { + setSubmitting(false); + AppToaster.show({ + message: intl.get('custom_fields.error_message'), + intent: Intent.DANGER, + }); + }; + + if (isNewMode) { + createCustomFieldMutate(values).then(onSuccess).catch(onError); + } else { + editCustomFieldMutate([customFieldId, values]).then(onSuccess).catch(onError); + } + }; + + return ( + + + + ); +} + +export default compose(withDashboardActions)(CustomFieldsForm); diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormContent.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormContent.tsx new file mode 100644 index 000000000..656a68b9b --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormContent.tsx @@ -0,0 +1,19 @@ +// @ts-nocheck +import React from 'react'; +import { Form } from 'formik'; + +import { CustomFieldsFormHeader } from './CustomFieldsFormHeader'; +import { CustomFieldsFormFloatingActions } from './CustomFieldsFormFloatingActions'; + +/** + * Preferences - Custom Fields Form content. + * @returns {React.JSX} + */ +export default function CustomFieldsFormContent() { + return ( +
+ + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormFloatingActions.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormFloatingActions.tsx new file mode 100644 index 000000000..381748727 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormFloatingActions.tsx @@ -0,0 +1,50 @@ +// @ts-nocheck +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { useFormikContext } from 'formik'; + +import { FormattedMessage as T } from '@/components'; +import { Button, Intent, Classes } from '@blueprintjs/core'; + +import { useCustomFieldsFormContext } from './CustomFieldsFormProvider'; + +/** + * Custom Fields form floating actions. + */ +export function CustomFieldsFormFloatingActions() { + // History context. + const history = useHistory(); + + // Formik context. + const { isSubmitting } = useFormikContext(); + + // Custom fields form context. + const { isNewMode } = useCustomFieldsFormContext(); + + // Handle cancel button click. + const handleCancelBtnClick = () => { + history.go(-1); + }; + + return ( +
+ + +
+ ); +} diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormHeader.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormHeader.tsx new file mode 100644 index 000000000..7bac297a3 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormHeader.tsx @@ -0,0 +1,267 @@ +// @ts-nocheck +import React from 'react'; +import intl from 'react-intl-universal'; +import { useFormikContext } from 'formik'; + +import { + FormattedMessage as T, + FieldRequiredHint, + Card, + FFormGroup, + FInputGroup, + FSelect, + FCheckbox, + FSwitch, + FNumericInput, +} from '@/components'; +import { useAutofocus } from '@/hooks'; +import { useCustomFieldsFormContext } from './CustomFieldsFormProvider'; + +const RESOURCE_OPTIONS = [ + { label: 'Sale Invoice', value: 'SaleInvoice' }, + { label: 'Sale Estimate', value: 'SaleEstimate' }, + { label: 'Sale Receipt', value: 'SaleReceipt' }, + { label: 'Customer', value: 'Customer' }, + { label: 'Item', value: 'Item' }, + { label: 'Credit Note', value: 'CreditNote' }, + { label: 'Payment Receive', value: 'PaymentReceive' }, +]; + +const FIELD_TYPE_OPTIONS = [ + { label: 'Text', value: 'text' }, + { label: 'Number', value: 'number' }, + { label: 'Date', value: 'date' }, + { label: 'Checkbox', value: 'checkbox' }, + { label: 'Dropdown', value: 'dropdown' }, + { label: 'URL', value: 'url' }, + { label: 'Textarea', value: 'textarea' }, + { label: 'Auto Number', value: 'autonumber' }, + { label: 'Lookup', value: 'lookup' }, + { label: 'Formula', value: 'formula' }, +]; + +/** + * Custom Fields form header. + * @returns {React.JSX} + */ +export function CustomFieldsFormHeader() { + const labelFieldRef = useAutofocus(); + const { values } = useFormikContext(); + const { isNewMode } = useCustomFieldsFormContext(); + const isDropdown = values.fieldType === 'dropdown'; + + return ( + + {/* ---------- Resource ---------- */} + + + + } + labelInfo={} + inline + fastField + > + + + + {/* ---------- Field Name ---------- */} + + + + } + labelInfo={} + inline + fastField + > + (labelFieldRef.current = ref)} + fill + fastField + /> + + + {/* ---------- Label ---------- */} + + + + } + labelInfo={} + inline + fastField + > + + + + {/* ---------- Field Type ---------- */} + + + + } + labelInfo={} + inline + fastField + > + + + + {/* ---------- Dropdown Options ---------- */} + {isDropdown && ( + + )} + + {/* ---------- Default Value ---------- */} + } + inline + fastField + > + + + + {/* ---------- Required ---------- */} + } + inline + fastField + > + + + + {/* ---------- Order ---------- */} + } + inline + fastField + > + + + + {/* ---------- Active ---------- */} + } + inline + fastField + > + + + + ); +} + +/** + * Dropdown options field. + */ +function DropdownOptionsField() { + const { values, setFieldValue } = useFormikContext(); + const choices = values.options?.choices || []; + + const handleAddChoice = () => { + const newChoices = [...choices, '']; + setFieldValue('options', { ...values.options, choices: newChoices }); + }; + + const handleRemoveChoice = (index) => { + const newChoices = choices.filter((_, i) => i !== index); + setFieldValue('options', { ...values.options, choices: newChoices }); + }; + + const handleChangeChoice = (index, value) => { + const newChoices = [...choices]; + newChoices[index] = value; + setFieldValue('options', { ...values.options, choices: newChoices }); + }; + + return ( + } + inline + > +
+ {choices.map((choice, index) => ( +
+ handleChangeChoice(index, e.target.value)} + className="bp4-input bp4-fill" + placeholder={intl.get('custom_fields.choice_placeholder')} + /> + +
+ ))} + +
+
+ ); +} diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormPage.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormPage.tsx new file mode 100644 index 000000000..47214616b --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormPage.tsx @@ -0,0 +1,20 @@ +// @ts-nocheck +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { CustomFieldsFormProvider } from './CustomFieldsFormProvider'; +import CustomFieldsForm from './CustomFieldsForm'; + +/** + * Custom Fields Form page. + */ +export default function CustomFieldsFormPage() { + const { id } = useParams(); + const idInteger = id ? parseInt(id, 10) : null; + const isNewMode = !idInteger; + + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormProvider.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormProvider.tsx new file mode 100644 index 000000000..d1c184cc9 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormProvider.tsx @@ -0,0 +1,57 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import { CLASSES } from '@/constants/classes'; + +import { + useCreateCustomField, + useEditCustomField, + useCustomField, +} from '@/hooks/query'; +import PreferencesPageLoader from '@/containers/Preferences/PreferencesPageLoader'; + +const CustomFieldsFormContext = React.createContext(); + +/** + * Custom Fields Form page provider. + */ +function CustomFieldsFormProvider({ customFieldId, isNewMode, ...props }) { + // Create and edit custom fields mutations. + const { mutateAsync: createCustomFieldMutate } = useCreateCustomField(); + const { mutateAsync: editCustomFieldMutate } = useEditCustomField(); + + // Retrieve custom field. + const { + data: customField, + isLoading: isCustomFieldLoading, + } = useCustomField(customFieldId, { + enabled: !!customFieldId, + }); + + // Provider state. + const provider = { + isNewMode, + customFieldId, + customField, + createCustomFieldMutate, + editCustomFieldMutate, + }; + + return ( +
+ {isCustomFieldLoading ? ( + + ) : ( + + )} +
+ ); +} + +const useCustomFieldsFormContext = () => React.useContext(CustomFieldsFormContext); + +export { CustomFieldsFormProvider, useCustomFieldsFormContext }; diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsDataTable.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsDataTable.tsx new file mode 100644 index 000000000..308ec06b4 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsDataTable.tsx @@ -0,0 +1,80 @@ +// @ts-nocheck +import React from 'react'; +import intl from 'react-intl-universal'; +import styled from 'styled-components'; +import { Intent, Button, Position } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; + +import { DataTable, AppToaster, TableSkeletonRows } from '@/components'; + +import { useCustomFieldsTableColumns, ActionsMenu } from './components'; +import { withAlertActions } from '@/containers/Alert/withAlertActions'; +import { useCustomFieldsContext } from './CustomFieldsListProvider'; + +import { compose } from '@/utils'; + +/** + * Custom fields data table. + */ +function CustomFieldsDataTable({ + // #withAlertActions + openAlert, +}) { + // History context. + const history = useHistory(); + + // Retrieve custom fields table columns + const columns = useCustomFieldsTableColumns(); + + // Custom fields table context. + const { customFields, isCustomFieldsFetching, isCustomFieldsLoading } = + useCustomFieldsContext(); + + // Handles delete the given custom field. + const handleDeleteCustomField = ({ id }) => { + openAlert('custom-field-delete', { customFieldId: id }); + }; + + // Handles the edit of the given custom field. + const handleEditCustomField = ({ id }) => { + history.push(`/preferences/custom-fields/${id}`); + }; + + // Handles navigate to the new custom field page. + const handleNewCustomField = () => { + history.push('/preferences/custom-fields/new'); + }; + + return ( +
+
+
+ +
+ ); +} + +const CustomFieldsTable = styled(DataTable)` + .table .tr { + min-height: 42px; + } +`; + +export default compose(withAlertActions)(CustomFieldsDataTable); diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsList.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsList.tsx new file mode 100644 index 000000000..2430e3146 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsList.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck +import React from 'react'; + +import { CustomFieldsListProvider } from './CustomFieldsListProvider'; +import CustomFieldsDataTable from './CustomFieldsDataTable'; + +/** + * Custom fields list. + */ +function CustomFieldsList() { + return ( + + + + ); +} + +export default CustomFieldsList; diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsListProvider.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsListProvider.tsx new file mode 100644 index 000000000..00dafaac8 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsListProvider.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import { CLASSES } from '@/constants/classes'; +import { useCustomFields } from '@/hooks/query'; + +const CustomFieldsListContext = React.createContext(); + +/** + * Custom fields list provider. + */ +function CustomFieldsListProvider({ ...props }) { + // Fetch custom fields list. + const { + data: customFields, + isFetching: isCustomFieldsFetching, + isLoading: isCustomFieldsLoading, + } = useCustomFields(); + + // Provider state. + const provider = { + customFields, + isCustomFieldsFetching, + isCustomFieldsLoading, + }; + + return ( +
+ +
+ ); +} + +const useCustomFieldsContext = () => React.useContext(CustomFieldsListContext); + +export { CustomFieldsListProvider, useCustomFieldsContext }; diff --git a/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/components.tsx b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/components.tsx new file mode 100644 index 000000000..522df85ab --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CustomFields/CustomFieldsList/components.tsx @@ -0,0 +1,99 @@ +// @ts-nocheck +import React from 'react'; +import intl from 'react-intl-universal'; + +import { Intent, Menu, MenuItem, MenuDivider } from '@blueprintjs/core'; +import { safeCallback } from '@/utils'; +import { Icon } from '@/components'; + +/** + * Context menu of custom fields. + */ +export function ActionsMenu({ + payload: { onDeleteCustomField, onEditCustomField }, + row: { original }, +}) { + return ( + + } + text={intl.get('custom_fields.edit')} + onClick={safeCallback(onEditCustomField, original)} + /> + + } + text={intl.get('custom_fields.delete')} + onClick={safeCallback(onDeleteCustomField, original)} + intent={Intent.DANGER} + /> + + ); +} + +/** + * Retrieve Custom Fields table columns. + * @returns + */ +export function useCustomFieldsTableColumns() { + return React.useMemo( + () => [ + { + id: 'label', + Header: intl.get('custom_fields.column.label'), + accessor: 'label', + className: 'label', + width: '120', + textOverview: true, + }, + { + id: 'fieldName', + Header: intl.get('custom_fields.column.field_name'), + accessor: 'fieldName', + className: 'field-name', + width: '120', + textOverview: true, + }, + { + id: 'resourceName', + Header: intl.get('custom_fields.column.resource'), + accessor: 'resourceName', + className: 'resource-name', + width: '100', + textOverview: true, + }, + { + id: 'fieldType', + Header: intl.get('custom_fields.column.type'), + accessor: 'fieldType', + className: 'field-type', + width: '80', + textOverview: true, + }, + { + id: 'required', + Header: intl.get('custom_fields.column.required'), + accessor: 'required', + className: 'required', + width: '60', + Cell: ({ value }) => (value ? intl.get('yes') : intl.get('no')), + }, + { + id: 'active', + Header: intl.get('custom_fields.column.active'), + accessor: 'active', + className: 'active', + width: '60', + Cell: ({ value }) => (value ? intl.get('yes') : intl.get('no')), + }, + { + id: 'order', + Header: intl.get('custom_fields.column.order'), + accessor: 'order', + className: 'order', + width: '50', + }, + ], + [], + ); +} diff --git a/packages/webapp/src/hooks/query/customFields.tsx b/packages/webapp/src/hooks/query/customFields.tsx new file mode 100644 index 000000000..7380a4db8 --- /dev/null +++ b/packages/webapp/src/hooks/query/customFields.tsx @@ -0,0 +1,117 @@ +// @ts-nocheck +import { useMutation, useQueryClient } from 'react-query'; +import { useRequestQuery } from '../useQueryRequest'; +import useApiRequest from '../useRequest'; +import t from './types'; + +// Common invalidate queries. +const commonInvalidateQueries = (queryClient) => { + queryClient.invalidateQueries(t.CUSTOM_FIELDS); + queryClient.invalidateQueries(t.CUSTOM_FIELD); +}; + +/** + * Retrieve the custom fields. + */ +export function useCustomFields(props, query) { + return useRequestQuery( + [t.CUSTOM_FIELDS, query], + { method: 'get', url: 'custom-fields', params: query }, + { + select: (res) => res.data?.data || [], + defaultData: [], + ...props, + }, + ); +} + +/** + * Retrieve the custom field. + */ +export function useCustomField(fieldId, props, requestProps) { + return useRequestQuery( + [t.CUSTOM_FIELD, fieldId], + { method: 'get', url: `custom-fields/${fieldId}`, ...requestProps }, + { + select: (res) => res.data, + defaultData: {}, + ...props, + }, + ); +} + +/** + * Create a new custom field. + */ +export function useCreateCustomField(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation((values) => apiRequest.post('custom-fields', values), { + onSuccess: () => { + commonInvalidateQueries(queryClient); + }, + ...props, + }); +} + +/** + * Edit the given custom field. + */ +export function useEditCustomField(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation(([id, values]) => apiRequest.put(`custom-fields/${id}`, values), { + onSuccess: () => { + commonInvalidateQueries(queryClient); + }, + ...props, + }); +} + +/** + * Delete the given custom field. + */ +export function useDeleteCustomField(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation((id) => apiRequest.delete(`custom-fields/${id}`), { + onSuccess: (res, id) => { + queryClient.invalidateQueries(t.CUSTOM_FIELD, id); + commonInvalidateQueries(queryClient); + }, + ...props, + }); +} + +/** + * Reorder custom fields. + */ +export function useReorderCustomFields(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation((values) => apiRequest.post('custom-fields/reorder', values), { + onSuccess: () => { + commonInvalidateQueries(queryClient); + }, + ...props, + }); +} + +/** + * Update custom field status. + */ +export function useUpdateCustomFieldStatus(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation(([id, values]) => apiRequest.put(`custom-fields/${id}/status`, values), { + onSuccess: () => { + commonInvalidateQueries(queryClient); + }, + ...props, + }); +} diff --git a/packages/webapp/src/hooks/query/index.tsx b/packages/webapp/src/hooks/query/index.tsx index 82fb35b5a..a4b595542 100644 --- a/packages/webapp/src/hooks/query/index.tsx +++ b/packages/webapp/src/hooks/query/index.tsx @@ -39,3 +39,4 @@ export * from './warehousesTransfers'; export * from './plaid'; export * from './FinancialReports'; export * from './apiKeys'; +export * from './customFields'; diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index 277c67942..4917058e2 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -245,6 +245,11 @@ export const API_KEYS = { API_KEYS: 'API_KEYS', }; +const CUSTOM_FIELDS = { + CUSTOM_FIELDS: 'CUSTOM_FIELDS', + CUSTOM_FIELD: 'CUSTOM_FIELD', +}; + export default { ...Authentication, ...ACCOUNTS, @@ -281,4 +286,5 @@ export default { ...TAX_RATES, ...EXCHANGE_RATE, ...API_KEYS, + ...CUSTOM_FIELDS, }; diff --git a/packages/webapp/src/routes/preferences.tsx b/packages/webapp/src/routes/preferences.tsx index 537cdc838..8aa297e0f 100644 --- a/packages/webapp/src/routes/preferences.tsx +++ b/packages/webapp/src/routes/preferences.tsx @@ -126,6 +126,36 @@ export const getPreferenceRoutes = () => [ component: lazy(() => import('@/containers/Preferences/ApiKeys/ApiKeys')), exact: true, }, + { + path: `${BASE_URL}/custom-fields`, + component: lazy( + () => + import( + '../containers/Preferences/CustomFields/CustomFieldsList/CustomFieldsList' + ), + ), + exact: true, + }, + { + path: `${BASE_URL}/custom-fields/new`, + component: lazy( + () => + import( + '../containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormPage' + ), + ), + exact: true, + }, + { + path: `${BASE_URL}/custom-fields/:id`, + component: lazy( + () => + import( + '../containers/Preferences/CustomFields/CustomFieldsForm/CustomFieldsFormPage' + ), + ), + exact: true, + }, { path: `${BASE_URL}/`, component: lazy(() => import('../containers/Preferences/DefaultRoute')), diff --git a/shared/sdk-ts/openapi.json b/shared/sdk-ts/openapi.json index f71d21038..2bf2be0e9 100644 --- a/shared/sdk-ts/openapi.json +++ b/shared/sdk-ts/openapi.json @@ -5278,6 +5278,385 @@ ] } }, + "/api/custom-fields": { + "post": { + "operationId": "CustomFieldsController_createCustomField", + "summary": "Create a new custom field.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token.", + "required": true, + "schema": { + "type": "string", + "example": "Bearer bc_1234567890abcdef" + } + }, + { + "name": "organization-id", + "in": "header", + "description": "Required if Authorization is a JWT token. The organization ID to operate within.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomFieldDto" + } + } + } + }, + "responses": { + "201": { + "description": "The custom field has been successfully created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldResponseDto" + } + } + } + } + }, + "tags": [ + "Custom Fields" + ] + }, + "get": { + "operationId": "CustomFieldsController_getCustomFields", + "summary": "Retrieves the custom fields.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token.", + "required": true, + "schema": { + "type": "string", + "example": "Bearer bc_1234567890abcdef" + } + }, + { + "name": "organization-id", + "in": "header", + "description": "Required if Authorization is a JWT token. The organization ID to operate within.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "resource", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The custom fields have been successfully retrieved.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomFieldResponseDto" + } + } + } + } + } + } + } + }, + "tags": [ + "Custom Fields" + ] + } + }, + "/api/custom-fields/{id}": { + "put": { + "operationId": "CustomFieldsController_editCustomField", + "summary": "Edit the given custom field.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token.", + "required": true, + "schema": { + "type": "string", + "example": "Bearer bc_1234567890abcdef" + } + }, + { + "name": "organization-id", + "in": "header", + "description": "Required if Authorization is a JWT token. The organization ID to operate within.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditCustomFieldDto" + } + } + } + }, + "responses": { + "200": { + "description": "The custom field has been successfully updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldResponseDto" + } + } + } + } + }, + "tags": [ + "Custom Fields" + ] + }, + "delete": { + "operationId": "CustomFieldsController_deleteCustomField", + "summary": "Delete the given custom field.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token.", + "required": true, + "schema": { + "type": "string", + "example": "Bearer bc_1234567890abcdef" + } + }, + { + "name": "organization-id", + "in": "header", + "description": "Required if Authorization is a JWT token. The organization ID to operate within.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "The custom field has been successfully deleted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldResponseDto" + } + } + } + } + }, + "tags": [ + "Custom Fields" + ] + }, + "get": { + "operationId": "CustomFieldsController_getCustomField", + "summary": "Retrieves the custom field details.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token.", + "required": true, + "schema": { + "type": "string", + "example": "Bearer bc_1234567890abcdef" + } + }, + { + "name": "organization-id", + "in": "header", + "description": "Required if Authorization is a JWT token. The organization ID to operate within.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "The custom field details have been successfully retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldResponseDto" + } + } + } + } + }, + "tags": [ + "Custom Fields" + ] + } + }, + "/api/custom-fields/reorder": { + "post": { + "operationId": "CustomFieldsController_reorderCustomFields", + "summary": "Reorder custom fields.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token.", + "required": true, + "schema": { + "type": "string", + "example": "Bearer bc_1234567890abcdef" + } + }, + { + "name": "organization-id", + "in": "header", + "description": "Required if Authorization is a JWT token. The organization ID to operate within.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderCustomFieldsDto" + } + } + } + }, + "responses": { + "200": { + "description": "The custom fields have been successfully reordered.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomFieldResponseDto" + } + } + } + } + } + }, + "tags": [ + "Custom Fields" + ] + } + }, + "/api/custom-fields/{id}/status": { + "put": { + "operationId": "CustomFieldsController_updateFieldStatus", + "summary": "Update custom field status.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token.", + "required": true, + "schema": { + "type": "string", + "example": "Bearer bc_1234567890abcdef" + } + }, + { + "name": "organization-id", + "in": "header", + "description": "Required if Authorization is a JWT token. The organization ID to operate within.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFieldStatusDto" + } + } + } + }, + "responses": { + "200": { + "description": "The custom field status has been successfully updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldResponseDto" + } + } + } + } + }, + "tags": [ + "Custom Fields" + ] + } + }, "/api/import/file": { "post": { "operationId": "ImportController_fileUpload", @@ -24711,6 +25090,13 @@ "type": "string", "description": "The date when the item was last updated", "example": "2024-03-20T10:00:00Z" + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -24837,6 +25223,13 @@ "items": { "type": "number" } + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -24980,6 +25373,13 @@ "items": { "type": "number" } + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -26398,6 +26798,13 @@ "type": "string", "description": "The date when the invoice was last updated", "example": "2023-01-02T00:00:00Z" + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -26545,6 +26952,13 @@ "items": { "type": "string" } + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -26681,6 +27095,13 @@ "items": { "type": "string" } + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -27127,6 +27548,13 @@ "items": { "$ref": "#/components/schemas/AttachmentLinkDto" } + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -27222,6 +27650,13 @@ "items": { "type": "string" } + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -27311,6 +27746,13 @@ "items": { "type": "string" } + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -27327,6 +27769,264 @@ "attachments" ] }, + "CustomFieldResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Custom field ID.", + "example": 1 + }, + "resourceName": { + "type": "string", + "description": "Resource name.", + "example": "SaleInvoice" + }, + "fieldName": { + "type": "string", + "description": "Field name.", + "example": "cf_priority" + }, + "label": { + "type": "string", + "description": "Display label.", + "example": "Priority" + }, + "fieldType": { + "type": "string", + "description": "Field type.", + "example": "dropdown" + }, + "options": { + "type": "object", + "description": "Field options.", + "example": { + "choices": [ + "Low", + "Medium", + "High" + ] + } + }, + "required": { + "type": "boolean", + "description": "Whether the field is required.", + "example": false + }, + "order": { + "type": "number", + "description": "Display order.", + "example": 1 + }, + "active": { + "type": "boolean", + "description": "Whether the field is active.", + "example": true + }, + "defaultValue": { + "type": "string", + "description": "Default value.", + "example": "Medium" + }, + "createdAt": { + "format": "date-time", + "type": "string", + "description": "Created at timestamp." + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "description": "Updated at timestamp." + } + }, + "required": [ + "id", + "resourceName", + "fieldName", + "label", + "fieldType", + "options", + "required", + "order", + "active", + "defaultValue", + "createdAt", + "updatedAt" + ] + }, + "CreateCustomFieldDto": { + "type": "object", + "properties": { + "resourceName": { + "type": "string", + "description": "Resource name the custom field belongs to.", + "example": "SaleInvoice" + }, + "fieldName": { + "type": "string", + "description": "Internal field name.", + "example": "cf_priority" + }, + "label": { + "type": "string", + "description": "Display label.", + "example": "Priority" + }, + "fieldType": { + "type": "string", + "description": "Field type.", + "example": "dropdown" + }, + "options": { + "type": "object", + "description": "Field options (e.g., dropdown choices).", + "example": { + "choices": [ + "Low", + "Medium", + "High" + ] + } + }, + "required": { + "type": "boolean", + "description": "Whether the field is required.", + "example": false + }, + "order": { + "type": "number", + "description": "Display order.", + "example": 1 + }, + "active": { + "type": "boolean", + "description": "Whether the field is active.", + "example": true + }, + "defaultValue": { + "type": "string", + "description": "Default value.", + "example": "Medium" + } + }, + "required": [ + "resourceName", + "fieldName", + "label", + "fieldType", + "options", + "required", + "order", + "active", + "defaultValue" + ] + }, + "EditCustomFieldDto": { + "type": "object", + "properties": { + "resourceName": { + "type": "string", + "description": "Resource name the custom field belongs to.", + "example": "SaleInvoice" + }, + "fieldName": { + "type": "string", + "description": "Internal field name.", + "example": "cf_priority" + }, + "label": { + "type": "string", + "description": "Display label.", + "example": "Priority" + }, + "fieldType": { + "type": "string", + "description": "Field type.", + "example": "dropdown" + }, + "options": { + "type": "object", + "description": "Field options (e.g., dropdown choices).", + "example": { + "choices": [ + "Low", + "Medium", + "High" + ] + } + }, + "required": { + "type": "boolean", + "description": "Whether the field is required.", + "example": false + }, + "order": { + "type": "number", + "description": "Display order.", + "example": 1 + }, + "active": { + "type": "boolean", + "description": "Whether the field is active.", + "example": true + }, + "defaultValue": { + "type": "string", + "description": "Default value.", + "example": "Medium" + } + }, + "required": [ + "resourceName", + "fieldName", + "label", + "fieldType", + "options", + "required", + "order", + "active", + "defaultValue" + ] + }, + "ReorderCustomFieldsDto": { + "type": "object", + "properties": { + "resourceName": { + "type": "string", + "description": "Resource name.", + "example": "SaleInvoice" + }, + "fieldIds": { + "description": "Ordered array of custom field IDs.", + "example": [ + 3, + 1, + 2 + ], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "resourceName", + "fieldIds" + ] + }, + "UpdateFieldStatusDto": { + "type": "object", + "properties": { + "active": { + "type": "boolean", + "description": "New active status.", + "example": true + } + }, + "required": [ + "active" + ] + }, "ModelMetaDefaultSortDto": { "type": "object", "properties": { @@ -28895,6 +29595,13 @@ "closingBalance": { "type": "number", "example": 1500 + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -29066,6 +29773,13 @@ "type": "string", "description": "Customer code", "example": "CUST-001" + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -29192,6 +29906,13 @@ "code": { "type": "string", "description": "Customer code" + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -29823,6 +30544,13 @@ "items": { "$ref": "#/components/schemas/AttachmentLinkDto" } + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -29960,6 +30688,13 @@ "type": "number", "description": "The adjustment of the estimate", "example": 1 + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -30083,6 +30818,13 @@ "type": "number", "description": "The adjustment of the estimate", "example": 1 + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -30343,6 +31085,13 @@ "type": "string", "description": "The date when the receipt was last updated", "example": "2024-01-02T00:00:00Z" + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -30475,6 +31224,13 @@ "type": "number", "description": "The adjustment of the sale receipt", "example": 1 + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -30599,6 +31355,13 @@ "type": "number", "description": "The adjustment of the sale receipt", "example": 1 + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -31728,6 +32491,13 @@ "type": "string", "description": "Formatted total in local currency", "example": "$1,000.00" + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -31843,6 +32613,13 @@ "percentage", "amount" ] + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ @@ -31962,6 +32739,13 @@ "percentage", "amount" ] + }, + "customFields": { + "type": "object", + "description": "Custom fields values", + "example": { + "cf_priority": "High" + } } }, "required": [ diff --git a/shared/sdk-ts/src/custom-fields.ts b/shared/sdk-ts/src/custom-fields.ts new file mode 100644 index 000000000..f7da1ce54 --- /dev/null +++ b/shared/sdk-ts/src/custom-fields.ts @@ -0,0 +1,78 @@ +import type { ApiFetcher } from './fetch-utils'; +import { paths } from './schema'; +import { OpForPath, OpQueryParams, OpRequestBody, OpResponseBody } from './utils'; + +export const CUSTOM_FIELDS_ROUTES = { + LIST: '/api/custom-fields', + BY_ID: '/api/custom-fields/{id}', + REORDER: '/api/custom-fields/reorder', + STATUS: '/api/custom-fields/{id}/status', +} as const satisfies Record; + +export type CustomFieldsList = OpResponseBody>; +export type CustomField = OpResponseBody>; +export type CreateCustomFieldBody = OpRequestBody>; +export type EditCustomFieldBody = OpRequestBody>; +export type ReorderCustomFieldsBody = OpRequestBody>; +export type UpdateCustomFieldStatusBody = OpRequestBody>; +export type GetCustomFieldsQuery = OpQueryParams>; + +export async function fetchCustomFields( + fetcher: ApiFetcher, + query?: GetCustomFieldsQuery +): Promise { + const get = fetcher.path(CUSTOM_FIELDS_ROUTES.LIST).method('get').create(); + const { data } = await get(query ?? { resource: '' }); + return data; +} + +export async function fetchCustomField( + fetcher: ApiFetcher, + id: number +): Promise { + const get = fetcher.path(CUSTOM_FIELDS_ROUTES.BY_ID).method('get').create(); + const { data } = await get({ id }); + return data; +} + +export async function createCustomField( + fetcher: ApiFetcher, + values: CreateCustomFieldBody +): Promise { + const post = fetcher.path(CUSTOM_FIELDS_ROUTES.LIST).method('post').create(); + await post(values); +} + +export async function editCustomField( + fetcher: ApiFetcher, + id: number, + values: EditCustomFieldBody +): Promise { + const put = fetcher.path(CUSTOM_FIELDS_ROUTES.BY_ID).method('put').create(); + await put({ id, ...values }); +} + +export async function deleteCustomField( + fetcher: ApiFetcher, + id: number +): Promise { + const del = fetcher.path(CUSTOM_FIELDS_ROUTES.BY_ID).method('delete').create(); + await del({ id }); +} + +export async function reorderCustomFields( + fetcher: ApiFetcher, + values: ReorderCustomFieldsBody +): Promise { + const post = fetcher.path(CUSTOM_FIELDS_ROUTES.REORDER).method('post').create(); + await post(values); +} + +export async function updateCustomFieldStatus( + fetcher: ApiFetcher, + id: number, + values: UpdateCustomFieldStatusBody +): Promise { + const put = fetcher.path(CUSTOM_FIELDS_ROUTES.STATUS).method('put').create(); + await put({ id, ...values }); +} diff --git a/shared/sdk-ts/src/index.ts b/shared/sdk-ts/src/index.ts index c7e7a006d..021da3ca5 100644 --- a/shared/sdk-ts/src/index.ts +++ b/shared/sdk-ts/src/index.ts @@ -19,6 +19,7 @@ export * from './expenses'; export * from './import'; export * from './manual-journals'; export * from './roles'; +export * from './custom-fields'; export * from './users'; export * from './dashboard'; export * from './settings'; diff --git a/shared/sdk-ts/src/schema.ts b/shared/sdk-ts/src/schema.ts index 95b85c03d..f146f4906 100644 --- a/shared/sdk-ts/src/schema.ts +++ b/shared/sdk-ts/src/schema.ts @@ -1353,6 +1353,77 @@ export interface paths { patch?: never; trace?: never; }; + "/api/custom-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Retrieves the custom fields. */ + get: operations["CustomFieldsController_getCustomFields"]; + put?: never; + /** Create a new custom field. */ + post: operations["CustomFieldsController_createCustomField"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/custom-fields/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Retrieves the custom field details. */ + get: operations["CustomFieldsController_getCustomField"]; + /** Edit the given custom field. */ + put: operations["CustomFieldsController_editCustomField"]; + post?: never; + /** Delete the given custom field. */ + delete: operations["CustomFieldsController_deleteCustomField"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/custom-fields/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reorder custom fields. */ + post: operations["CustomFieldsController_reorderCustomFields"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/custom-fields/{id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update custom field status. */ + put: operations["CustomFieldsController_updateFieldStatus"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/import/file": { parameters: { query?: never; @@ -5299,6 +5370,13 @@ export interface components { * @example 2024-03-20T10:00:00Z */ updatedAt: string; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; EditItemDto: { /** @@ -5397,6 +5475,13 @@ export interface components { * ] */ mediaIds?: number[]; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; BulkDeleteItemsDto: { /** @@ -5511,6 +5596,13 @@ export interface components { * ] */ mediaIds?: number[]; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; InventoryAdjustmentResponseDto: { /** @@ -6525,6 +6617,13 @@ export interface components { * @example 2023-01-02T00:00:00Z */ updatedAt?: string; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; CreateSaleInvoiceDto: { /** @@ -6633,6 +6732,13 @@ export interface components { * ] */ attachments: string[]; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; EditSaleInvoiceDto: { /** @@ -6741,6 +6847,13 @@ export interface components { * ] */ attachments: string[]; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; UploadAttachmentDto: { /** Format: binary */ @@ -7086,6 +7199,13 @@ export interface components { * ] */ attachments?: components["schemas"]["AttachmentLinkDto"][]; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; CreatePaymentReceivedDto: { /** @@ -7153,6 +7273,13 @@ export interface components { * ] */ attachments: string[]; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; EditPaymentReceivedDto: { /** @@ -7220,6 +7347,210 @@ export interface components { * ] */ attachments: string[]; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; + }; + CustomFieldResponseDto: { + /** + * @description Custom field ID. + * @example 1 + */ + id: number; + /** + * @description Resource name. + * @example SaleInvoice + */ + resourceName: string; + /** + * @description Field name. + * @example cf_priority + */ + fieldName: string; + /** + * @description Display label. + * @example Priority + */ + label: string; + /** + * @description Field type. + * @example dropdown + */ + fieldType: string; + /** + * @description Field options. + * @example { + * "choices": [ + * "Low", + * "Medium", + * "High" + * ] + * } + */ + options: Record; + /** + * @description Whether the field is required. + * @example false + */ + required: boolean; + /** + * @description Display order. + * @example 1 + */ + order: number; + /** + * @description Whether the field is active. + * @example true + */ + active: boolean; + /** + * @description Default value. + * @example Medium + */ + defaultValue: string; + /** + * Format: date-time + * @description Created at timestamp. + */ + createdAt: string; + /** + * Format: date-time + * @description Updated at timestamp. + */ + updatedAt: string; + }; + CreateCustomFieldDto: { + /** + * @description Resource name the custom field belongs to. + * @example SaleInvoice + */ + resourceName: string; + /** + * @description Internal field name. + * @example cf_priority + */ + fieldName: string; + /** + * @description Display label. + * @example Priority + */ + label: string; + /** + * @description Field type. + * @example dropdown + */ + fieldType: string; + /** + * @description Field options (e.g., dropdown choices). + * @example { + * "choices": [ + * "Low", + * "Medium", + * "High" + * ] + * } + */ + options: Record; + /** + * @description Whether the field is required. + * @example false + */ + required: boolean; + /** + * @description Display order. + * @example 1 + */ + order: number; + /** + * @description Whether the field is active. + * @example true + */ + active: boolean; + /** + * @description Default value. + * @example Medium + */ + defaultValue: string; + }; + EditCustomFieldDto: { + /** + * @description Resource name the custom field belongs to. + * @example SaleInvoice + */ + resourceName: string; + /** + * @description Internal field name. + * @example cf_priority + */ + fieldName: string; + /** + * @description Display label. + * @example Priority + */ + label: string; + /** + * @description Field type. + * @example dropdown + */ + fieldType: string; + /** + * @description Field options (e.g., dropdown choices). + * @example { + * "choices": [ + * "Low", + * "Medium", + * "High" + * ] + * } + */ + options: Record; + /** + * @description Whether the field is required. + * @example false + */ + required: boolean; + /** + * @description Display order. + * @example 1 + */ + order: number; + /** + * @description Whether the field is active. + * @example true + */ + active: boolean; + /** + * @description Default value. + * @example Medium + */ + defaultValue: string; + }; + ReorderCustomFieldsDto: { + /** + * @description Resource name. + * @example SaleInvoice + */ + resourceName: string; + /** + * @description Ordered array of custom field IDs. + * @example [ + * 3, + * 1, + * 2 + * ] + */ + fieldIds: string[]; + }; + UpdateFieldStatusDto: { + /** + * @description New active status. + * @example true + */ + active: boolean; }; ModelMetaDefaultSortDto: { /** @@ -8225,6 +8556,13 @@ export interface components { localOpeningBalance: number; /** @example 1500 */ closingBalance: number; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; CreateCustomerDto: { /** @description Billing address line 1 */ @@ -8343,6 +8681,13 @@ export interface components { * @example CUST-001 */ code?: string; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; EditCustomerDto: { /** @description Billing address line 1 */ @@ -8403,6 +8748,13 @@ export interface components { active?: boolean; /** @description Customer code */ code?: string; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; CustomerOpeningBalanceEditDto: { /** @@ -8842,6 +9194,13 @@ export interface components { entries: components["schemas"]["ItemEntryDto"][]; /** @description Attachments of the sale estimate */ attachments: components["schemas"]["AttachmentLinkDto"][]; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; CreateSaleEstimateDto: { /** @@ -8938,6 +9297,13 @@ export interface components { * @example 1 */ adjustment: number; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; EditSaleEstimateDto: { /** @@ -9034,6 +9400,13 @@ export interface components { * @example 1 */ adjustment: number; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; SaleReceiptStateResponseDto: { /** @@ -9229,6 +9602,13 @@ export interface components { * @example 2024-01-02T00:00:00Z */ updatedAt?: string; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; CreateSaleReceiptDto: { /** @@ -9325,6 +9705,13 @@ export interface components { * @example 1 */ adjustment: number; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; EditSaleReceiptDto: { /** @@ -9421,6 +9808,13 @@ export interface components { * @example 1 */ adjustment: number; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; BillResponseDto: { /** @@ -10277,6 +10671,13 @@ export interface components { * @example $1,000.00 */ totalLocalFormatted: string; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; CreateCreditNoteDto: { /** @@ -10358,6 +10759,13 @@ export interface components { * @enum {string} */ discountType: "percentage" | "amount"; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; CreditNoteStateResponseDto: { /** @@ -10446,6 +10854,13 @@ export interface components { * @enum {string} */ discountType: "percentage" | "amount"; + /** + * @description Custom fields values + * @example { + * "cf_priority": "High" + * } + */ + customFields?: Record; }; RefundCreditAccountDto: { /** @example 10 */ @@ -17345,6 +17760,209 @@ export interface operations { }; }; }; + CustomFieldsController_getCustomFields: { + parameters: { + query: { + resource: string; + }; + header: { + /** @description Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token. */ + Authorization: string; + /** @description Required if Authorization is a JWT token. The organization ID to operate within. */ + "organization-id": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The custom fields have been successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["CustomFieldResponseDto"][]; + }; + }; + }; + }; + }; + CustomFieldsController_createCustomField: { + parameters: { + query?: never; + header: { + /** @description Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token. */ + Authorization: string; + /** @description Required if Authorization is a JWT token. The organization ID to operate within. */ + "organization-id": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateCustomFieldDto"]; + }; + }; + responses: { + /** @description The custom field has been successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomFieldResponseDto"]; + }; + }; + }; + }; + CustomFieldsController_getCustomField: { + parameters: { + query?: never; + header: { + /** @description Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token. */ + Authorization: string; + /** @description Required if Authorization is a JWT token. The organization ID to operate within. */ + "organization-id": string; + }; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The custom field details have been successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomFieldResponseDto"]; + }; + }; + }; + }; + CustomFieldsController_editCustomField: { + parameters: { + query?: never; + header: { + /** @description Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token. */ + Authorization: string; + /** @description Required if Authorization is a JWT token. The organization ID to operate within. */ + "organization-id": string; + }; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EditCustomFieldDto"]; + }; + }; + responses: { + /** @description The custom field has been successfully updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomFieldResponseDto"]; + }; + }; + }; + }; + CustomFieldsController_deleteCustomField: { + parameters: { + query?: never; + header: { + /** @description Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token. */ + Authorization: string; + /** @description Required if Authorization is a JWT token. The organization ID to operate within. */ + "organization-id": string; + }; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The custom field has been successfully deleted. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomFieldResponseDto"]; + }; + }; + }; + }; + CustomFieldsController_reorderCustomFields: { + parameters: { + query?: never; + header: { + /** @description Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token. */ + Authorization: string; + /** @description Required if Authorization is a JWT token. The organization ID to operate within. */ + "organization-id": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReorderCustomFieldsDto"]; + }; + }; + responses: { + /** @description The custom fields have been successfully reordered. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomFieldResponseDto"][]; + }; + }; + }; + }; + CustomFieldsController_updateFieldStatus: { + parameters: { + query?: never; + header: { + /** @description Value must be 'Bearer ' where is an API key prefixed with 'bc_' or a JWT token. */ + Authorization: string; + /** @description Required if Authorization is a JWT token. The organization ID to operate within. */ + "organization-id": string; + }; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateFieldStatusDto"]; + }; + }; + responses: { + /** @description The custom field status has been successfully updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomFieldResponseDto"]; + }; + }; + }; + }; ImportController_fileUpload: { parameters: { query?: never;