1
0

feat(custom-fields): add custom fields support

This commit is contained in:
Ahmed Bouhuolia
2026-04-18 01:46:57 +02:00
parent 52c97f1401
commit 2ec3ca8d33
105 changed files with 4373 additions and 20 deletions
@@ -705,6 +705,20 @@ export const events = {
onDisconnected: 'onBankAccountDisconnected', onDisconnected: 'onBankAccountDisconnected',
}, },
/**
* Custom fields service.
*/
customField: {
onCreating: 'onCustomFieldCreating',
onCreated: 'onCustomFieldCreated',
onEditing: 'onCustomFieldEditing',
onEdited: 'onCustomFieldEdited',
onDeleting: 'onCustomFieldDeleting',
onDeleted: 'onCustomFieldDeleted',
},
// Import files. // Import files.
import: { import: {
onImportCommitted: 'onImportFileCommitted', onImportCommitted: 'onImportFileCommitted',
@@ -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');
};
@@ -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');
};
+2
View File
@@ -132,6 +132,7 @@ export interface IItemEventCreatedPayload {
// tenantId: number; // tenantId: number;
item: Item; item: Item;
itemId: number; itemId: number;
itemDTO: any;
trx: Knex.Transaction; trx: Knex.Transaction;
} }
@@ -139,6 +140,7 @@ export interface IItemEventEditedPayload {
item: Item; item: Item;
oldItem: Item; oldItem: Item;
itemId: number; itemId: number;
itemDTO: any;
trx: Knex.Transaction; trx: Knex.Transaction;
} }
@@ -98,6 +98,7 @@ import { MiscellaneousModule } from '../Miscellaneous/Miscellaneous.module';
import { UsersModule } from '../UsersModule/Users.module'; import { UsersModule } from '../UsersModule/Users.module';
import { ContactsModule } from '../Contacts/Contacts.module'; import { ContactsModule } from '../Contacts/Contacts.module';
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module'; import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module'; import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module'; import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module'; import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
@@ -256,6 +257,7 @@ import { AppThrottleModule } from './AppThrottle.module';
MiscellaneousModule, MiscellaneousModule,
UsersModule, UsersModule,
ContactsModule, ContactsModule,
CustomFieldsModule,
SocketModule, SocketModule,
ExchangeRatesModule, ExchangeRatesModule,
], ],
@@ -36,6 +36,7 @@ import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.
import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module'; import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module';
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service'; import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service'; import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
@Module({ @Module({
imports: [ imports: [
@@ -50,6 +51,7 @@ import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCredit
AccountsModule, AccountsModule,
DynamicListModule, DynamicListModule,
InventoryCostModule, InventoryCostModule,
CustomFieldsModule,
forwardRef(() => CreditNoteRefundsModule), forwardRef(() => CreditNoteRefundsModule),
forwardRef(() => CreditNotesApplyInvoiceModule) forwardRef(() => CreditNotesApplyInvoiceModule)
], ],
@@ -13,6 +13,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events'; import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreateCreditNoteDto } from '../dtos/CreditNote.dto'; import { CreateCreditNoteDto } from '../dtos/CreditNote.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable() @Injectable()
export class CreateCreditNoteService { export class CreateCreditNoteService {
@@ -29,6 +30,7 @@ export class CreateCreditNoteService {
private readonly itemsEntriesService: ItemsEntriesService, private readonly itemsEntriesService: ItemsEntriesService,
private readonly eventPublisher: EventEmitter2, private readonly eventPublisher: EventEmitter2,
private readonly commandCreditNoteDTOTransform: CommandCreditNoteDTOTransform, private readonly commandCreditNoteDTOTransform: CommandCreditNoteDTOTransform,
private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService,
@Inject(CreditNote.name) @Inject(CreditNote.name)
private readonly creditNoteModel: TenantModelProxy<typeof CreditNote>, private readonly creditNoteModel: TenantModelProxy<typeof CreditNote>,
@@ -84,6 +86,17 @@ export class CreateCreditNoteService {
.upsertGraph({ .upsertGraph({
...creditNoteModel, ...creditNoteModel,
}); });
// Save custom field values.
if (creditNoteDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'CreditNote',
creditNote.id,
creditNoteDTO.customFields,
trx,
);
}
// Triggers `onCreditNoteCreated` event. // Triggers `onCreditNoteCreated` event.
await this.eventPublisher.emitAsync(events.creditNote.onCreated, { await this.eventPublisher.emitAsync(events.creditNote.onCreated, {
creditNoteDTO, creditNoteDTO,
@@ -13,6 +13,7 @@ import { events } from '@/common/events/events';
import { CommandCreditNoteDTOTransform } from './CommandCreditNoteDTOTransform.service'; import { CommandCreditNoteDTOTransform } from './CommandCreditNoteDTOTransform.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditCreditNoteDto } from '../dtos/CreditNote.dto'; import { EditCreditNoteDto } from '../dtos/CreditNote.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable() @Injectable()
export class EditCreditNoteService { export class EditCreditNoteService {
@@ -35,6 +36,7 @@ export class EditCreditNoteService {
private itemsEntriesService: ItemsEntriesService, private itemsEntriesService: ItemsEntriesService,
private eventPublisher: EventEmitter2, private eventPublisher: EventEmitter2,
private uow: UnitOfWork, private uow: UnitOfWork,
private saveCustomFieldValuesService: SaveCustomFieldValuesService,
) {} ) {}
/** /**
@@ -93,6 +95,17 @@ export class EditCreditNoteService {
id: creditNoteId, id: creditNoteId,
...creditNoteModel, ...creditNoteModel,
}); });
// Save custom field values.
if (creditNoteEditDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'CreditNote',
creditNoteId,
creditNoteEditDTO.customFields,
trx,
);
}
// Triggers `onCreditNoteEdited` event. // Triggers `onCreditNoteEdited` event.
await this.eventPublisher.emitAsync(events.creditNote.onEdited, { await this.eventPublisher.emitAsync(events.creditNote.onEdited, {
trx, trx,
@@ -11,6 +11,7 @@ import {
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsObject,
IsOptional, IsOptional,
IsPositive, IsPositive,
IsString, IsString,
@@ -126,6 +127,15 @@ export class CommandCreditNoteDto {
@ToNumber() @ToNumber()
@IsNumber() @IsNumber()
adjustment?: number; adjustment?: number;
@IsOptional()
@IsObject()
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
export class CreateCreditNoteDto extends CommandCreditNoteDto {} export class CreateCreditNoteDto extends CommandCreditNoteDto {}
@@ -263,4 +263,11 @@ export class CreditNoteResponseDto {
example: '$1,000.00', example: '$1,000.00',
}) })
totalLocalFormatted: string; totalLocalFormatted: string;
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
@@ -30,9 +30,18 @@ export class CreditNoteTransformer extends Transformer {
'entries', 'entries',
'attachments', 'attachments',
'customFields',
]; ];
}; };
/**
* Retrieve custom fields from options.
* @returns {Record<string, any>}
*/
public customFields(): Record<string, any> {
return this.options?.customFields;
}
/** /**
* Retrieve formatted credit note date. * Retrieve formatted credit note date.
* @param {ICreditNote} credit * @param {ICreditNote} credit
@@ -5,11 +5,13 @@ import { Inject, Injectable } from '@nestjs/common';
import { CreditNote } from '../models/CreditNote'; import { CreditNote } from '../models/CreditNote';
import { ServiceError } from '@/modules/Items/ServiceError'; import { ServiceError } from '@/modules/Items/ServiceError';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service';
@Injectable() @Injectable()
export class GetCreditNoteService { export class GetCreditNoteService {
constructor( constructor(
private readonly transformer: TransformerInjectable, private readonly transformer: TransformerInjectable,
private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService,
@Inject(CreditNote.name) @Inject(CreditNote.name)
private readonly creditNoteModel: TenantModelProxy<typeof CreditNote>, private readonly creditNoteModel: TenantModelProxy<typeof CreditNote>,
@@ -32,7 +34,19 @@ export class GetCreditNoteService {
if (!creditNote) { if (!creditNote) {
throw new ServiceError(ERRORS.CREDIT_NOTE_NOT_FOUND); 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. // 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;
} }
} }
@@ -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);
}
}
@@ -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);
}
}
@@ -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 {}
@@ -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<typeof CustomField>,
) {}
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;
});
}
}
@@ -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<typeof CustomField>,
@Inject(CustomFieldValue.name)
private readonly customFieldValueModel: TenantModelProxy<typeof CustomFieldValue>,
) {}
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;
});
}
}
@@ -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<typeof CustomField>,
) {}
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;
});
}
}
@@ -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<typeof CustomField>,
) {}
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');
});
}
}
@@ -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<typeof CustomField>,
) {}
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,
});
});
}
}
@@ -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<string, any>;
@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 {}
@@ -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<string, any>;
@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;
}
@@ -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[];
}
@@ -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;
}
@@ -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<string, any>;
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 {};
}
}
@@ -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 {};
}
}
@@ -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<typeof CustomField>,
) {}
async getCustomField(fieldId: number) {
const customField = await this.customFieldModel().query().findById(fieldId);
if (!customField) {
throw new NotFoundException('Custom field not found.');
}
return customField;
}
}
@@ -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<typeof CustomField>,
) {}
async getCustomFields(resourceName?: string) {
let query = this.customFieldModel()
.query()
.orderBy('order', 'ASC');
if (resourceName) {
query = query.where('resource_name', resourceName);
}
return query;
}
}
@@ -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<typeof CustomField>,
@Inject(CustomFieldValue.name)
private readonly customFieldValueModel: TenantModelProxy<typeof CustomFieldValue>,
) {}
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<string, any> = {};
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<number, Record<string, any>> = {};
for (const resourceId of resourceIds) {
const resourceValues: Record<string, any> = {};
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;
}
}
@@ -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<typeof CustomField>,
@Inject(CustomFieldValue.name)
private readonly customFieldValueModel: TenantModelProxy<typeof CustomFieldValue>,
) {}
async saveValues(
resourceType: string,
resourceId: number,
customFields: Record<string, any>,
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);
}
}
@@ -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,
);
}
}
}
@@ -0,0 +1,6 @@
export enum CustomFieldAction {
CREATE = 'Create',
EDIT = 'Edit',
DELETE = 'Delete',
VIEW = 'View',
}
@@ -20,6 +20,7 @@ import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service'; import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
import { LedgerModule } from '../Ledger/Ledger.module'; import { LedgerModule } from '../Ledger/Ledger.module';
import { AccountsModule } from '../Accounts/Accounts.module'; import { AccountsModule } from '../Accounts/Accounts.module';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
import { CustomerGLEntries } from './CustomerGLEntries'; import { CustomerGLEntries } from './CustomerGLEntries';
import { CustomerGLEntriesStorage } from './CustomerGLEntriesStorage'; import { CustomerGLEntriesStorage } from './CustomerGLEntriesStorage';
import { CustomerWriteGLOpeningBalanceSubscriber } from './subscribers/CustomerGLEntriesSubscriber'; import { CustomerWriteGLOpeningBalanceSubscriber } from './subscribers/CustomerGLEntriesSubscriber';
@@ -30,6 +31,7 @@ import { CustomerWriteGLOpeningBalanceSubscriber } from './subscribers/CustomerG
DynamicListModule, DynamicListModule,
LedgerModule, LedgerModule,
AccountsModule, AccountsModule,
CustomFieldsModule,
], ],
controllers: [CustomersController], controllers: [CustomersController],
providers: [ providers: [
@@ -11,6 +11,7 @@ import {
} from '../types/Customers.types'; } from '../types/Customers.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreateCustomerDto } from '../dtos/CreateCustomer.dto'; import { CreateCustomerDto } from '../dtos/CreateCustomer.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable() @Injectable()
export class CreateCustomer { export class CreateCustomer {
@@ -24,6 +25,7 @@ export class CreateCustomer {
private readonly uow: UnitOfWork, private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2, private readonly eventPublisher: EventEmitter2,
private readonly customerDTO: CreateEditCustomerDTO, private readonly customerDTO: CreateEditCustomerDTO,
private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService,
@Inject(Customer.name) @Inject(Customer.name)
private readonly customerModel: TenantModelProxy<typeof Customer>, private readonly customerModel: TenantModelProxy<typeof Customer>,
@@ -55,6 +57,17 @@ export class CreateCustomer {
.insertAndFetch({ .insertAndFetch({
...customerObj, ...customerObj,
}); });
// Save custom field values.
if (customerDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'Customer',
customer.id,
customerDTO.customFields,
trx,
);
}
// Triggers `onCustomerCreated` event. // Triggers `onCustomerCreated` event.
await this.eventPublisher.emitAsync(events.customers.onCreated, { await this.eventPublisher.emitAsync(events.customers.onCreated, {
customer, customer,
@@ -12,6 +12,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditCustomerDto } from '../dtos/EditCustomer.dto'; import { EditCustomerDto } from '../dtos/EditCustomer.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable() @Injectable()
export class EditCustomer { export class EditCustomer {
@@ -25,6 +26,7 @@ export class EditCustomer {
private uow: UnitOfWork, private uow: UnitOfWork,
private eventPublisher: EventEmitter2, private eventPublisher: EventEmitter2,
private customerDTO: CreateEditCustomerDTO, private customerDTO: CreateEditCustomerDTO,
private saveCustomFieldValuesService: SaveCustomFieldValuesService,
@Inject(Customer.name) @Inject(Customer.name)
private customerModel: TenantModelProxy<typeof Customer>, private customerModel: TenantModelProxy<typeof Customer>,
@@ -64,6 +66,17 @@ export class EditCustomer {
.updateAndFetchById(customerId, { .updateAndFetchById(customerId, {
...customerObj, ...customerObj,
}); });
// Save custom field values.
if (customerDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'Customer',
customerId,
customerDTO.customFields,
trx,
);
}
// Triggers `onCustomerEdited` event. // Triggers `onCustomerEdited` event.
await this.eventPublisher.emitAsync(events.customers.onEdited, { await this.eventPublisher.emitAsync(events.customers.onEdited, {
customerId, customerId,
@@ -3,11 +3,13 @@ import {
IsEmail, IsEmail,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsObject,
IsOptional,
IsString, IsString,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, ToNumber } from '@/common/decorators/Validators'; import { ToNumber } from '@/common/decorators/Validators';
import { ContactAddressDto } from './ContactAddress.dto'; import { ContactAddressDto } from './ContactAddress.dto';
export class CreateCustomerDto extends ContactAddressDto { export class CreateCustomerDto extends ContactAddressDto {
@@ -164,4 +166,13 @@ export class CreateCustomerDto extends ContactAddressDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
code?: string; code?: string;
@ApiProperty({
required: false,
description: 'Custom fields values',
example: { cf_priority: 'High' },
})
@IsOptional()
@IsObject()
customFields?: Record<string, any>;
} }
@@ -115,4 +115,11 @@ export class CustomerResponseDto {
@ApiProperty({ example: 1500.0 }) @ApiProperty({ example: 1500.0 })
closingBalance: number; closingBalance: number;
@ApiProperty({
required: false,
description: 'Custom fields values',
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
@@ -68,4 +68,12 @@ export class EditCustomerDto extends ContactAddressDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
code?: string; code?: string;
@ApiProperty({
required: false,
description: 'Custom fields values',
example: { cf_priority: 'High' },
})
@IsOptional()
customFields?: Record<string, any>;
} }
@@ -12,9 +12,18 @@ export class CustomerTransfromer extends ContactTransfromer {
'formattedOpeningBalanceAt', 'formattedOpeningBalanceAt',
'customerType', 'customerType',
'formattedCustomerType', 'formattedCustomerType',
'customFields',
]; ];
}; };
/**
* Retrieve custom fields from options.
* @returns {Record<string, any>}
*/
public customFields(): Record<string, any> {
return this.options?.customFields;
}
/** /**
* Retrieve customer type. * Retrieve customer type.
* @returns {string} * @returns {string}
@@ -3,11 +3,13 @@ import { CustomerTransfromer } from './CustomerTransformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Customer } from '../models/Customer'; import { Customer } from '../models/Customer';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service';
@Injectable() @Injectable()
export class GetCustomerService { export class GetCustomerService {
constructor( constructor(
private transformer: TransformerInjectable, private transformer: TransformerInjectable,
private getResourceCustomFieldsService: GetResourceCustomFieldsService,
@Inject(Customer.name) @Inject(Customer.name)
private customerModel: TenantModelProxy<typeof Customer>, private customerModel: TenantModelProxy<typeof Customer>,
@@ -24,7 +26,19 @@ export class GetCustomerService {
.findById(customerId) .findById(customerId)
.throwIfNotFound(); .throwIfNotFound();
// Load custom field values.
const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields(
'Customer',
customerId,
);
// Retrieves the transformered customers. // Retrieves the transformered customers.
return this.transformer.transform(customer, new CustomerTransfromer()); const transformed = await this.transformer.transform(
customer,
new CustomerTransfromer(),
{ customFields },
);
return transformed;
} }
} }
@@ -36,7 +36,7 @@ export class ImportFileCommon {
parsedData: Record<string, any>[], parsedData: Record<string, any>[],
trx?: Knex.Transaction, trx?: Knex.Transaction,
): Promise<[ImportOperSuccess[], ImportOperError[]]> { ): Promise<[ImportOperSuccess[], ImportOperError[]]> {
const resourceFields = this.resource.getResourceFields2( const resourceFields = await this.resource.getResourceFields2(
importFile.resource, importFile.resource,
); );
const importable = await this.importableRegistry.getImportable( const importable = await this.importableRegistry.getImportable(
@@ -34,7 +34,7 @@ export class ImportFileMapping {
.throwIfNotFound(); .throwIfNotFound();
// Invalidate the from/to map attributes. // Invalidate the from/to map attributes.
this.validateMapsAttrs(importFile, maps); await this.validateMapsAttrs(importFile, maps);
// @todo validate the required fields. // @todo validate the required fields.
@@ -63,8 +63,8 @@ export class ImportFileMapping {
* @param {ImportMappingAttr[]} maps * @param {ImportMappingAttr[]} maps
* @throws {ServiceError(ERRORS.INVALID_MAP_ATTRS)} * @throws {ServiceError(ERRORS.INVALID_MAP_ATTRS)}
*/ */
private validateMapsAttrs(importFile: any, maps: ImportMappingAttr[]) { private async validateMapsAttrs(importFile: any, maps: ImportMappingAttr[]) {
const fields = this.resource.getResourceFields2(importFile.resource); const fields = await this.resource.getResourceFields2(importFile.resource);
const columnsMap = fromPairs( const columnsMap = fromPairs(
importFile.columnsParsed.map((field) => [field, '']), importFile.columnsParsed.map((field) => [field, '']),
); );
@@ -53,7 +53,7 @@ export class ImportFileProcess {
const [sheetData, sheetColumns] = parseSheetData(buffer); const [sheetData, sheetColumns] = parseSheetData(buffer);
const resource = importFile.resource; 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. // Runs the importing operation with ability to return errors that will happen.
const [successedImport, failedImport, allData] = const [successedImport, failedImport, allData] =
@@ -100,7 +100,7 @@ export class ImportFileUploadService {
params: paramsStringified, params: paramsStringified,
}); });
const resourceColumnsMap = const resourceColumnsMap =
this.resourceService.getResourceFields2(resource); await this.resourceService.getResourceFields2(resource);
const resourceColumns = getResourceColumns(resourceColumnsMap); const resourceColumns = getResourceColumns(resourceColumnsMap);
return { return {
@@ -9,6 +9,7 @@ import { Item } from './models/Item';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenantModelProxy } from '../System/models/TenantBaseModel'; import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { CreateItemDto } from './dtos/Item.dto'; import { CreateItemDto } from './dtos/Item.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable({ scope: Scope.REQUEST }) @Injectable({ scope: Scope.REQUEST })
export class CreateItemService { export class CreateItemService {
@@ -23,6 +24,7 @@ export class CreateItemService {
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork, private readonly uow: UnitOfWork,
private readonly validators: ItemsValidators, private readonly validators: ItemsValidators,
private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService,
@Inject(Item.name) @Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>, private readonly itemModel: TenantModelProxy<typeof Item>,
@@ -110,10 +112,22 @@ export class CreateItemService {
.insertAndFetch({ .insertAndFetch({
...itemInsert, ...itemInsert,
}); });
// Save custom field values.
if (itemDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'Item',
item.id,
itemDTO.customFields,
trx,
);
}
// Triggers `onItemCreated` event. // Triggers `onItemCreated` event.
await this.eventEmitter.emitAsync(events.item.onCreated, { await this.eventEmitter.emitAsync(events.item.onCreated, {
item, item,
itemId: item.id, itemId: item.id,
itemDTO,
trx, trx,
} as IItemEventCreatedPayload); } as IItemEventCreatedPayload);
@@ -8,6 +8,7 @@ import { Item } from './models/Item';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenantModelProxy } from '../System/models/TenantBaseModel'; import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { EditItemDto } from './dtos/Item.dto'; import { EditItemDto } from './dtos/Item.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable() @Injectable()
export class EditItemService { export class EditItemService {
@@ -22,6 +23,7 @@ export class EditItemService {
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork, private readonly uow: UnitOfWork,
private readonly validators: ItemsValidators, private readonly validators: ItemsValidators,
private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService,
@Inject(Item.name) @Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>, private readonly itemModel: TenantModelProxy<typeof Item>,
@@ -130,11 +132,22 @@ export class EditItemService {
.query(trx) .query(trx)
.patchAndFetchById(itemId, itemModel); .patchAndFetchById(itemId, itemModel);
// Save custom field values.
if (itemDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'Item',
itemId,
itemDTO.customFields,
trx,
);
}
// Edit event payload. // Edit event payload.
const eventPayload: IItemEventEditedPayload = { const eventPayload: IItemEventEditedPayload = {
item: newItem, item: newItem,
oldItem, oldItem,
itemId: newItem.id, itemId: newItem.id,
itemDTO,
trx, trx,
}; };
// Triggers `onItemEdited` event. // Triggers `onItemEdited` event.
@@ -6,6 +6,7 @@ import { TransformerInjectable } from '../Transformer/TransformerInjectable.serv
import { ItemTransformer } from './Item.transformer'; import { ItemTransformer } from './Item.transformer';
import { TenantModelProxy } from '../System/models/TenantBaseModel'; import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service';
@Injectable() @Injectable()
export class GetItemService { export class GetItemService {
@@ -15,6 +16,7 @@ export class GetItemService {
private readonly eventEmitter2: EventEmitter2, private readonly eventEmitter2: EventEmitter2,
private readonly transformerInjectable: TransformerInjectable, private readonly transformerInjectable: TransformerInjectable,
private readonly clsService: ClsService, private readonly clsService: ClsService,
private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService,
) {} ) {}
/** /**
@@ -35,10 +37,18 @@ export class GetItemService {
.withGraphFetched('purchaseTaxRate') .withGraphFetched('purchaseTaxRate')
.throwIfNotFound(); .throwIfNotFound();
// Load custom field values.
const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields(
'Item',
itemId,
);
const transformed = await this.transformerInjectable.transform( const transformed = await this.transformerInjectable.transform(
item, item,
new ItemTransformer(), new ItemTransformer(),
{ customFields },
); );
const eventPayload = { itemId }; const eventPayload = { itemId };
// Triggers the `onItemViewed` event. // Triggers the `onItemViewed` event.
@@ -13,9 +13,18 @@ export class ItemTransformer extends Transformer {
'sellPriceFormatted', 'sellPriceFormatted',
'costPriceFormatted', 'costPriceFormatted',
'itemWarehouses', 'itemWarehouses',
'customFields',
]; ];
}; };
/**
* Retrieve custom fields from options.
* @returns {Record<string, any>}
*/
public customFields(): Record<string, any> {
return this.options?.customFields;
}
/** /**
* Formatted item type. * Formatted item type.
* @param {IItem} item * @param {IItem} item
@@ -20,12 +20,14 @@ import { ItemsExportable } from './ItemsExportable.service';
import { ItemsImportable } from './ItemsImportable.service'; import { ItemsImportable } from './ItemsImportable.service';
import { BulkDeleteItemsService } from './BulkDeleteItems.service'; import { BulkDeleteItemsService } from './BulkDeleteItems.service';
import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service'; import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
@Module({ @Module({
imports: [ imports: [
TenancyDatabaseModule, TenancyDatabaseModule,
DynamicListModule, DynamicListModule,
InventoryAdjustmentsModule, InventoryAdjustmentsModule,
CustomFieldsModule,
], ],
controllers: [ItemsController], controllers: [ItemsController],
providers: [ providers: [
@@ -9,9 +9,11 @@ import {
MaxLength, MaxLength,
Min, Min,
IsNotEmpty, IsNotEmpty,
IsObject,
IsOptional,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, ToNumber } from '@/common/decorators/Validators'; import { ToNumber } from '@/common/decorators/Validators';
export class CommandItemDto { export class CommandItemDto {
@IsString() @IsString()
@@ -209,6 +211,15 @@ export class CommandItemDto {
example: [1, 2, 3], example: [1, 2, 3],
}) })
mediaIds?: number[]; mediaIds?: number[];
@IsOptional()
@IsObject()
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
export class CreateItemDto extends CommandItemDto {} export class CreateItemDto extends CommandItemDto {}
@@ -240,4 +240,11 @@ export class ItemResponseDto {
example: '2024-03-20T10:00:00Z', example: '2024-03-20T10:00:00Z',
}) })
updatedAt: Date; updatedAt: Date;
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
@@ -43,6 +43,7 @@ import { GetPaymentReceivedMailTemplate } from './queries/GetPaymentReceivedMail
import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service'; import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service';
import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service'; import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service';
import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service'; import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
@Module({ @Module({
controllers: [PaymentReceivesController], controllers: [PaymentReceivesController],
@@ -95,6 +96,7 @@ import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePa
AccountsModule, AccountsModule,
MailNotificationModule, MailNotificationModule,
DynamicListModule, DynamicListModule,
CustomFieldsModule,
MailModule, MailModule,
BullModule.registerQueue({ name: SEND_PAYMENT_RECEIVED_MAIL_QUEUE }), BullModule.registerQueue({ name: SEND_PAYMENT_RECEIVED_MAIL_QUEUE }),
BullBoardModule.forFeature({ BullBoardModule.forFeature({
@@ -15,6 +15,7 @@ import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreatePaymentReceivedDto } from '../dtos/PaymentReceived.dto'; import { CreatePaymentReceivedDto } from '../dtos/PaymentReceived.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable() @Injectable()
export class CreatePaymentReceivedService { export class CreatePaymentReceivedService {
@@ -24,6 +25,7 @@ export class CreatePaymentReceivedService {
private uow: UnitOfWork, private uow: UnitOfWork,
private transformer: PaymentReceiveDTOTransformer, private transformer: PaymentReceiveDTOTransformer,
private tenancyContext: TenancyContext, private tenancyContext: TenancyContext,
private saveCustomFieldValuesService: SaveCustomFieldValuesService,
@Inject(PaymentReceived.name) @Inject(PaymentReceived.name)
private paymentReceived: TenantModelProxy<typeof PaymentReceived>, private paymentReceived: TenantModelProxy<typeof PaymentReceived>,
@@ -92,6 +94,17 @@ export class CreatePaymentReceivedService {
.insertGraphAndFetch({ .insertGraphAndFetch({
...paymentReceiveObj, ...paymentReceiveObj,
}); });
// Save custom field values.
if (paymentReceiveDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'PaymentReceive',
paymentReceive.id,
paymentReceiveDTO.customFields,
trx,
);
}
// Triggers `onPaymentReceiveCreated` event. // Triggers `onPaymentReceiveCreated` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onCreated, { await this.eventPublisher.emitAsync(events.paymentReceive.onCreated, {
paymentReceive, paymentReceive,
@@ -15,6 +15,7 @@ import { Customer } from '@/modules/Customers/models/Customer';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditPaymentReceivedDto } from '../dtos/PaymentReceived.dto'; import { EditPaymentReceivedDto } from '../dtos/PaymentReceived.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable() @Injectable()
export class EditPaymentReceivedService { export class EditPaymentReceivedService {
@@ -24,6 +25,7 @@ export class EditPaymentReceivedService {
private readonly eventPublisher: EventEmitter2, private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork, private readonly uow: UnitOfWork,
private readonly tenancyContext: TenancyContext, private readonly tenancyContext: TenancyContext,
private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService,
@Inject(PaymentReceived.name) @Inject(PaymentReceived.name)
private readonly paymentReceiveModel: TenantModelProxy< private readonly paymentReceiveModel: TenantModelProxy<
@@ -130,6 +132,17 @@ export class EditPaymentReceivedService {
id: paymentReceiveId, id: paymentReceiveId,
...paymentReceiveObj, ...paymentReceiveObj,
}); });
// Save custom field values.
if (paymentReceiveDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'PaymentReceive',
paymentReceiveId,
paymentReceiveDTO.customFields,
trx,
);
}
// Triggers `onPaymentReceiveEdited` event. // Triggers `onPaymentReceiveEdited` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onEdited, { await this.eventPublisher.emitAsync(events.paymentReceive.onEdited, {
paymentReceiveId, paymentReceiveId,
@@ -8,6 +8,7 @@ import {
IsArray, IsArray,
IsNotEmpty, IsNotEmpty,
IsInt, IsInt,
IsObject,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { ToNumber } from '@/common/decorators/Validators'; import { ToNumber } from '@/common/decorators/Validators';
@@ -138,6 +139,15 @@ export class CommandPaymentReceivedDto {
], ],
}) })
attachments?: AttachmentLinkDto[]; attachments?: AttachmentLinkDto[];
@IsOptional()
@IsObject()
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
export class CreatePaymentReceivedDto extends CommandPaymentReceivedDto {} export class CreatePaymentReceivedDto extends CommandPaymentReceivedDto {}
@@ -199,4 +199,11 @@ export class PaymentReceivedResponseDto {
}) })
@Type(() => AttachmentLinkDto) @Type(() => AttachmentLinkDto)
attachments?: AttachmentLinkDto[]; attachments?: AttachmentLinkDto[];
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
@@ -5,11 +5,13 @@ import { PaymentReceived } from '../models/PaymentReceived';
import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service'; import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service';
import { ServiceError } from '../../Items/ServiceError'; import { ServiceError } from '../../Items/ServiceError';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service';
@Injectable() @Injectable()
export class GetPaymentReceivedService { export class GetPaymentReceivedService {
constructor( constructor(
private readonly transformer: TransformerInjectable, private readonly transformer: TransformerInjectable,
private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService,
@Inject(PaymentReceived.name) @Inject(PaymentReceived.name)
private readonly paymentReceiveModel: TenantModelProxy< private readonly paymentReceiveModel: TenantModelProxy<
@@ -38,9 +40,18 @@ export class GetPaymentReceivedService {
if (!paymentReceive) { if (!paymentReceive) {
throw new ServiceError(ERRORS.PAYMENT_RECEIVE_NOT_EXISTS); 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, paymentReceive,
new PaymentReceiveTransfromer(), new PaymentReceiveTransfromer(),
{ customFields },
); );
return transformed;
} }
} }
@@ -19,9 +19,18 @@ export class PaymentReceiveTransfromer extends Transformer {
'formattedExchangeRate', 'formattedExchangeRate',
'entries', 'entries',
'attachments', 'attachments',
'customFields',
]; ];
}; };
/**
* Retrieve custom fields from options.
* @returns {Record<string, any>}
*/
public customFields(): Record<string, any> {
return this.options?.customFields;
}
/** /**
* Retrieve formatted payment receive date. * Retrieve formatted payment receive date.
* @param {PaymentReceived} invoice * @param {PaymentReceived} invoice
@@ -5,9 +5,10 @@ import { WarehousesModule } from '../Warehouses/Warehouses.module';
import { AccountsExportable } from '../Accounts/AccountsExportable.service'; import { AccountsExportable } from '../Accounts/AccountsExportable.service';
import { AccountsModule } from '../Accounts/Accounts.module'; import { AccountsModule } from '../Accounts/Accounts.module';
import { ResourceController } from './Resource.controller'; import { ResourceController } from './Resource.controller';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
@Module({ @Module({
imports: [BranchesModule, WarehousesModule, AccountsModule], imports: [BranchesModule, WarehousesModule, AccountsModule, CustomFieldsModule],
providers: [ResourceService], providers: [ResourceService],
exports: [ResourceService], exports: [ResourceService],
controllers: [ResourceController] controllers: [ResourceController]
@@ -10,6 +10,7 @@ import { IModelMeta } from '@/interfaces/Model';
import { IModelMetaField } from '@/interfaces/Model'; import { IModelMetaField } from '@/interfaces/Model';
import { Features } from '@/common/types/Features'; import { Features } from '@/common/types/Features';
import { resourceToModelName } from './_utils'; import { resourceToModelName } from './_utils';
import { GetCustomFieldsService } from '../CustomFields/queries/GetCustomFields.service';
const ERRORS = { const ERRORS = {
RESOURCE_MODEL_NOT_FOUND: 'RESOURCE_MODEL_NOT_FOUND', RESOURCE_MODEL_NOT_FOUND: 'RESOURCE_MODEL_NOT_FOUND',
@@ -22,6 +23,7 @@ export class ResourceService {
private readonly warehousesSettings: WarehousesSettings, private readonly warehousesSettings: WarehousesSettings,
private readonly moduleRef: ModuleRef, private readonly moduleRef: ModuleRef,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly getCustomFieldsService: GetCustomFieldsService,
) { } ) { }
/** /**
@@ -135,20 +137,69 @@ export class ResourceService {
return mapValues(fields, (field) => this.localizeField(field)); 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<string, string> = {
'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. * Retrieve the resource fields with localized names and hints.
* @param {string} modelName * @param {string} modelName
* @returns {IModelMetaField2} * @returns {IModelMetaField2}
*/ */
public getResourceFields2(modelName: string): { public async getResourceFields2(modelName: string): Promise<{
[key: string]: IModelMetaField2; [key: string]: IModelMetaField2;
} { }> {
const meta = this.getResourceMeta(modelName); const meta = this.getResourceMeta(modelName);
const filteredFields = this.filterSupportFeatures(meta.fields2); const filteredFields = this.filterSupportFeatures(meta.fields2);
return this.localizeFields( const localizedFields = this.localizeFields(
filteredFields as Record<string, IModelMetaField2>, filteredFields as Record<string, IModelMetaField2>,
); );
// 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<string, string> = {
'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;
} }
/** /**
@@ -60,7 +60,8 @@ export enum AbilitySubject {
CreditNote = 'CreditNode', CreditNote = 'CreditNode',
VendorCredit = 'VendorCredit', VendorCredit = 'VendorCredit',
Project = 'Project', Project = 'Project',
TaxRate = 'TaxRate' TaxRate = 'TaxRate',
CustomField = 'CustomField',
} }
export interface IRoleCreatedPayload { export interface IRoleCreatedPayload {
@@ -45,6 +45,7 @@ import { SaleEstimateAutoIncrementSubscriber } from './subscribers/SaleEstimateA
import { BulkDeleteSaleEstimatesService } from './BulkDeleteSaleEstimates.service'; import { BulkDeleteSaleEstimatesService } from './BulkDeleteSaleEstimates.service';
import { ValidateBulkDeleteSaleEstimatesService } from './ValidateBulkDeleteSaleEstimates.service'; import { ValidateBulkDeleteSaleEstimatesService } from './ValidateBulkDeleteSaleEstimates.service';
import { SendSaleEstimateMailProcess } from './processes/SendSaleEstimateMail.process'; import { SendSaleEstimateMailProcess } from './processes/SendSaleEstimateMail.process';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
@Module({ @Module({
imports: [ imports: [
@@ -55,6 +56,7 @@ import { SendSaleEstimateMailProcess } from './processes/SendSaleEstimateMail.pr
ChromiumlyTenancyModule, ChromiumlyTenancyModule,
TemplateInjectableModule, TemplateInjectableModule,
PdfTemplatesModule, PdfTemplatesModule,
CustomFieldsModule,
BullModule.registerQueue({ name: SendSaleEstimateMailQueue }), BullModule.registerQueue({ name: SendSaleEstimateMailQueue }),
BullBoardModule.forFeature({ BullBoardModule.forFeature({
name: SendSaleEstimateMailQueue, name: SendSaleEstimateMailQueue,
@@ -78,6 +78,7 @@ export class CreateSaleEstimate {
.upsertGraphAndFetch({ .upsertGraphAndFetch({
...estimateObj, ...estimateObj,
}); });
// Triggers `onSaleEstimateCreated` event. // Triggers `onSaleEstimateCreated` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onCreated, { await this.eventPublisher.emitAsync(events.saleEstimate.onCreated, {
saleEstimate, saleEstimate,
@@ -10,11 +10,13 @@ import {
IsEnum, IsEnum,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsObject,
IsOptional,
IsString, IsString,
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { IsOptional, ToNumber } from '@/common/decorators/Validators'; import { ToNumber } from '@/common/decorators/Validators';
enum DiscountType { enum DiscountType {
Percentage = 'percentage', Percentage = 'percentage',
@@ -176,6 +178,15 @@ export class CommandSaleEstimateDto {
example: 1, example: 1,
}) })
adjustment?: number; adjustment?: number;
@IsOptional()
@IsObject()
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
export class CreateSaleEstimateDto extends CommandSaleEstimateDto { } export class CreateSaleEstimateDto extends CommandSaleEstimateDto { }
@@ -203,4 +203,11 @@ export class SaleEstimateResponseDto {
}) })
@Type(() => AttachmentLinkDto) @Type(() => AttachmentLinkDto)
attachments: AttachmentLinkDto[]; attachments: AttachmentLinkDto[];
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
@@ -6,6 +6,7 @@ import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectab
import { SaleEstimate } from '../models/SaleEstimate'; import { SaleEstimate } from '../models/SaleEstimate';
import { events } from '@/common/events/events'; import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service';
@Injectable() @Injectable()
export class GetSaleEstimate { export class GetSaleEstimate {
@@ -16,6 +17,7 @@ export class GetSaleEstimate {
private readonly transformer: TransformerInjectable, private readonly transformer: TransformerInjectable,
private readonly validators: SaleEstimateValidators, private readonly validators: SaleEstimateValidators,
private readonly eventPublisher: EventEmitter2, private readonly eventPublisher: EventEmitter2,
private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService,
) {} ) {}
/** /**
@@ -35,11 +37,19 @@ export class GetSaleEstimate {
// Validates the estimate existance. // Validates the estimate existance.
this.validators.validateEstimateExistance(estimate); this.validators.validateEstimateExistance(estimate);
// Load custom field values.
const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields(
'SaleEstimate',
estimateId,
);
// Transformes sale estimate model to POJO. // Transformes sale estimate model to POJO.
const transformed = await this.transformer.transform( const transformed = await this.transformer.transform(
estimate, estimate,
new SaleEstimateTransfromer(), new SaleEstimateTransfromer(),
{ customFields },
); );
const eventPayload = { saleEstimateId: estimateId }; const eventPayload = { saleEstimateId: estimateId };
// Triggers `onSaleEstimateViewed` event. // Triggers `onSaleEstimateViewed` event.
@@ -27,9 +27,18 @@ export class SaleEstimateTransfromer extends Transformer {
'formattedCreatedAt', 'formattedCreatedAt',
'entries', 'entries',
'attachments', 'attachments',
'customFields',
]; ];
}; };
/**
* Retrieve custom fields from options.
* @returns {Record<string, any>}
*/
public customFields(): Record<string, any> {
return this.options?.customFields;
}
/** /**
* Retrieve formatted estimate date. * Retrieve formatted estimate date.
* @param {ISaleEstimate} invoice * @param {ISaleEstimate} invoice
@@ -62,6 +62,7 @@ import { SaleInvoicesCost } from './SalesInvoicesCost';
import { SaleInvoicesExportable } from './commands/SaleInvoicesExportable'; import { SaleInvoicesExportable } from './commands/SaleInvoicesExportable';
import { SaleInvoicesImportable } from './commands/SaleInvoicesImportable'; import { SaleInvoicesImportable } from './commands/SaleInvoicesImportable';
import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module'; import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
import { BulkDeleteSaleInvoicesService } from './BulkDeleteSaleInvoices.service'; import { BulkDeleteSaleInvoicesService } from './BulkDeleteSaleInvoices.service';
import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleInvoices.service'; import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleInvoices.service';
@@ -82,6 +83,7 @@ import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleI
forwardRef(() => InventoryCostModule), forwardRef(() => InventoryCostModule),
forwardRef(() => PaymentLinksModule), forwardRef(() => PaymentLinksModule),
DynamicListModule, DynamicListModule,
CustomFieldsModule,
BullModule.registerQueue({ name: SendSaleInvoiceQueue }), BullModule.registerQueue({ name: SendSaleInvoiceQueue }),
BullBoardModule.forFeature({ BullBoardModule.forFeature({
name: SendSaleInvoiceQueue, name: SendSaleInvoiceQueue,
@@ -115,6 +115,7 @@ export class EditSaleInvoice {
id: saleInvoiceId, id: saleInvoiceId,
...saleInvoiceObj, ...saleInvoiceObj,
}); });
// Edit event payload. // Edit event payload.
const editEventPayload: ISaleInvoiceEditedPayload = { const editEventPayload: ISaleInvoiceEditedPayload = {
saleInvoiceId, saleInvoiceId,
@@ -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 { ItemEntryDto } from '@/modules/TransactionItemEntry/dto/ItemEntry.dto';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
@@ -11,6 +11,8 @@ import {
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsObject,
IsOptional,
IsString, IsString,
Min, Min,
ValidateNested, ValidateNested,
@@ -216,6 +218,15 @@ class CommandSaleInvoiceDto {
example: [{ key: '123456' }], example: [{ key: '123456' }],
}) })
attachments?: AttachmentDto[]; attachments?: AttachmentDto[];
@IsOptional()
@IsObject()
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
export class CreateSaleInvoiceDto extends CommandSaleInvoiceDto {} export class CreateSaleInvoiceDto extends CommandSaleInvoiceDto {}
@@ -234,4 +234,11 @@ export class SaleInvoiceResponseDto {
required: false, required: false,
}) })
updatedAt?: Date; updatedAt?: Date;
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
@@ -8,6 +8,7 @@ import { CommandSaleInvoiceValidators } from '../commands/CommandSaleInvoiceVali
import { events } from '@/common/events/events'; import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { SaleInvoiceResponseDto } from '../dtos/SaleInvoiceResponse.dto'; import { SaleInvoiceResponseDto } from '../dtos/SaleInvoiceResponse.dto';
import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service';
@Injectable() @Injectable()
export class GetSaleInvoice { export class GetSaleInvoice {
@@ -15,6 +16,7 @@ export class GetSaleInvoice {
private transformer: TransformerInjectable, private transformer: TransformerInjectable,
private validators: CommandSaleInvoiceValidators, private validators: CommandSaleInvoiceValidators,
private eventPublisher: EventEmitter2, private eventPublisher: EventEmitter2,
private getResourceCustomFieldsService: GetResourceCustomFieldsService,
@Inject(SaleInvoice.name) @Inject(SaleInvoice.name)
private saleInvoiceModel: TenantModelProxy<typeof SaleInvoice>, private saleInvoiceModel: TenantModelProxy<typeof SaleInvoice>,
@@ -44,10 +46,18 @@ export class GetSaleInvoice {
// Validates the given sale invoice existance. // Validates the given sale invoice existance.
this.validators.validateInvoiceExistance(saleInvoice); this.validators.validateInvoiceExistance(saleInvoice);
// Load custom field values.
const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields(
'SaleInvoice',
saleInvoiceId,
);
const transformed = await this.transformer.transform( const transformed = await this.transformer.transform(
saleInvoice, saleInvoice,
new SaleInvoiceTransformer(), new SaleInvoiceTransformer(),
{ customFields },
); );
const eventPayload = { const eventPayload = {
saleInvoiceId, saleInvoiceId,
}; };
@@ -8,12 +8,14 @@ import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model';
import { SaleInvoice } from '../models/SaleInvoice'; import { SaleInvoice } from '../models/SaleInvoice';
import { GetSaleInvoicesQueryDto } from '../dtos/GetSaleInvoicesQuery.dto'; import { GetSaleInvoicesQueryDto } from '../dtos/GetSaleInvoicesQuery.dto';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service';
@Injectable() @Injectable()
export class GetSaleInvoicesService { export class GetSaleInvoicesService {
constructor( constructor(
private readonly dynamicListService: DynamicListService, private readonly dynamicListService: DynamicListService,
private readonly transformer: TransformerInjectable, private readonly transformer: TransformerInjectable,
private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService,
@Inject(SaleInvoice.name) @Inject(SaleInvoice.name)
private readonly saleInvoiceModel: TenantModelProxy<typeof SaleInvoice>, private readonly saleInvoiceModel: TenantModelProxy<typeof SaleInvoice>,
@@ -63,6 +65,18 @@ export class GetSaleInvoicesService {
new SaleInvoiceTransformer(), 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 { return {
salesInvoices, salesInvoices,
pagination, pagination,
@@ -32,9 +32,18 @@ export class SaleInvoiceTransformer extends Transformer {
'taxes', 'taxes',
'entries', 'entries',
'attachments', 'attachments',
'customFields',
]; ];
}; };
/**
* Retrieve custom fields from options.
* @returns {Record<string, any>}
*/
public customFields(): Record<string, any> {
return this.options?.customFields;
}
/** /**
* Retrieve formatted invoice date. * Retrieve formatted invoice date.
* @param {ISaleInvoice} invoice * @param {ISaleInvoice} invoice
@@ -46,6 +46,7 @@ import { SaleReceiptCostGLEntriesSubscriber } from './subscribers/SaleReceiptCos
import { SaleReceiptCostGLEntries } from './SaleReceiptCostGLEntries'; import { SaleReceiptCostGLEntries } from './SaleReceiptCostGLEntries';
import { BulkDeleteSaleReceiptsService } from './BulkDeleteSaleReceipts.service'; import { BulkDeleteSaleReceiptsService } from './BulkDeleteSaleReceipts.service';
import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleReceipts.service'; import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleReceipts.service';
import { CustomFieldsModule } from '../CustomFields/CustomFields.module';
@Module({ @Module({
controllers: [SaleReceiptsController], controllers: [SaleReceiptsController],
@@ -61,6 +62,7 @@ import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleR
AccountsModule, AccountsModule,
InventoryCostModule, InventoryCostModule,
DynamicListModule, DynamicListModule,
CustomFieldsModule,
MailModule, MailModule,
MailNotificationModule, MailNotificationModule,
BullModule.registerQueue({ name: SendSaleReceiptMailQueue }), BullModule.registerQueue({ name: SendSaleReceiptMailQueue }),
@@ -15,6 +15,7 @@ import { events } from '@/common/events/events';
import { Customer } from '@/modules/Customers/models/Customer'; import { Customer } from '@/modules/Customers/models/Customer';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditSaleReceiptDto } from '../dtos/SaleReceipt.dto'; import { EditSaleReceiptDto } from '../dtos/SaleReceipt.dto';
import { SaveCustomFieldValuesService } from '@/modules/CustomFields/queries/SaveCustomFieldValues.service';
@Injectable() @Injectable()
export class EditSaleReceipt { export class EditSaleReceipt {
@@ -24,6 +25,7 @@ export class EditSaleReceipt {
private readonly uow: UnitOfWork, private readonly uow: UnitOfWork,
private readonly validators: SaleReceiptValidators, private readonly validators: SaleReceiptValidators,
private readonly dtoTransformer: SaleReceiptDTOTransformer, private readonly dtoTransformer: SaleReceiptDTOTransformer,
private readonly saveCustomFieldValuesService: SaveCustomFieldValuesService,
@Inject(SaleReceipt.name) @Inject(SaleReceipt.name)
private readonly saleReceiptModel: TenantModelProxy<typeof SaleReceipt>, private readonly saleReceiptModel: TenantModelProxy<typeof SaleReceipt>,
@@ -96,6 +98,17 @@ export class EditSaleReceipt {
id: saleReceiptId, id: saleReceiptId,
...saleReceiptObj, ...saleReceiptObj,
}); });
// Save custom field values.
if (saleReceiptDTO.customFields) {
await this.saveCustomFieldValuesService.saveValues(
'SaleReceipt',
saleReceiptId,
saleReceiptDTO.customFields,
trx,
);
}
// Triggers `onSaleReceiptEdited` event. // Triggers `onSaleReceiptEdited` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onEdited, { await this.eventPublisher.emitAsync(events.saleReceipt.onEdited, {
oldSaleReceipt, oldSaleReceipt,
@@ -11,6 +11,7 @@ import {
IsEnum, IsEnum,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsObject,
IsOptional, IsOptional,
IsPositive, IsPositive,
IsString, IsString,
@@ -175,6 +176,15 @@ export class CommandSaleReceiptDto {
example: 1, example: 1,
}) })
adjustment?: number; adjustment?: number;
@IsOptional()
@IsObject()
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
export class CreateSaleReceiptDto extends CommandSaleReceiptDto {} export class CreateSaleReceiptDto extends CommandSaleReceiptDto {}
@@ -243,4 +243,11 @@ export class SaleReceiptResponseDto {
required: false, required: false,
}) })
updatedAt?: Date; updatedAt?: Date;
@ApiProperty({
description: 'Custom fields values',
required: false,
example: { cf_priority: 'High' },
})
customFields?: Record<string, any>;
} }
@@ -4,6 +4,7 @@ import { SaleReceiptValidators } from '../commands/SaleReceiptValidators.service
import { SaleReceipt } from '../models/SaleReceipt'; import { SaleReceipt } from '../models/SaleReceipt';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetResourceCustomFieldsService } from '@/modules/CustomFields/queries/GetResourceCustomFields.service';
@Injectable() @Injectable()
export class GetSaleReceipt { export class GetSaleReceipt {
@@ -11,6 +12,7 @@ export class GetSaleReceipt {
@Inject(SaleReceipt.name) @Inject(SaleReceipt.name)
private readonly saleReceiptModel: TenantModelProxy<typeof SaleReceipt>, private readonly saleReceiptModel: TenantModelProxy<typeof SaleReceipt>,
private readonly transformer: TransformerInjectable, private readonly transformer: TransformerInjectable,
private readonly getResourceCustomFieldsService: GetResourceCustomFieldsService,
) {} ) {}
/** /**
@@ -29,9 +31,18 @@ export class GetSaleReceipt {
.withGraphFetched('attachments') .withGraphFetched('attachments')
.throwIfNotFound(); .throwIfNotFound();
return this.transformer.transform( // Load custom field values.
const customFields = await this.getResourceCustomFieldsService.getResourceCustomFields(
'SaleReceipt',
saleReceiptId,
);
const transformed = await this.transformer.transform(
saleReceipt, saleReceipt,
new SaleReceiptTransformer(), new SaleReceiptTransformer(),
{ customFields },
); );
return transformed;
} }
} }
@@ -26,9 +26,18 @@ export class SaleReceiptTransformer extends Transformer {
'entries', 'entries',
'attachments', 'attachments',
'customFields',
]; ];
}; };
/**
* Retrieve custom fields from options.
* @returns {Record<string, any>}
*/
public customFields(): Record<string, any> {
return this.options?.customFields;
}
/** /**
* Retrieve formatted receipt date. * Retrieve formatted receipt date.
* @param {ISaleReceipt} invoice * @param {ISaleReceipt} invoice
@@ -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');
});
});
+1 -1
View File
@@ -2,7 +2,7 @@
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".", "rootDir": ".",
"testEnvironment": "node", "testEnvironment": "node",
"testRegex": "auth.e2e-spec.ts$", "testRegex": "(auth|custom-fields)\\.e2e-spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
@@ -63,6 +63,11 @@ export const PreferencesMenu = [
disabled: false, disabled: false,
href: '/preferences/items', href: '/preferences/items',
}, },
{
text: 'Custom Fields',
disabled: false,
href: '/preferences/custom-fields',
},
// { // {
// text: 'Integrations', // text: 'Integrations',
// disabled: false, // disabled: false,
@@ -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 (
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={<T id={'delete'} />}
icon="trash"
intent={Intent.DANGER}
isOpen={isOpen}
onCancel={handleCancelDelete}
onConfirm={handleConfirmDelete}
loading={isLoading}
>
<p>
<FormattedHTMLMessage
id={'custom_fields.delete_confirmation'}
/>
</p>
</Alert>
);
}
export default compose(
withAlertStoreConnect(),
withAlertActions,
)(CustomFieldDeleteAlert);
@@ -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 }];
@@ -31,6 +31,7 @@ import { SubscriptionAlerts } from '../Subscriptions/alerts/alerts';
import { BankAccountAlerts } from '@/containers/CashFlow/AccountTransactions/alerts'; import { BankAccountAlerts } from '@/containers/CashFlow/AccountTransactions/alerts';
import { BrandingTemplatesAlerts } from '../BrandingTemplates/alerts/BrandingTemplatesAlerts'; import { BrandingTemplatesAlerts } from '../BrandingTemplates/alerts/BrandingTemplatesAlerts';
import { PaymentMethodsAlerts } from '../Preferences/PaymentMethods/alerts/PaymentMethodsAlerts'; import { PaymentMethodsAlerts } from '../Preferences/PaymentMethods/alerts/PaymentMethodsAlerts';
import CustomFieldsAlerts from '@/containers/Alerts/CustomFields/CustomFieldsAlerts';
export default [ export default [
...AccountsAlerts, ...AccountsAlerts,
@@ -65,4 +66,5 @@ export default [
...BankAccountAlerts, ...BankAccountAlerts,
...BrandingTemplatesAlerts, ...BrandingTemplatesAlerts,
...PaymentMethodsAlerts, ...PaymentMethodsAlerts,
...CustomFieldsAlerts,
]; ];
@@ -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),
});
@@ -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 ? (
<T id={'custom_fields.new_custom_field'} />
) : (
<T id={'custom_fields.edit_custom_field'} />
),
);
}, [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 (
<Formik
initialValues={initialValues}
validationSchema={CustomFieldsFormSchema}
onSubmit={handleFormSubmit}
>
<CustomFieldsFormContent />
</Formik>
);
}
export default compose(withDashboardActions)(CustomFieldsForm);
@@ -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 (
<Form>
<CustomFieldsFormHeader />
<CustomFieldsFormFloatingActions />
</Form>
);
}
@@ -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 (
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
style={{ minWidth: 85 }}
type="submit"
>
{isNewMode ? (
<T id={'save'} />
) : (
<T id={'update'} />
)}
</Button>
<Button
onClick={handleCancelBtnClick}
style={{ minWidth: 75 }}>
<T id={'cancel'} />
</Button>
</div>
);
}
@@ -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 (
<Card>
{/* ---------- Resource ---------- */}
<FFormGroup
name={'resourceName'}
label={
<strong>
<T id={'custom_fields.label.resource'} />
</strong>
}
labelInfo={<FieldRequiredHint />}
inline
fastField
>
<FSelect
name={'resourceName'}
items={RESOURCE_OPTIONS}
valueAccessor={'value'}
textAccessor={'label'}
placeholder={intl.get('select_an_item')}
disabled={!isNewMode}
popoverProps={{ minimal: true }}
fastField
/>
</FFormGroup>
{/* ---------- Field Name ---------- */}
<FFormGroup
name={'fieldName'}
label={
<strong>
<T id={'custom_fields.label.field_name'} />
</strong>
}
labelInfo={<FieldRequiredHint />}
inline
fastField
>
<FInputGroup
name={'fieldName'}
medium={true}
inputRef={(ref) => (labelFieldRef.current = ref)}
fill
fastField
/>
</FFormGroup>
{/* ---------- Label ---------- */}
<FFormGroup
name={'label'}
label={
<strong>
<T id={'custom_fields.label.label'} />
</strong>
}
labelInfo={<FieldRequiredHint />}
inline
fastField
>
<FInputGroup
name={'label'}
medium={true}
fill
fastField
/>
</FFormGroup>
{/* ---------- Field Type ---------- */}
<FFormGroup
name={'fieldType'}
label={
<strong>
<T id={'custom_fields.label.type'} />
</strong>
}
labelInfo={<FieldRequiredHint />}
inline
fastField
>
<FSelect
name={'fieldType'}
items={FIELD_TYPE_OPTIONS}
valueAccessor={'value'}
textAccessor={'label'}
placeholder={intl.get('select_an_item')}
popoverProps={{ minimal: true }}
fastField
/>
</FFormGroup>
{/* ---------- Dropdown Options ---------- */}
{isDropdown && (
<DropdownOptionsField />
)}
{/* ---------- Default Value ---------- */}
<FFormGroup
name={'defaultValue'}
label={<T id={'custom_fields.label.default_value'} />}
inline
fastField
>
<FInputGroup
name={'defaultValue'}
medium={true}
fill
fastField
/>
</FFormGroup>
{/* ---------- Required ---------- */}
<FFormGroup
name={'required'}
label={<T id={'custom_fields.label.required'} />}
inline
fastField
>
<FCheckbox
name={'required'}
label={intl.get('custom_fields.required_description')}
fastField
/>
</FFormGroup>
{/* ---------- Order ---------- */}
<FFormGroup
name={'order'}
label={<T id={'custom_fields.label.order'} />}
inline
fastField
>
<FNumericInput
name={'order'}
min={0}
fill
fastField
/>
</FFormGroup>
{/* ---------- Active ---------- */}
<FFormGroup
name={'active'}
label={<T id={'custom_fields.label.active'} />}
inline
fastField
>
<FSwitch
name={'active'}
label={intl.get('custom_fields.active_description')}
fastField
/>
</FFormGroup>
</Card>
);
}
/**
* 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 (
<FFormGroup
label={<strong><T id={'custom_fields.label.choices'} /></strong>}
inline
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
{choices.map((choice, index) => (
<div key={index} style={{ display: 'flex', gap: 8 }}>
<input
type="text"
value={choice}
onChange={(e) => handleChangeChoice(index, e.target.value)}
className="bp4-input bp4-fill"
placeholder={intl.get('custom_fields.choice_placeholder')}
/>
<button
type="button"
className="bp4-button bp4-intent-danger"
onClick={() => handleRemoveChoice(index)}
>
{intl.get('remove')}
</button>
</div>
))}
<button
type="button"
className="bp4-button bp4-intent-primary"
onClick={handleAddChoice}
style={{ alignSelf: 'flex-start' }}
>
{intl.get('custom_fields.add_choice')}
</button>
</div>
</FFormGroup>
);
}
@@ -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 (
<CustomFieldsFormProvider customFieldId={idInteger} isNewMode={isNewMode}>
<CustomFieldsForm />
</CustomFieldsFormProvider>
);
}
@@ -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 (
<div
className={classNames(
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT,
)}
>
{isCustomFieldLoading ? (
<PreferencesPageLoader />
) : (
<CustomFieldsFormContext.Provider value={provider} {...props} />
)}
</div>
);
}
const useCustomFieldsFormContext = () => React.useContext(CustomFieldsFormContext);
export { CustomFieldsFormProvider, useCustomFieldsFormContext };
@@ -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 (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button
intent={Intent.PRIMARY}
onClick={handleNewCustomField}
text={intl.get('custom_fields.new_custom_field')}
/>
</div>
<CustomFieldsTable
columns={columns}
data={customFields}
loading={isCustomFieldsLoading}
headerLoading={isCustomFieldsFetching}
progressBarLoading={isCustomFieldsFetching}
TableLoadingRenderer={TableSkeletonRows}
ContextMenu={ActionsMenu}
payload={{
onDeleteCustomField: handleDeleteCustomField,
onEditCustomField: handleEditCustomField,
}}
/>
</div>
);
}
const CustomFieldsTable = styled(DataTable)`
.table .tr {
min-height: 42px;
}
`;
export default compose(withAlertActions)(CustomFieldsDataTable);
@@ -0,0 +1,18 @@
// @ts-nocheck
import React from 'react';
import { CustomFieldsListProvider } from './CustomFieldsListProvider';
import CustomFieldsDataTable from './CustomFieldsDataTable';
/**
* Custom fields list.
*/
function CustomFieldsList() {
return (
<CustomFieldsListProvider>
<CustomFieldsDataTable />
</CustomFieldsListProvider>
);
}
export default CustomFieldsList;
@@ -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 (
<div
className={classNames(
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT,
)}
>
<CustomFieldsListContext.Provider value={provider} {...props} />
</div>
);
}
const useCustomFieldsContext = () => React.useContext(CustomFieldsListContext);
export { CustomFieldsListProvider, useCustomFieldsContext };
@@ -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 (
<Menu>
<MenuItem
icon={<Icon icon="pen-18" />}
text={intl.get('custom_fields.edit')}
onClick={safeCallback(onEditCustomField, original)}
/>
<MenuDivider />
<MenuItem
icon={<Icon icon="trash-16" iconSize={16} />}
text={intl.get('custom_fields.delete')}
onClick={safeCallback(onDeleteCustomField, original)}
intent={Intent.DANGER}
/>
</Menu>
);
}
/**
* 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',
},
],
[],
);
}
@@ -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,
});
}
@@ -39,3 +39,4 @@ export * from './warehousesTransfers';
export * from './plaid'; export * from './plaid';
export * from './FinancialReports'; export * from './FinancialReports';
export * from './apiKeys'; export * from './apiKeys';
export * from './customFields';
@@ -245,6 +245,11 @@ export const API_KEYS = {
API_KEYS: 'API_KEYS', API_KEYS: 'API_KEYS',
}; };
const CUSTOM_FIELDS = {
CUSTOM_FIELDS: 'CUSTOM_FIELDS',
CUSTOM_FIELD: 'CUSTOM_FIELD',
};
export default { export default {
...Authentication, ...Authentication,
...ACCOUNTS, ...ACCOUNTS,
@@ -281,4 +286,5 @@ export default {
...TAX_RATES, ...TAX_RATES,
...EXCHANGE_RATE, ...EXCHANGE_RATE,
...API_KEYS, ...API_KEYS,
...CUSTOM_FIELDS,
}; };

Some files were not shown because too many files have changed in this diff Show More