1
0

Merge branch 'develop' into feat/ee-workspaces-multi-org-pr

This commit is contained in:
Ahmed Bouhuolia
2026-05-18 13:25:35 +02:00
97 changed files with 2024 additions and 1078 deletions
+3 -1
View File
@@ -23,7 +23,9 @@
"tenants:migrate:latest": "lerna run cli:tenants:migrate:latest --scope \"@bigcapital/server\"", "tenants:migrate:latest": "lerna run cli:tenants:migrate:latest --scope \"@bigcapital/server\"",
"system:seed:latest": "lerna run cli:system:seed:latest --scope \"@bigcapital/server\"", "system:seed:latest": "lerna run cli:system:seed:latest --scope \"@bigcapital/server\"",
"tenants:seed:latest": "lerna run cli:tenants:seed:latest --scope \"@bigcapital/server\"", "tenants:seed:latest": "lerna run cli:tenants:seed:latest --scope \"@bigcapital/server\"",
"generate:sdk-types": "lerna run openapi:export --scope \"@bigcapital/server\" && lerna run generate --scope \"@bigcapital/sdk-ts\" && lerna run build --scope \"@bigcapital/sdk-ts\"" "generate:sdk-types": "lerna run openapi:export --scope \"@bigcapital/server\" && lerna run generate --scope \"@bigcapital/sdk-ts\" && lerna run build --scope \"@bigcapital/sdk-ts\"",
"format": "lerna run format",
"format:check": "lerna run format:check"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^17.4.2", "@commitlint/cli": "^17.4.2",
@@ -0,0 +1,12 @@
exports.up = function(knex) {
return knex.schema.alterTable('contacts', table => {
table.string('code').nullable().unique();
});
};
exports.down = function(knex) {
return knex.schema.alterTable('contacts', table => {
table.dropColumn('code');
});
};
@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.alterTable('documents', (table) => {
table.unique('key');
});
};
exports.down = function (knex) {
return knex.schema.alterTable('documents', (table) => {
table.dropUnique('key');
});
};
@@ -9,7 +9,7 @@
"non_current_assets": "Non-Current Assets", "non_current_assets": "Non-Current Assets",
"liabilities_and_equity": "Liabilities and Equity", "liabilities_and_equity": "Liabilities and Equity",
"liabilities": "Liabilities", "liabilities": "Liabilities",
"current_liabilties": "Current Liabilties", "current_liabilities": "Current Liabilities",
"long_term_liabilities": "Long-Term Liabilities", "long_term_liabilities": "Long-Term Liabilities",
"non_current_liabilities": "Non-Current Liabilities", "non_current_liabilities": "Non-Current Liabilities",
"equity": "Equity", "equity": "Equity",
@@ -18,7 +18,7 @@ import { createBullBoardAuthMiddleware } from '@/middleware/bull-board-auth.midd
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { ClsModule, ClsService } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { AppController } from './App.controller'; import { AppController } from './App.controller';
import { AppService } from './App.service'; import { AppService } from './App.service';
import { ItemsModule } from '../Items/Items.module'; import { ItemsModule } from '../Items/Items.module';
@@ -170,9 +170,6 @@ import { AppThrottleModule } from './AppThrottle.module';
global: true, global: true,
middleware: { middleware: {
mount: true, mount: true,
setup: (cls: ClsService, req: Request, res: Response) => {
cls.set('organizationId', req.headers['organization-id']);
},
generateId: true, generateId: true,
saveReq: true, saveReq: true,
}, },
@@ -1,5 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { randomUUID } from 'node:crypto';
import * as multerS3 from 'multer-s3'; import * as multerS3 from 'multer-s3';
import { ClsService } from 'nestjs-cls';
import { S3_CLIENT, S3Module } from "../S3/S3.module"; import { S3_CLIENT, S3Module } from "../S3/S3.module";
import { DeleteAttachment } from "./DeleteAttachment"; import { DeleteAttachment } from "./DeleteAttachment";
import { GetAttachment } from "./GetAttachment"; import { GetAttachment } from "./GetAttachment";
@@ -59,8 +61,12 @@ const models = [
AttachmentUploadPipeline, AttachmentUploadPipeline,
{ {
provide: MULTER_MODULE_OPTIONS, provide: MULTER_MODULE_OPTIONS,
inject: [ConfigService, S3_CLIENT], inject: [ConfigService, S3_CLIENT, ClsService],
useFactory: (configService: ConfigService, s3: S3Client) => ({ useFactory: (
configService: ConfigService,
s3: S3Client,
cls: ClsService,
) => ({
storage: multerS3({ storage: multerS3({
s3, s3,
bucket: configService.get('s3.bucket'), bucket: configService.get('s3.bucket'),
@@ -69,7 +75,11 @@ const models = [
cb(null, { fieldName: file.fieldname }); cb(null, { fieldName: file.fieldname });
}, },
key: function (req, file, cb) { key: function (req, file, cb) {
cb(null, Date.now().toString()); const organizationId = cls.get<string>('organizationId');
if (!organizationId) {
return cb(new Error('Tenant context required for upload.'), undefined as any);
}
cb(null, `${organizationId}/${randomUUID()}`);
}, },
acl: function(req, file, cb) { acl: function(req, file, cb) {
// Conditionally set file to public or private based on isPublic flag // Conditionally set file to public or private based on isPublic flag
@@ -31,6 +31,9 @@ import { AttachmentUploadPipeline } from './S3UploadPipeline';
import { FileInterceptor } from '@/common/interceptors/file.interceptor'; import { FileInterceptor } from '@/common/interceptors/file.interceptor';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
import { AbilitySubject } from '@/modules/Roles/Roles.types';
import { AttachmentAction } from './Attachments.types';
@ApiTags('Attachments') @ApiTags('Attachments')
@Controller('/attachments') @Controller('/attachments')
@@ -86,6 +89,7 @@ export class AttachmentsController {
@ApiOperation({ summary: 'Get attachment by ID' }) @ApiOperation({ summary: 'Get attachment by ID' })
@ApiParam({ name: 'id', description: 'Attachment ID' }) @ApiParam({ name: 'id', description: 'Attachment ID' })
@ApiResponse({ status: 200, description: 'Returns the attachment file' }) @ApiResponse({ status: 200, description: 'Returns the attachment file' })
@RequirePermission(AttachmentAction.View, AbilitySubject.Attachment)
async getAttachment( async getAttachment(
@Res() res: Response, @Res() res: Response,
@Param('id') documentId: string, @Param('id') documentId: string,
@@ -93,11 +97,12 @@ export class AttachmentsController {
const data = await this.attachmentsApplication.get(documentId); const data = await this.attachmentsApplication.get(documentId);
const byte = await data.Body.transformToByteArray(); const byte = await data.Body.transformToByteArray();
const extension = mime.extension(data.ContentType); const contentType = data.ContentType || 'application/octet-stream';
const extension = mime.extension(contentType) || 'bin';
const buffer = Buffer.from(byte); const buffer = Buffer.from(byte);
res.set('Content-Disposition', `filename="${documentId}.${extension}"`); res.set('Content-Disposition', `filename="${documentId}.${extension}"`);
res.set('Content-Type', data.ContentType); res.set('Content-Type', contentType);
res.send(buffer); res.send(buffer);
} }
@@ -111,6 +116,7 @@ export class AttachmentsController {
status: 200, status: 200,
description: 'The document has been deleted successfully', description: 'The document has been deleted successfully',
}) })
@RequirePermission(AttachmentAction.Delete, AbilitySubject.Attachment)
async deleteAttachment(@Param('id') documentId: string) { async deleteAttachment(@Param('id') documentId: string) {
await this.attachmentsApplication.delete(documentId); await this.attachmentsApplication.delete(documentId);
@@ -184,6 +190,7 @@ export class AttachmentsController {
status: 200, status: 200,
description: 'Returns the presigned URL for the attachment', description: 'Returns the presigned URL for the attachment',
}) })
@RequirePermission(AttachmentAction.View, AbilitySubject.Attachment)
async getAttachmentPresignedUrl(@Param('id') documentKey: string) { async getAttachmentPresignedUrl(@Param('id') documentKey: string) {
const presignedUrl = const presignedUrl =
await this.attachmentsApplication.getPresignedUrl(documentKey); await this.attachmentsApplication.getPresignedUrl(documentKey);
@@ -1,3 +1,8 @@
export interface AttachmentLinkDTO { export interface AttachmentLinkDTO {
key: string; key: string;
} }
export enum AttachmentAction {
View = 'View',
Delete = 'Delete',
}
@@ -31,17 +31,17 @@ export class DeleteAttachment {
* @param {string} filekey * @param {string} filekey
*/ */
async delete(filekey: string): Promise<void> { async delete(filekey: string): Promise<void> {
const foundDocument = await this.documentModel()
.query()
.findOne('key', filekey)
.throwIfNotFound();
const params = { const params = {
Bucket: this.configService.get('s3.bucket'), Bucket: this.configService.get('s3.bucket'),
Key: filekey, Key: filekey,
}; };
await this.s3Client.send(new DeleteObjectCommand(params)); await this.s3Client.send(new DeleteObjectCommand(params));
const foundDocument = await this.documentModel()
.query()
.findOne('key', filekey)
.throwIfNotFound();
await this.uow.withTransaction(async (trx: Knex.Transaction) => { await this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Delete all document links // Delete all document links
await this.documentLinkModel() await this.documentLinkModel()
@@ -2,6 +2,8 @@ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { S3_CLIENT } from '../S3/S3.module'; import { S3_CLIENT } from '../S3/S3.module';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentModel } from './models/Document.model';
@Injectable() @Injectable()
export class GetAttachment { export class GetAttachment {
@@ -10,13 +12,21 @@ export class GetAttachment {
@Inject(S3_CLIENT) @Inject(S3_CLIENT)
private readonly s3: S3Client, private readonly s3: S3Client,
@Inject(DocumentModel.name)
private readonly documentModel: TenantModelProxy<typeof DocumentModel>,
) {} ) {}
/** /**
* Retrieves data of the given document key. * Retrieves data of the given document key.
* @param {string} filekey * @param {string} filekey
*/ */
async getAttachment(filekey: string) { async getAttachment(filekey: string) {
await this.documentModel()
.query()
.findOne('key', filekey)
.throwIfNotFound();
const params = { const params = {
Bucket: this.configService.get('s3.bucket'), Bucket: this.configService.get('s3.bucket'),
Key: filekey, Key: filekey,
@@ -24,7 +24,10 @@ export class GetAttachmentPresignedUrl {
* @returns {string} * @returns {string}
*/ */
async getPresignedUrl(key: string) { async getPresignedUrl(key: string) {
const foundDocument = await this.documentModel().query().findOne({ key }); const foundDocument = await this.documentModel()
.query()
.findOne({ key })
.throwIfNotFound();
const config = this.configService.get('s3'); const config = this.configService.get('s3');
let ResponseContentDisposition = 'attachment'; let ResponseContentDisposition = 'attachment';
@@ -2,6 +2,7 @@ import { ClsService } from 'nestjs-cls';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { SystemUser } from '@/modules/System/models/SystemUser'; import { SystemUser } from '@/modules/System/models/SystemUser';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { ModelObject } from 'objection'; import { ModelObject } from 'objection';
import { JwtPayload } from '../Auth.interfaces'; import { JwtPayload } from '../Auth.interfaces';
import { InvalidEmailPasswordException } from '../exceptions/InvalidEmailPassword.exception'; import { InvalidEmailPasswordException } from '../exceptions/InvalidEmailPassword.exception';
@@ -12,6 +13,10 @@ export class AuthSigninService {
constructor( constructor(
@Inject(SystemUser.name) @Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser, private readonly systemUserModel: typeof SystemUser,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly clsService: ClsService, private readonly clsService: ClsService,
) { } ) { }
@@ -49,6 +54,7 @@ export class AuthSigninService {
*/ */
async verifyPayload(payload: JwtPayload): Promise<any> { async verifyPayload(payload: JwtPayload): Promise<any> {
let user: SystemUser; let user: SystemUser;
let tenant: TenantModel | undefined;
try { try {
user = await this.systemUserModel user = await this.systemUserModel
@@ -56,8 +62,14 @@ export class AuthSigninService {
.findOne({ email: payload.sub }) .findOne({ email: payload.sub })
.throwIfNotFound(); .throwIfNotFound();
tenant = await this.tenantModel
.query()
.findById(user.tenantId)
.throwIfNotFound();
this.clsService.set('tenantId', user.tenantId); this.clsService.set('tenantId', user.tenantId);
this.clsService.set('userId', user.id); this.clsService.set('userId', user.id);
this.clsService.set('organizationId', tenant.organizationId);
} catch (error) { } catch (error) {
throw new UserNotFoundException(String(payload.sub)); throw new UserNotFoundException(String(payload.sub));
} }
@@ -11,6 +11,7 @@ import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator'; import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator';
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
import { BillMeta } from './Bill.meta'; import { BillMeta } from './Bill.meta';
import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection';
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
import { BillDefaultViews } from '../Bills.constants'; import { BillDefaultViews } from '../Bills.constants';
import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator';
@@ -407,7 +408,8 @@ export class Bill extends TenantBaseModel {
* Sort the bills by full-payment bills. * Sort the bills by full-payment bills.
*/ */
sortByStatus(query, order) { sortByStatus(query, order) {
query.orderByRaw(`PAYMENT_AMOUNT = AMOUNT ${order}`); const dir = sanitizeSortDirection(order);
query.orderByRaw(`PAYMENT_AMOUNT = AMOUNT ${dir}`);
}, },
/** /**
@@ -9,6 +9,7 @@ import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/Inje
import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry'; import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry';
import { Warehouse } from '@/modules/Warehouses/models/Warehouse.model'; import { Warehouse } from '@/modules/Warehouses/models/Warehouse.model';
import { CreditNoteMeta } from './CreditNote.meta'; import { CreditNoteMeta } from './CreditNote.meta';
import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection';
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
import { CreditNoteDefaultViews } from '../constants'; import { CreditNoteDefaultViews } from '../constants';
import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator';
@@ -277,8 +278,9 @@ export class CreditNote extends TenantBaseModel {
* *
*/ */
sortByStatus(query, order) { sortByStatus(query, order) {
const dir = sanitizeSortDirection(order);
query.orderByRaw( query.orderByRaw(
`COALESCE(REFUNDED_AMOUNT) + COALESCE(INVOICES_AMOUNT) = COALESCE(AMOUNT) ${order}`, `COALESCE(REFUNDED_AMOUNT) + COALESCE(INVOICES_AMOUNT) = COALESCE(AMOUNT) ${dir}`,
); );
}, },
}; };
@@ -1,5 +1,6 @@
import { IsEmail, IsOptional, IsString } from 'class-validator'; import { IsEmail, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsOptional } from '@/common/decorators/Validators';
export class ContactAddressDto { export class ContactAddressDto {
@ApiProperty({ required: false, description: 'Billing address line 1' }) @ApiProperty({ required: false, description: 'Billing address line 1' })
@@ -155,4 +155,13 @@ export class CreateCustomerDto extends ContactAddressDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
active?: boolean; active?: boolean;
@ApiProperty({
required: false,
description: 'Customer code',
example: 'CUST-001',
})
@IsOptional()
@IsString()
code?: string;
} }
@@ -1,6 +1,7 @@
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsBoolean, IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ContactAddressDto } from './ContactAddress.dto'; import { ContactAddressDto } from './ContactAddress.dto';
import { IsOptional } from '@/common/decorators/Validators';
export class EditCustomerDto extends ContactAddressDto { export class EditCustomerDto extends ContactAddressDto {
@ApiProperty({ required: true, description: 'Customer type' }) @ApiProperty({ required: true, description: 'Customer type' })
@@ -62,4 +63,9 @@ export class EditCustomerDto extends ContactAddressDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
active?: boolean; active?: boolean;
@ApiProperty({ required: false, description: 'Customer code' })
@IsOptional()
@IsString()
code?: string;
} }
@@ -70,6 +70,8 @@ export class Customer extends TenantBaseModel {
note: string; note: string;
active: boolean; active: boolean;
code?: string;
/** /**
* Query builder. * Query builder.
*/ */
@@ -32,6 +32,7 @@ export interface ICustomerNewDTO extends IContactAddressDTO {
note?: string; note?: string;
active?: boolean; active?: boolean;
code?: string;
} }
export interface ICustomerEditDTO extends IContactAddressDTO { export interface ICustomerEditDTO extends IContactAddressDTO {
@@ -50,6 +51,7 @@ export interface ICustomerEditDTO extends IContactAddressDTO {
note?: string; note?: string;
active?: boolean; active?: boolean;
code?: string;
} }
export interface ICustomersFilter extends IDynamicListFilter { export interface ICustomersFilter extends IDynamicListFilter {
@@ -1,5 +1,6 @@
import { FIELD_TYPE } from './constants'; import { FIELD_TYPE } from './constants';
import { DynamicFilterRoleAbstractor } from './DynamicFilterRoleAbstractor'; import { DynamicFilterRoleAbstractor } from './DynamicFilterRoleAbstractor';
import { sanitizeSortDirection } from './sanitizeSortDirection';
interface ISortRole { interface ISortRole {
fieldKey: string; fieldKey: string;
@@ -67,17 +68,18 @@ export class DynamicFilterSortBy extends DynamicFilterRoleAbstractor {
public buildQuery = () => { public buildQuery = () => {
const field = this.model.getField(this.sortRole.fieldKey); const field = this.model.getField(this.sortRole.fieldKey);
const comparatorColumn = this.getFieldComparatorColumn(field); const comparatorColumn = this.getFieldComparatorColumn(field);
const safeOrder = sanitizeSortDirection(this.sortRole.order);
// Sort custom query. // Sort custom query.
if (typeof field.sortCustomQuery !== 'undefined') { if (typeof field.sortCustomQuery !== 'undefined') {
return (builder) => { return (builder) => {
field.sortCustomQuery(builder, this.sortRole); field.sortCustomQuery(builder, { ...this.sortRole, order: safeOrder });
}; };
} }
return (builder) => { return (builder) => {
if (this.sortRole.fieldKey) { if (this.sortRole.fieldKey) {
builder.orderBy(`${comparatorColumn}`, this.sortRole.order); builder.orderBy(`${comparatorColumn}`, safeOrder);
} }
}; };
}; };
@@ -0,0 +1,8 @@
/**
* Normalises an arbitrary `sortOrder` value to a SQL-safe direction.
* Returns 'DESC' only on an explicit case-insensitive match; otherwise 'ASC'.
* Used to defuse `orderByRaw` interpolation in dynamic listing modifiers.
*/
export function sanitizeSortDirection(order: unknown): 'ASC' | 'DESC' {
return String(order ?? '').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
}
@@ -1,9 +1,21 @@
import { ToNumber } from '@/common/decorators/Validators'; import { ToNumber } from '@/common/decorators/Validators';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsString } from 'class-validator'; import { IsArray, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
import { IFilterRole, ISortOrder } from '../DynamicFilter/DynamicFilter.types'; import { IFilterRole, ISortOrder } from '../DynamicFilter/DynamicFilter.types';
export class DynamicFilterQueryDto { export class DynamicFilterQueryDto {
@ApiPropertyOptional({ description: 'Page number (1-based)', type: Number })
@IsOptional()
@IsInt()
@ToNumber()
page?: number;
@ApiPropertyOptional({ description: 'Page size', type: Number })
@IsOptional()
@IsInt()
@ToNumber()
pageSize?: number;
@ApiPropertyOptional({ description: 'Custom view ID', type: Number }) @ApiPropertyOptional({ description: 'Custom view ID', type: Number })
@IsOptional() @IsOptional()
@ToNumber() @ToNumber()
@@ -20,7 +32,7 @@ export class DynamicFilterQueryDto {
columnSortBy: string; columnSortBy: string;
@ApiPropertyOptional({ description: 'Sort order (asc/desc)', type: String }) @ApiPropertyOptional({ description: 'Sort order (asc/desc)', type: String })
@IsString() @IsIn(['ASC', 'DESC', 'asc', 'desc'])
@IsOptional() @IsOptional()
sortOrder: ISortOrder; sortOrder: ISortOrder;
@@ -12,6 +12,7 @@ import { ServiceError } from '../Items/ServiceError';
import { ResourceService } from '../Resource/ResourceService'; import { ResourceService } from '../Resource/ResourceService';
import { getExportableService } from './decorators/ExportableModel.decorator'; import { getExportableService } from './decorators/ExportableModel.decorator';
import { ContextIdFactory, ModuleRef } from '@nestjs/core'; import { ContextIdFactory, ModuleRef } from '@nestjs/core';
import { I18nService } from 'nestjs-i18n';
@Injectable() @Injectable()
export class ExportResourceService { export class ExportResourceService {
@@ -20,6 +21,7 @@ export class ExportResourceService {
private readonly exportPdf: ExportPdf, private readonly exportPdf: ExportPdf,
private readonly resourceService: ResourceService, private readonly resourceService: ResourceService,
private readonly moduleRef: ModuleRef, private readonly moduleRef: ModuleRef,
private readonly i18nService: I18nService,
) {} ) {}
/** /**
@@ -147,7 +149,7 @@ export class ExportResourceService {
const group = parent; const group = parent;
return [ return [
{ {
name: value.name, name: this.i18nService.t(value.name, { defaultValue: value.name }),
type: value.type || 'text', type: value.type || 'text',
accessor: value.accessor || key, accessor: value.accessor || key,
group, group,
@@ -174,7 +176,7 @@ export class ExportResourceService {
const group = parent; const group = parent;
return [ return [
{ {
name: value.name, name: this.i18nService.t(value.name, { defaultValue: value.name }),
type: value.type || 'text', type: value.type || 'text',
accessor: value.accessor || key, accessor: value.accessor || key,
group, group,
@@ -38,6 +38,7 @@ export class APAgingSummarySheet extends AgingSummaryReport {
this.query = query; this.query = query;
this.repository = repository; this.repository = repository;
this.baseCurrency = meta.baseCurrency;
this.numberFormat = this.query.numberFormat; this.numberFormat = this.query.numberFormat;
this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat;
@@ -44,6 +44,7 @@ export class ARAgingSummarySheet extends AgingSummaryReport {
this.query = query; this.query = query;
this.repository = repository; this.repository = repository;
this.baseCurrency = meta.baseCurrency;
this.numberFormat = this.query.numberFormat; this.numberFormat = this.query.numberFormat;
this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat;
@@ -18,7 +18,7 @@ import { IARAgingSummaryCustomer } from '../ARAgingSummary/ARAgingSummary.types'
export abstract class AgingSummaryReport extends AgingReport { export abstract class AgingSummaryReport extends AgingReport {
readonly contacts: ModelObject<Customer | Vendor>[]; readonly contacts: ModelObject<Customer | Vendor>[];
readonly agingPeriods: IAgingPeriod[] = []; readonly agingPeriods: IAgingPeriod[] = [];
readonly baseCurrency: string; public baseCurrency: string;
readonly query: IAgingSummaryQuery; readonly query: IAgingSummaryQuery;
readonly overdueInvoicesByContactId: Record< readonly overdueInvoicesByContactId: Record<
number, number,
@@ -281,7 +281,7 @@ export const BalanceSheetResponseExample = {
}, },
children: [ children: [
{ {
name: 'Current Liabilties', name: 'Current Liabilities',
id: 'CURRENT_LIABILITY', id: 'CURRENT_LIABILITY',
node_type: 'AGGREGATE', node_type: 'AGGREGATE',
type: 'AGGREGATE', type: 'AGGREGATE',
@@ -912,7 +912,7 @@ export const BalanceSheetTableResponseExample = {
cells: [ cells: [
{ {
key: 'name', key: 'name',
value: 'Current Liabilties', value: 'Current Liabilities',
}, },
{ {
key: 'total', key: 'total',
@@ -1024,7 +1024,7 @@ export const BalanceSheetTableResponseExample = {
cells: [ cells: [
{ {
key: 'name', key: 'name',
value: 'Total Current Liabilties', value: 'Total Current Liabilities',
}, },
{ {
key: 'total', key: 'total',
@@ -88,7 +88,7 @@ export const getBalanceSheetSchema = () => [
type: BALANCE_SHEET_SCHEMA_NODE_TYPE.AGGREGATE, type: BALANCE_SHEET_SCHEMA_NODE_TYPE.AGGREGATE,
children: [ children: [
{ {
name: 'balance_sheet.current_liabilties', name: 'balance_sheet.current_liabilities',
id: BALANCE_SHEET_SCHEMA_NODE_ID.CURRENT_LIABILITY, id: BALANCE_SHEET_SCHEMA_NODE_ID.CURRENT_LIABILITY,
type: BALANCE_SHEET_SCHEMA_NODE_TYPE.ACCOUNTS, type: BALANCE_SHEET_SCHEMA_NODE_TYPE.ACCOUNTS,
accountsTypes: [ accountsTypes: [
@@ -33,6 +33,7 @@ export class InventoryValuationSheet extends FinancialSheet {
this.query = query; this.query = query;
this.repository = repository; this.repository = repository;
this.baseCurrency = meta.baseCurrency;
this.numberFormat = this.query.numberFormat; this.numberFormat = this.query.numberFormat;
this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat;
} }
@@ -31,6 +31,7 @@ export class SalesTaxLiabilitySummary extends FinancialSheet {
this.query = query; this.query = query;
this.repository = repository; this.repository = repository;
this.baseCurrency = meta.baseCurrency;
this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat;
} }
@@ -38,7 +38,7 @@ export class VendorBalanceSummaryService {
const reportInstance = new VendorBalanceSummaryReport( const reportInstance = new VendorBalanceSummaryReport(
this.vendorBalanceSummaryRepository, this.vendorBalanceSummaryRepository,
filter, filter,
{ baseCurrency: this.vendorBalanceSummaryRepository.baseCurrency, dateFormat: meta.dateFormat }, { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat },
); );
// Triggers `onVendorBalanceSummaryViewed` event. // Triggers `onVendorBalanceSummaryViewed` event.
@@ -5,6 +5,7 @@ import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator'; import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator';
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
import { ManualJournalMeta } from './ManualJournal.meta'; import { ManualJournalMeta } from './ManualJournal.meta';
import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection';
import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; import { ImportableModel } from '@/modules/Import/decorators/Import.decorator';
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
import { ManualJournalDefaultViews } from '../constants'; import { ManualJournalDefaultViews } from '../constants';
@@ -80,7 +81,8 @@ export class ManualJournal extends TenantBaseModel {
* Sort by status query. * Sort by status query.
*/ */
sortByStatus(query, order) { sortByStatus(query, order) {
query.orderByRaw(`PUBLISHED_AT IS NULL ${order}`); const dir = sanitizeSortDirection(order);
query.orderByRaw(`PUBLISHED_AT IS NULL ${dir}`);
}, },
/** /**
@@ -16,6 +16,7 @@ import { BillAction } from "../Bills/Bills.types";
import { AbilitySubject, ISubjectAbilitiesSchema, ISubjectAbilitySchema } from "./Roles.types"; import { AbilitySubject, ISubjectAbilitiesSchema, ISubjectAbilitySchema } from "./Roles.types";
import { PaymentReceiveAction } from "../PaymentReceived/types/PaymentReceived.types"; import { PaymentReceiveAction } from "../PaymentReceived/types/PaymentReceived.types";
import { PreferencesAction } from "../Settings/Settings.types"; import { PreferencesAction } from "../Settings/Settings.types";
import { AttachmentAction } from "../Attachments/Attachments.types";
export const AbilitySchema: ISubjectAbilitiesSchema[] = [ export const AbilitySchema: ISubjectAbilitiesSchema[] = [
{ {
@@ -305,6 +306,14 @@ export const AbilitySchema: ISubjectAbilitiesSchema[] = [
}, },
], ],
}, },
{
subject: AbilitySubject.Attachment,
subjectLabel: 'ability.attachments',
abilities: [
{ key: AttachmentAction.View, label: 'ability.view', default: true },
{ key: AttachmentAction.Delete, label: 'ability.delete', default: true },
],
},
]; ];
/** /**
@@ -60,7 +60,8 @@ export enum AbilitySubject {
CreditNote = 'CreditNode', CreditNote = 'CreditNode',
VendorCredit = 'VendorCredit', VendorCredit = 'VendorCredit',
Project = 'Project', Project = 'Project',
TaxRate = 'TaxRate' TaxRate = 'TaxRate',
Attachment = 'Attachment',
} }
export interface IRoleCreatedPayload { export interface IRoleCreatedPayload {
@@ -6,6 +6,7 @@ import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.dec
import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; import { ImportableModel } from '@/modules/Import/decorators/Import.decorator';
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
import { SaleEstimateMeta } from './SaleEstimate.meta'; import { SaleEstimateMeta } from './SaleEstimate.meta';
import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection';
import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry'; import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry';
import { Document } from '@/modules/ChromiumlyTenancy/models/Document'; import { Document } from '@/modules/ChromiumlyTenancy/models/Document';
import { Customer } from '@/modules/Customers/models/Customer'; import { Customer } from '@/modules/Customers/models/Customer';
@@ -250,7 +251,8 @@ export class SaleEstimate extends TenantBaseModel {
* Sorting the estimates orders by delivery status. * Sorting the estimates orders by delivery status.
*/ */
orderByStatus(query, order) { orderByStatus(query, order) {
query.orderByRaw(`delivered_at is null ${order}`); const dir = sanitizeSortDirection(order);
query.orderByRaw(`delivered_at is null ${dir}`);
}, },
/** /**
* Filtering the estimates oreders by status field. * Filtering the estimates oreders by status field.
@@ -10,6 +10,7 @@ import { Document } from '@/modules/ChromiumlyTenancy/models/Document';
import { DiscountType } from '@/common/types/Discount'; import { DiscountType } from '@/common/types/Discount';
import { Account } from '@/modules/Accounts/models/Account.model'; import { Account } from '@/modules/Accounts/models/Account.model';
import { ISearchRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types'; import { ISearchRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types';
import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { TransactionPaymentServiceEntry } from '@/modules/PaymentServices/models/TransactionPaymentServiceEntry.model'; import { TransactionPaymentServiceEntry } from '@/modules/PaymentServices/models/TransactionPaymentServiceEntry.model';
import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator';
@@ -417,14 +418,16 @@ export class SaleInvoice extends TenantBaseModel {
* Sort the sale invoices by full-payment invoices. * Sort the sale invoices by full-payment invoices.
*/ */
sortByStatus(query, order) { sortByStatus(query, order) {
query.orderByRaw(`PAYMENT_AMOUNT = BALANCE ${order}`); const dir = sanitizeSortDirection(order);
query.orderByRaw(`PAYMENT_AMOUNT = BALANCE ${dir}`);
}, },
/** /**
* Sort the sale invoices by the due amount. * Sort the sale invoices by the due amount.
*/ */
sortByDueAmount(query, order) { sortByDueAmount(query, order) {
query.orderByRaw(`BALANCE - PAYMENT_AMOUNT ${order}`); const dir = sanitizeSortDirection(order);
query.orderByRaw(`BALANCE - PAYMENT_AMOUNT ${dir}`);
}, },
/** /**
@@ -18,6 +18,7 @@ import { ImportableModel } from '@/modules/Import/decorators/Import.decorator';
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator';
import { SaleReceiptMeta } from './SaleReceipt.meta'; import { SaleReceiptMeta } from './SaleReceipt.meta';
import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection';
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
import { SaleReceiptDefaultViews } from '../constants'; import { SaleReceiptDefaultViews } from '../constants';
@@ -238,7 +239,8 @@ export class SaleReceipt extends ExtendedModel {
* Sorting the receipts order by status. * Sorting the receipts order by status.
*/ */
sortByStatus(query, order) { sortByStatus(query, order) {
query.orderByRaw(`CLOSED_AT IS NULL ${order}`); const dir = sanitizeSortDirection(order);
query.orderByRaw(`CLOSED_AT IS NULL ${dir}`);
}, },
/** /**
@@ -20,14 +20,14 @@ export const TenantAgnosticRoute = () => SetMetadata(IS_TENANT_AGNOSTIC, true);
@Injectable() @Injectable()
export class TenancyGlobalGuard implements CanActivate { export class TenancyGlobalGuard implements CanActivate {
constructor( constructor(
private reflector: Reflector,
private readonly cls: ClsService,
@Inject(UserTenant.name) @Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant, private readonly userTenantModel: typeof UserTenant,
@Inject(TenantModel.name) @Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel, private readonly tenantModel: typeof TenantModel,
private readonly reflector: Reflector,
private readonly clsService: ClsService,
) {} ) {}
/** /**
@@ -55,9 +55,8 @@ export class TenancyGlobalGuard implements CanActivate {
if (!organizationId) { if (!organizationId) {
throw new UnauthorizedException('Organization ID is required.'); throw new UnauthorizedException('Organization ID is required.');
} }
// Validate that the authenticated user is a member of the requested organization. // Validate that the authenticated user is a member of the requested organization.
const userId = this.cls.get<number>('userId'); const userId = this.clsService.get<number>('userId');
const tenant = await this.tenantModel.query().findOne({ organizationId }); const tenant = await this.tenantModel.query().findOne({ organizationId });
if (!tenant) { if (!tenant) {
@@ -73,7 +72,6 @@ export class TenancyGlobalGuard implements CanActivate {
'You do not have access to this organization.', 'You do not have access to this organization.',
); );
} }
return true; return true;
} }
} }
@@ -9,6 +9,7 @@ import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.dec
import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; import { ImportableModel } from '@/modules/Import/decorators/Import.decorator';
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
import { VendorCreditMeta } from './VendorCredit.meta'; import { VendorCreditMeta } from './VendorCredit.meta';
import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection';
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
import { VendorCreditDefaultViews } from '../constants'; import { VendorCreditDefaultViews } from '../constants';
import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator';
@@ -198,8 +199,9 @@ export class VendorCredit extends TenantBaseModel {
* *
*/ */
sortByStatus(query, order) { sortByStatus(query, order) {
const dir = sanitizeSortDirection(order);
query.orderByRaw( query.orderByRaw(
`COALESCE(REFUNDED_AMOUNT) + COALESCE(INVOICED_AMOUNT) = COALESCE(AMOUNT) ${order}`, `COALESCE(REFUNDED_AMOUNT) + COALESCE(INVOICED_AMOUNT) = COALESCE(AMOUNT) ${dir}`,
); );
}, },
}; };
@@ -115,4 +115,13 @@ export class CreateVendorDto extends ContactAddressDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
active?: boolean; active?: boolean;
@ApiProperty({
required: false,
description: 'Vendor code',
example: 'VEND-001',
})
@IsOptional()
@IsString()
code?: string;
} }
@@ -1,5 +1,6 @@
import { ContactAddressDto } from '@/modules/Customers/dtos/ContactAddress.dto'; import { ContactAddressDto } from '@/modules/Customers/dtos/ContactAddress.dto';
import { IsEmail, IsString, IsBoolean, IsOptional } from 'class-validator'; import { IsEmail, IsString, IsBoolean } from 'class-validator';
import { IsOptional } from '@/common/decorators/Validators';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class EditVendorDto extends ContactAddressDto { export class EditVendorDto extends ContactAddressDto {
@@ -60,4 +61,9 @@ export class EditVendorDto extends ContactAddressDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
active?: boolean; active?: boolean;
@ApiProperty({ required: false, description: 'Vendor code' })
@IsOptional()
@IsString()
code?: string;
} }
@@ -71,6 +71,8 @@ export class Vendor extends TenantBaseModel {
note: string; note: string;
active: boolean; active: boolean;
code?: string;
/** /**
* Query builder. * Query builder.
*/ */
@@ -31,6 +31,7 @@ export interface IVendorNewDTO extends IContactAddressDTO {
note?: string; note?: string;
active?: boolean; active?: boolean;
code?: string;
} }
export interface IVendorEditDTO extends IContactAddressDTO { export interface IVendorEditDTO extends IContactAddressDTO {
salutation?: string; salutation?: string;
@@ -46,6 +47,7 @@ export interface IVendorEditDTO extends IContactAddressDTO {
note?: string; note?: string;
active?: boolean; active?: boolean;
code?: string;
} }
export interface IVendorsFilter extends IDynamicListFilter { export interface IVendorsFilter extends IDynamicListFilter {
+12
View File
@@ -77,4 +77,16 @@ describe('Expenses (e2e)', () => {
.set('Authorization', AuthorizationHeader) .set('Authorization', AuthorizationHeader)
.expect(200); .expect(200);
}); });
it('/expenses (GET) honors page and pageSize query params', async () => {
const response = await request(app.getHttpServer())
.get('/expenses?page=2&pageSize=5')
.set('organization-id', orgainzationId)
.set('Authorization', AuthorizationHeader)
.expect(200);
expect(response.body.pagination).toBeDefined();
expect(response.body.pagination.page).toBe(2);
expect(response.body.pagination.page_size).toBe(5);
});
}); });
+2
View File
@@ -138,6 +138,8 @@
"build": "vite build", "build": "vite build",
"preview": "cross-env PORT=4173 vite preview", "preview": "cross-env PORT=4173 vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,md}\"",
"test": "node scripts/test.js", "test": "node scripts/test.js",
"storybook": "start-storybook -p 6006" "storybook": "start-storybook -p 6006"
}, },
@@ -4,6 +4,11 @@ import { FSelect } from '../Forms';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
export type DisplayNameListItem = { label: string }; export type DisplayNameListItem = { label: string };
type DisplayNameFormat = {
format: string;
values: Array<string | undefined>;
required: number[];
};
export interface DisplayNameListProps export interface DisplayNameListProps
extends Omit< extends Omit<
@@ -11,6 +16,47 @@ export interface DisplayNameListProps
'items' | 'valueAccessor' | 'textAccessor' | 'labelAccessor' 'items' | 'valueAccessor' | 'textAccessor' | 'labelAccessor'
> {} > {}
function useDisplayNameFormatOptions(
salutation?: string,
firstName?: string,
lastName?: string,
companyName?: string,
): DisplayNameListItem[] {
return useMemo(() => {
const formats: DisplayNameFormat[] = [
{
format: '{1} {2} {3}',
values: [salutation, firstName, lastName],
required: [1],
},
{ format: '{1} {2}', values: [firstName, lastName], required: [] },
{ format: '{1}, {2}', values: [firstName, lastName], required: [1, 2] },
{ format: '{1}', values: [companyName], required: [1] },
];
return formats
.filter(
(format) =>
!format.values.some((value, index) => {
return !value && format.required.indexOf(index + 1) !== -1;
}),
)
.map((formatOption) => {
const { format, values } = formatOption;
let label = format;
values.forEach((value, index) => {
const replaceWith = value || '';
label = label.replace(`{${index + 1}}`, replaceWith).trim();
});
return {
label: label.replace(/\s+/g, ' ').replace(/\s+,/g, ',').trim(),
};
})
.filter(({ label }) => Boolean(label));
}, [salutation, firstName, lastName, companyName]);
}
export function DisplayNameList({ ...restProps }: DisplayNameListProps) { export function DisplayNameList({ ...restProps }: DisplayNameListProps) {
const { const {
values: { values: {
@@ -21,40 +67,11 @@ export function DisplayNameList({ ...restProps }: DisplayNameListProps) {
}, },
} = useFormikContext<any>(); } = useFormikContext<any>();
const formats = useMemo( const formatOptions = useDisplayNameFormatOptions(
() => [ salutation,
{ firstName,
format: '{1} {2} {3}', lastName,
values: [salutation, firstName, lastName], companyName,
required: [1],
},
{ format: '{1} {2}', values: [firstName, lastName], required: [] },
{ format: '{1}, {2}', values: [firstName, lastName], required: [1, 2] },
{ format: '{1}', values: [companyName], required: [1] },
],
[firstName, lastName, companyName, salutation],
);
const formatOptions: DisplayNameListItem[] = useMemo(
() =>
formats
.filter(
(format) =>
!format.values.some((value, index) => {
return !value && format.required.indexOf(index + 1) !== -1;
}),
)
.map((formatOption) => {
const { format, values } = formatOption;
let label = format;
values.forEach((value, index) => {
const replaceWith = value || '';
label = label.replace(`{${index + 1}}`, replaceWith).trim();
});
return { label: label.replace(/\s+/g, ' ') };
}),
[formats],
); );
return ( return (
@@ -62,6 +79,7 @@ export function DisplayNameList({ ...restProps }: DisplayNameListProps) {
items={formatOptions} items={formatOptions}
valueAccessor={'label'} valueAccessor={'label'}
textAccessor={'label'} textAccessor={'label'}
labelAccessor={'_label'}
placeholder={intl.get('select_display_name_as')} placeholder={intl.get('select_display_name_as')}
filterable={false} filterable={false}
{...restProps} {...restProps}
@@ -28,6 +28,7 @@ export function SalutationList({ ...restProps }: SalutationListProps) {
items={items} items={items}
valueAccessor={'key'} valueAccessor={'key'}
textAccessor={'label'} textAccessor={'label'}
labelAccessor={'_label'}
placeholder={intl.get('salutation')} placeholder={intl.get('salutation')}
filterable={false} filterable={false}
{...restProps} {...restProps}
@@ -35,11 +35,16 @@ function UncategorizeBankTransactionsBulkAlert({
uncategorizeTransactions({ ids: uncategorizeTransactionsIds }) uncategorizeTransactions({ ids: uncategorizeTransactionsIds })
.then(() => { .then(() => {
AppToaster.show({ AppToaster.show({
message: 'The bank feeds of the bank account has been resumed.', message: 'The selected transactions have been uncategorized.',
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
}); });
}) })
.catch((error) => {}) .catch((error) => {
AppToaster.show({
message: 'Something went wrong while uncategorizing transactions.',
intent: Intent.DANGER,
});
})
.finally(() => { .finally(() => {
closeAlert(name); closeAlert(name);
}); });
@@ -1,153 +1,17 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { Row, Col } from '@/components'; import { Row } from '@/components';
import {
FormattedMessage as T,
FFormGroup,
FInputGroup,
FTextArea,
} from '@/components';
const CustomerBillingAddress = ({}) => { import CustomerBillingAddress from './CustomerBillingAddress';
import CustomerShippingAddress from './CustomerShippingAddress';
export default function CustomerAddressTabs() {
return ( return (
<div className={'tab-panel--address'}> <div className={'tab-panel--address'}>
<Row> <Row>
<Col xs={6}> <CustomerBillingAddress />
<h4> <CustomerShippingAddress />
<T id={'billing_address'} />
</h4>
{/*------------ Billing Address country -----------*/}
<FFormGroup
name={'billing_address_country'}
inline={true}
label={<T id={'country'} />}
>
<FInputGroup name={'billing_address_country'} />
</FFormGroup>
{/*------------ Billing Address 1 -----------*/}
<FFormGroup
name={'billing_address1'}
label={<T id={'address_line_1'} />}
inline={true}
>
<FTextArea name={'billing_address1'} />
</FFormGroup>
{/*------------ Billing Address 2 -----------*/}
<FFormGroup
name={'billing_address2'}
label={<T id={'address_line_2'} />}
inline={true}
>
<FTextArea name={'billing_address2'} />
</FFormGroup>
{/*------------ Billing Address city -----------*/}
<FFormGroup
name={'billing_address_city'}
label={<T id={'city_town'} />}
inline={true}
>
<FInputGroup name={'billing_address_city'} />
</FFormGroup>
{/*------------ Billing Address state -----------*/}
<FFormGroup
name={'billing_address_state'}
label={<T id={'state'} />}
inline={true}
>
<FInputGroup name={'billing_address_state'} />
</FFormGroup>
{/*------------ Billing Address postcode -----------*/}
<FFormGroup
name={'billing_address_postcode'}
label={<T id={'zip_code'} />}
inline={true}
>
<FInputGroup name={'billing_address_postcode'} />
</FFormGroup>
{/*------------ Billing Address phone -----------*/}
<FFormGroup
name={'billing_address_phone'}
label={<T id={'phone'} />}
inline={true}
>
<FInputGroup name={'billing_address_phone'} />
</FFormGroup>
</Col>
<Col xs={6}>
<h4>
<T id={'shipping_address'} />
</h4>
{/*------------ Shipping Address country -----------*/}
<FFormGroup
name={'shipping_address_country'}
label={<T id={'country'} />}
inline={true}
>
<FInputGroup name={'shipping_address_country'} />
</FFormGroup>
{/*------------ Shipping Address 1 -----------*/}
<FFormGroup
name={'shipping_address1'}
label={<T id={'address_line_1'} />}
inline={true}
>
<FTextArea name={'shipping_address1'} />
</FFormGroup>
{/*------------ Shipping Address 2 -----------*/}
<FFormGroup
name={'shipping_address2'}
label={<T id={'address_line_2'} />}
inline={true}
>
<FTextArea name={'shipping_address2'} />
</FFormGroup>
{/*------------ Shipping Address city -----------*/}
<FFormGroup
name={'shipping_address_city'}
label={<T id={'city_town'} />}
inline={true}
>
<FInputGroup name={'shipping_address_city'} />
</FFormGroup>
{/*------------ Shipping Address state -----------*/}
<FFormGroup
name={'shipping_address_state'}
label={<T id={'state'} />}
inline={true}
>
<FInputGroup name={'shipping_address_state'} />
</FFormGroup>
{/*------------ Shipping Address postcode -----------*/}
<FFormGroup
name={'shipping_address_postcode'}
label={<T id={'zip_code'} />}
inline={true}
>
<FInputGroup name={'shipping_address_postcode'} />
</FFormGroup>
{/*------------ Shipping Address phone -----------*/}
<FFormGroup
name={'shipping_address_phone'}
label={<T id={'phone'} />}
inline={true}
>
<FInputGroup name={'shipping_address_phone'} />
</FFormGroup>
</Col>
</Row> </Row>
</div> </div>
); );
}; }
export default CustomerBillingAddress;
@@ -0,0 +1,82 @@
// @ts-nocheck
import React from 'react';
import { Box } from '@/components';
import {
FormattedMessage as T,
FFormGroup,
FInputGroup,
FTextArea,
} from '@/components';
import { CustomerFormSectionTitle } from './CustomerFormSectionTitle';
export function CustomerBillingAddress() {
return (
<Box data-section-id="billingAddress">
<CustomerFormSectionTitle>
<T id={'billing_address'} />
</CustomerFormSectionTitle>
<FFormGroup
name={'billing_address_country'}
label={<T id={'country'} />}
inline
fill
>
<FInputGroup name={'billing_address_country'} fill />
</FFormGroup>
<FFormGroup
name={'billing_address1'}
label={<T id={'address_line_1'} />}
inline
fill
>
<FTextArea name={'billing_address1'} fill />
</FFormGroup>
<FFormGroup
name={'billing_address2'}
label={<T id={'address_line_2'} />}
inline
fill
>
<FTextArea name={'billing_address2'} fill />
</FFormGroup>
<FFormGroup
name={'billing_address_city'}
label={<T id={'city_town'} />}
inline
fill
>
<FInputGroup name={'billing_address_city'} fill />
</FFormGroup>
<FFormGroup
name={'billing_address_state'}
label={<T id={'state'} />}
inline
fill
>
<FInputGroup name={'billing_address_state'} fill />
</FFormGroup>
<FFormGroup
name={'billing_address_postcode'}
label={<T id={'zip_code'} />}
inline
fill
>
<FInputGroup name={'billing_address_postcode'} fill />
</FFormGroup>
<FFormGroup
name={'billing_address_phone'}
label={<T id={'phone'} />}
inline
fill
>
<FInputGroup name={'billing_address_phone'} fill />
</FFormGroup>
</Box>
);
}
@@ -1,4 +1,3 @@
// @ts-nocheck
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
@@ -11,52 +10,36 @@ import {
Menu, Menu,
MenuItem, MenuItem,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import classNames from 'classnames';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { Group, Icon, FormattedMessage as T } from '@/components'; import { Group, Icon, FormattedMessage as T } from '@/components';
import { CLASSES } from '@/constants/classes';
import { useCustomerFormContext } from './CustomerFormProvider'; import { useCustomerFormContext } from './CustomerFormProvider';
import { safeInvoke } from '@/utils';
/** export function CustomerFloatingActions() {
* Customer floating actions bar.
*/
export default function CustomerFloatingActions({ onCancel }) {
// Customer form context. // Customer form context.
const { isNewMode, setSubmitPayload } = useCustomerFormContext(); const { isNewMode, setSubmitPayload } = useCustomerFormContext() as {
isNewMode: boolean;
setSubmitPayload: (payload: { noRedirect: boolean }) => void;
};
// Formik context. // Formik context.
const { resetForm, submitForm, isSubmitting } = useFormikContext(); const { submitForm, isSubmitting } = useFormikContext();
// Handle submit button click. // Handle submit button click.
const handleSubmitBtnClick = (event) => { const handleSubmitBtnClick = (_event: React.MouseEvent<HTMLElement>) => {
setSubmitPayload({ noRedirect: false }); setSubmitPayload({ noRedirect: false });
}; };
// Handle cancel button click.
const handleCancelBtnClick = (event) => {
safeInvoke(onCancel, event);
};
// handle clear button clicl.
const handleClearBtnClick = (event) => {
resetForm();
};
// Handle submit & new button click. // Handle submit & new button click.
const handleSubmitAndNewClick = (event) => { const handleSubmitAndNewClick = (_event: React.MouseEvent<HTMLElement>) => {
submitForm(); submitForm();
setSubmitPayload({ noRedirect: true }); setSubmitPayload({ noRedirect: true });
}; };
return ( return (
<Group <FloatingActionsGroup spacing={10}>
spacing={10}
className={classNames(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}
>
<ButtonGroup> <ButtonGroup>
{/* ----------- Save and New ----------- */} {/* ----------- Save and New ----------- */}
<SaveButton <Button
disabled={isSubmitting} disabled={isSubmitting}
loading={isSubmitting} loading={isSubmitting}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
@@ -73,9 +56,9 @@ export default function CustomerFloatingActions({ onCancel }) {
/> />
</Menu> </Menu>
} }
minimal={true}
interactionKind={PopoverInteractionKind.CLICK} interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT} position={Position.BOTTOM_RIGHT}
minimal
> >
<Button <Button
disabled={isSubmitting} disabled={isSubmitting}
@@ -84,24 +67,16 @@ export default function CustomerFloatingActions({ onCancel }) {
/> />
</Popover> </Popover>
</ButtonGroup> </ButtonGroup>
</FloatingActionsGroup>
{/* ----------- Clear & Reset----------- */}
<Button
className={'ml1'}
disabled={isSubmitting}
onClick={handleClearBtnClick}
text={!isNewMode ? <T id={'reset'} /> : <T id={'clear'} />}
/>
{/* ----------- Cancel ----------- */}
<Button
className={'ml1'}
onClick={handleCancelBtnClick}
text={<T id={'cancel'} />}
/>
</Group>
); );
} }
const SaveButton = styled(Button)` const FloatingActionsGroup = styled(Group)`
min-width: 100px; padding: 10px 0;
`; padding-left: 165px;
border-top: 1px solid #50555a;
position: sticky;
bottom: 0;
background: var(--color-card-background);
z-index: 1;
`;
@@ -1,15 +0,0 @@
// @ts-nocheck
import React from 'react';
import { CustomerFormProvider } from './CustomerFormProvider';
import CustomerFormFormik from './CustomerFormFormik';
/**
* Abstracted customer form.
*/
export default function CustomerForm({ customerId }) {
return (
<CustomerFormProvider customerId={customerId}>
<CustomerFormFormik />
</CustomerFormProvider>
);
}
@@ -6,34 +6,46 @@ import { FormattedMessage as T, FFormGroup, FInputGroup } from '@/components';
export default function CustomerFormAfterPrimarySection({}) { export default function CustomerFormAfterPrimarySection({}) {
return ( return (
<div className={'customer-form__after-primary-section-content'}> <div>
{/*------------ Customer email -----------*/} {/*------------ Customer email -----------*/}
<FFormGroup <FFormGroup
name={'email'} name={'email'}
label={<T id={'customer_email'} />} label={<T id={'customer_email'} />}
inline={true} inline
fill
> >
<FInputGroup name={'email'} /> <FInputGroup name={'email'} fill />
</FFormGroup> </FFormGroup>
{/*------------ Phone number -----------*/} {/*------------ Phone number -----------*/}
<FFormGroup <FFormGroup
name={'personal_phone'} name={'personal_phone'}
label={<T id={'phone_number'} />} label={<T id={'phone_number'} />}
inline={true} inline
fill
> >
<ControlGroup> <ControlGroup fill>
<FInputGroup <FInputGroup
name={'personal_phone'} name={'personal_phone'}
placeholder={intl.get('personal')} placeholder={intl.get('personal')}
fill
/>
<FInputGroup
name={'work_phone'}
placeholder={intl.get('work')}
fill
/> />
<FInputGroup name={'work_phone'} placeholder={intl.get('work')} />
</ControlGroup> </ControlGroup>
</FFormGroup> </FFormGroup>
{/*------------ Customer website -----------*/} {/*------------ Customer website -----------*/}
<FFormGroup name={'website'} label={<T id={'website'} />} inline={true}> <FFormGroup
<FInputGroup name={'website'} placeholder={'http://'} /> name={'website'}
label={<T id={'website'} />}
inline
fill
>
<FInputGroup name={'website'} placeholder={'http://'} fill />
</FFormGroup> </FFormGroup>
</div> </div>
); );
@@ -0,0 +1,138 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import { ControlGroup, Divider, Icon as BlueprintIcon } from '@blueprintjs/core';
import {
Hint,
FieldRequiredHint,
SalutationList,
DisplayNameList,
FormattedMessage as T,
FInputGroup,
FFormGroup,
Box,
Icon,
Stack,
} from '@/components';
import { CustomerTypeRadioField } from './CustomerTypeRadioField';
import { CustomerFormSectionTitle } from './CustomerFormSectionTitle';
import { useAutofocus } from '@/hooks';
export function CustomerFormBasicSection({}) {
const firstNameFieldRef = useAutofocus();
return (
<Box data-section-id="primary">
<CustomerFormSectionTitle>Customer details</CustomerFormSectionTitle>
{/**-----------Customer type. -----------*/}
<CustomerTypeRadioField />
{/**----------- Contact name -----------*/}
<FFormGroup
name={'salutation'}
label={<T id={'contact_name'} />}
inline
fill
>
<ControlGroup fill>
<SalutationList
name={'salutation'}
popoverProps={{ minimal: true }}
/>
<FInputGroup
name={'first_name'}
placeholder={intl.get('first_name')}
inputRef={(ref) => (firstNameFieldRef.current = ref)}
fill
/>
<FInputGroup
name={'last_name'}
placeholder={intl.get('last_name')}
fill
/>
</ControlGroup>
</FFormGroup>
<FFormGroup
name={'code'}
label={'Customer Code'}
helperText="Add a unique account number to identify, reference and search for the contact."
inline
fill
>
<FInputGroup
name={'code'}
fill />
</FFormGroup>
{/*----------- Company Name -----------*/}
<FFormGroup
name={'company_name'}
label={<T id={'company_name'} />}
inline
fill
>
<FInputGroup name={'company_name'} fill />
</FFormGroup>
{/*----------- Display Name -----------*/}
<FFormGroup
name={'display_name'}
label={<T id={'display_name'} />}
helperText="This is the name that appears on invoices and emails."
inline
fill
>
<DisplayNameList
name={'display_name'}
popoverProps={{ minimal: true }}
buttonProps={{ fill: true }}
/>
</FFormGroup>
<Divider style={{ margin: '20px 0' }} />
{/*------------ Vendor email -----------*/}
<FFormGroup
name={'email'}
label={<T id={'vendor_email'} />}
inline
>
<FInputGroup
name={'email'}
leftIcon={<Icon icon="envelope" />}
/>
</FFormGroup>
{/*------------ Phone number -----------*/}
<FFormGroup
name={'work_phone'}
className={'form-group--phone-number'}
label={<T id={'phone_number'} />}
inline={true}
>
<Stack spacing={10}>
<FInputGroup
name={'work_phone'}
placeholder={intl.get('work')}
leftIcon="phone"
/>
<FInputGroup
name={'personal_phone'}
placeholder={intl.get('mobile')}
/>
</Stack>
</FFormGroup>
{/*------------ Vendor website -----------*/}
<FFormGroup name={'website'} label={<T id={'website'} />} inline={true}>
<FInputGroup
name={'website'}
placeholder={'http://'}
leftIcon={<BlueprintIcon icon="globe-network" />}
/>
</FFormGroup>
</Box>
);
}
@@ -0,0 +1,45 @@
import { Tab } from "@blueprintjs/core";
import { Card, Group } from "@/components";
import { Tabs } from "@blueprintjs/core";
import { useState } from "react";
import { css } from '@emotion/css';
import { CustomerFloatingActions } from "./CustomerFloatingActions";
import { CustomerFormSections } from "./CustomerFormFields";
export function CustomerFormContent() {
const [selectedTabId, setSelectedTabId] = useState('primary');
const handleTabChange = (tabId: string) => {
const sectionId = String(tabId);
setSelectedTabId(sectionId);
const section = document.querySelector(
`[data-section-id="${sectionId}"]`,
);
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<Card className={css`padding-bottom: 0 !important;`}>
<Group verticalAlign={'top'} alignItems={'flex-start'} flexWrap={'nowrap'}>
<Tabs
selectedTabId={selectedTabId}
onChange={handleTabChange}
className={css`position: sticky; top: 20px;`}
vertical
>
<Tab id={'primary'} title={'Basic'} />
<Tab id={'financial'} title={'Financial'} />
<Tab id={'billingAddress'} title={'Billing address'} />
<Tab id={'shippingAddress'} title={'Shipping address'} />
<Tab id={'notes'} title={'Notes'} />
</Tabs>
<CustomerFormSections />
</Group>
<CustomerFloatingActions />
</Card>
)
}
@@ -0,0 +1,33 @@
import { Divider } from '@blueprintjs/core';
import { css } from '@emotion/css';
import { Box } from '@/components';
import { CustomerFormBasicSection } from './CustomerFormBasicSection';
import { CustomerFormFinancialSection } from './CustomerFormFinancialSection';
import { CustomerBillingAddress } from './CustomerBillingAddress';
import { CustomerShippingAddress } from './CustomerShippingAddress';
import { CustomerFormNotesSection } from './CustomerFormNotesSection';
const customerFormSectionDividerClass = css`
margin: 20px 0;
`;
export function CustomerFormSections() {
return (
<Box>
<CustomerFormBasicSection />
<Divider className={customerFormSectionDividerClass} />
<CustomerFormFinancialSection />
<Divider className={customerFormSectionDividerClass} />
<CustomerBillingAddress />
<Divider className={customerFormSectionDividerClass} />
<CustomerShippingAddress />
<Divider className={customerFormSectionDividerClass} />
<CustomerFormNotesSection />
</Box>
);
}
@@ -1,8 +1,7 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import { Position, ControlGroup } from '@blueprintjs/core';
import { FormGroup, Position, Classes, ControlGroup } from '@blueprintjs/core'; import { ErrorMessage, useFormikContext } from 'formik';
import { FastField, ErrorMessage, useFormikContext } from 'formik';
import { Features } from '@/constants'; import { Features } from '@/constants';
import { import {
FFormGroup, FFormGroup,
@@ -11,11 +10,11 @@ import {
CurrencySelectList, CurrencySelectList,
BranchSelect, BranchSelect,
FeatureCan, FeatureCan,
Row,
Col,
FMoneyInputGroup, FMoneyInputGroup,
ExchangeRateInputGroup, ExchangeRateInputGroup,
FDateInput, FDateInput,
Icon,
Box,
} from '@/components'; } from '@/components';
import { useCustomerFormContext } from './CustomerFormProvider'; import { useCustomerFormContext } from './CustomerFormProvider';
import { import {
@@ -24,67 +23,56 @@ import {
useSetPrimaryBranchToForm, useSetPrimaryBranchToForm,
} from './utils'; } from './utils';
import { useCurrentOrganization } from '@/hooks/state'; import { useCurrentOrganization } from '@/hooks/state';
import { CustomerFormSectionTitle } from './CustomerFormSectionTitle';
/** export function CustomerFormFinancialSection() {
* Customer financial panel.
*/
export default function CustomerFinancialPanel() {
const { currencies, customerId, branches } = useCustomerFormContext(); const { currencies, customerId, branches } = useCustomerFormContext();
// Sets the primary branch to form. // Sets the primary branch to form.
useSetPrimaryBranchToForm(); useSetPrimaryBranchToForm();
return ( return (
<div className={'tab-panel--financial'}> <Box data-section-id="financial">
<Row> <CustomerFormSectionTitle>
<Col xs={6}> <T id={'financial'} />
{/*------------ Currency -----------*/} </CustomerFormSectionTitle>
<FFormGroup <FFormGroup
name={'currency_code'} name={'currency_code'}
label={<T id={'currency'} />} label={<T id={'currency'} />}
fastField fastField
inline inline
> fill
>
<CurrencySelectList <CurrencySelectList
name="currency_code" name="currency_code"
items={currencies} items={currencies}
disabled={customerId} disabled={customerId}
/> />
</FFormGroup> </FFormGroup>
{/*------------ Opening balance -----------*/}
<CustomerOpeningBalanceField /> <CustomerOpeningBalanceField />
{/*------ Opening Balance Exchange Rate -----*/}
<CustomerOpeningBalanceExchangeRateField /> <CustomerOpeningBalanceExchangeRateField />
{/*------------ Opening balance at -----------*/}
<CustomerOpeningBalanceAtField /> <CustomerOpeningBalanceAtField />
{/*------------ Opening branch -----------*/}
<FeatureCan feature={Features.Branches}> <FeatureCan feature={Features.Branches}>
<FFormGroup <FFormGroup
label={<T id={'customer.label.opening_branch'} />} label={<T id={'customer.label.opening_branch'} />}
name={'opening_balance_branch_id'} name={'opening_balance_branch_id'}
inline={true} inline
> >
<BranchSelect <BranchSelect
name={'opening_balance_branch_id'} name={'opening_balance_branch_id'}
branches={branches} branches={branches}
popoverProps={{ minimal: true }} popoverProps={{ minimal: true }}
fastField
/> />
</FFormGroup> </FFormGroup>
</FeatureCan> </FeatureCan>
</Col> </Box>
</Row>
</div>
); );
} }
/**
* Customer opening balance at date field.
* @returns {JSX.Element}
*/
function CustomerOpeningBalanceAtField() { function CustomerOpeningBalanceAtField() {
const { customerId } = useCustomerFormContext(); const { customerId } = useCustomerFormContext();
@@ -92,10 +80,11 @@ function CustomerOpeningBalanceAtField() {
if (customerId) return null; if (customerId) return null;
return ( return (
<FormGroup <FFormGroup
name={'opening_balance_at'} name={'opening_balance_at'}
label={<T id={'opening_balance_at'} />} label={<T id={'opening_balance_at'} />}
inline={true} inline
fill
helperText={<ErrorMessage name="opening_balance_at" />} helperText={<ErrorMessage name="opening_balance_at" />}
> >
<FDateInput <FDateInput
@@ -104,16 +93,15 @@ function CustomerOpeningBalanceAtField() {
disabled={customerId} disabled={customerId}
formatDate={(date) => date.toLocaleDateString()} formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)} parseDate={(str) => new Date(str)}
inputProps={{
leftIcon: <Icon icon={'date-range'} />,
}}
fill={true} fill={true}
/> />
</FormGroup> </FFormGroup>
); );
} }
/**
* Customer opening balance field.
* @returns {JSX.Element}
*/
function CustomerOpeningBalanceField() { function CustomerOpeningBalanceField() {
const { customerId } = useCustomerFormContext(); const { customerId } = useCustomerFormContext();
const { values } = useFormikContext(); const { values } = useFormikContext();
@@ -125,15 +113,17 @@ function CustomerOpeningBalanceField() {
<FFormGroup <FFormGroup
label={<T id={'opening_balance'} />} label={<T id={'opening_balance'} />}
name={'opening_balance'} name={'opening_balance'}
inline={true} inline
shouldUpdate={openingBalanceFieldShouldUpdate} shouldUpdate={openingBalanceFieldShouldUpdate}
shouldUpdateDeps={{ currencyCode: values.currency_code }} shouldUpdateDeps={{ currencyCode: values.currency_code }}
fastField={true} fastField={true}
fill
> >
<ControlGroup> <ControlGroup>
<InputPrependText text={values.currency_code} /> <InputPrependText text={values.currency_code as string} />
<FMoneyInputGroup <FMoneyInputGroup
name={'opening_balance'} name={'opening_balance'}
fastField
inputGroupProps={{ fill: true }} inputGroupProps={{ fill: true }}
/> />
</ControlGroup> </ControlGroup>
@@ -141,11 +131,6 @@ function CustomerOpeningBalanceField() {
); );
} }
/**
* Customer opening balance exchange rate field if the customer has foreign
* currency.
* @returns {JSX.Element}
*/
function CustomerOpeningBalanceExchangeRateField() { function CustomerOpeningBalanceExchangeRateField() {
const { values } = useFormikContext(); const { values } = useFormikContext();
const { customerId } = useCustomerFormContext(); const { customerId } = useCustomerFormContext();
@@ -158,16 +143,14 @@ function CustomerOpeningBalanceExchangeRateField() {
return null; return null;
} }
return ( return (
<FFormGroup
label={' '}
name={'opening_balance_exchange_rate'}
inline={true}
>
<ExchangeRateInputGroup <ExchangeRateInputGroup
fromCurrency={values.currency_code} fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency} toCurrency={currentOrganization.base_currency}
name={'opening_balance_exchange_rate'} name={'opening_balance_exchange_rate'}
onRecalcConfirm={() => {}}
onCancel={() => {}}
formGroupProps={{ label: ' ' }}
/> />
</FFormGroup>
); );
} }
@@ -1,40 +1,95 @@
// @ts-nocheck import { useMemo } from 'react';
import React, { useMemo } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import classNames from 'classnames'; import { Formik, Form, FormikHelpers } from 'formik';
import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import styled from 'styled-components'; import styled from 'styled-components';
import { CLASSES } from '@/constants/classes';
import { CreateCustomerForm, EditCustomerForm } from './CustomerForm.schema'; import { CreateCustomerForm, EditCustomerForm } from './CustomerForm.schema';
import { compose, transformToForm, saveInvoke, parseBoolean } from '@/utils'; import { compose, transformToForm, saveInvoke, parseBoolean } from '@/utils';
import { useCustomerFormContext } from './CustomerFormProvider'; import { useCustomerFormContext } from './CustomerFormProvider';
import { defaultInitialValues } from './utils'; import { defaultInitialValues } from './utils';
import { AppToaster } from '@/components'; import { AppToaster } from '@/components';
import CustomerFormPrimarySection from './CustomerFormPrimarySection';
import CustomerFormAfterPrimarySection from './CustomerFormAfterPrimarySection';
import CustomersTabs from './CustomersTabs';
import CustomerFloatingActions from './CustomerFloatingActions';
import { withCurrentOrganization } from '@/containers/Organization/withCurrentOrganization'; import { withCurrentOrganization } from '@/containers/Organization/withCurrentOrganization';
import { CustomerFormContent } from './CustomerFormContent';
import '@/style/pages/Customers/Form.scss'; type CustomerFormValues = {
customer_type: string;
salutation: string;
first_name: string;
last_name: string;
company_name: string;
display_name: string;
/** email?: string;
* Customer form. work_phone?: string;
*/ personal_phone?: string;
function CustomerFormFormik({ website?: string;
note?: string;
active: boolean | string;
billing_address_country: string;
billing_address1: string;
billing_address2: string;
billing_address_city: string;
billing_address_state: string;
billing_address_postcode?: string;
billing_address_phone?: string;
shipping_address_country: string;
shipping_address1: string;
shipping_address2: string;
shipping_address_city: string;
shipping_address_state: string;
shipping_address_postcode?: string;
shipping_address_phone?: string;
currency_code: string;
opening_balance?: string | number;
opening_balance_at?: string;
opening_balance_exchange_rate?: string;
opening_balance_branch_id?: string;
[key: string]: any;
};
type CustomerFormSubmitPayload = {
noRedirect?: boolean;
};
type CustomerFormFormikRootProps = {
organization: {
base_currency: string;
};
// #ownProps
initialValues?: Partial<CustomerFormValues>;
onSubmitSuccess?: (
values: CustomerFormValues,
formArgs: FormikHelpers<CustomerFormValues>,
submitPayload: CustomerFormSubmitPayload,
responseData?: unknown,
) => void;
onSubmitError?: (
values: CustomerFormValues,
formArgs: FormikHelpers<CustomerFormValues>,
submitPayload: CustomerFormSubmitPayload,
errorData?: unknown,
) => void;
onCancel?: () => void;
className?: string;
};
const EMPTY_INITIAL_VALUES: Partial<CustomerFormValues> = {};
function CustomerFormFormikRoot({
organization: { base_currency }, organization: { base_currency },
// #ownProps // #ownProps
initialValues: initialCustomerValues, initialValues: initialCustomerValues = EMPTY_INITIAL_VALUES,
onSubmitSuccess, onSubmitSuccess,
onSubmitError, onSubmitError,
onCancel, // `onCancel` is accepted for compatibility but currently not used.
className, className,
}) { }: CustomerFormFormikRootProps) {
const { const {
customer, customer,
submitPayload, submitPayload,
@@ -44,28 +99,28 @@ function CustomerFormFormik({
isNewMode, isNewMode,
} = useCustomerFormContext(); } = useCustomerFormContext();
/** const initialValues = useMemo<CustomerFormValues>(
* Initial values in create and edit mode.
*/
const initialValues = useMemo(
() => ({ () => ({
...defaultInitialValues, ...defaultInitialValues,
currency_code: base_currency, currency_code: base_currency,
...transformToForm(contactDuplicate || customer, defaultInitialValues), ...transformToForm(contactDuplicate ?? customer ?? {}, defaultInitialValues),
...transformToForm(initialCustomerValues, defaultInitialValues), ...transformToForm(initialCustomerValues, defaultInitialValues),
}), }) as CustomerFormValues,
[customer, contactDuplicate, base_currency, initialCustomerValues], [customer, contactDuplicate, base_currency, initialCustomerValues],
); );
// Handles the form submit. // Handles the form submit.
const handleFormSubmit = (values, formArgs) => { const handleFormSubmit = (
values: CustomerFormValues,
formArgs: FormikHelpers<CustomerFormValues>,
) => {
const { setSubmitting, resetForm } = formArgs; const { setSubmitting, resetForm } = formArgs;
const formValues = { const formValues = {
...values, ...values,
active: parseBoolean(values.active, true), active: parseBoolean(values.active, true),
}; };
const onSuccess = (res) => { const onSuccess = (res: { data?: unknown }) => {
AppToaster.show({ AppToaster.show({
message: intl.get( message: intl.get(
isNewMode isNewMode
@@ -83,60 +138,40 @@ function CustomerFormFormik({
setSubmitting(false); setSubmitting(false);
saveInvoke(onSubmitError, values, formArgs, submitPayload); saveInvoke(onSubmitError, values, formArgs, submitPayload);
}; };
if (isNewMode) { if (isNewMode) {
createCustomerMutate(formValues).then(onSuccess).catch(onError); createCustomerMutate(formValues).then(onSuccess).catch(onError);
} else { } else {
editCustomerMutate([customer.id, formValues]) if (!customer) return;
.then(onSuccess) editCustomerMutate([customer.id, formValues]).then(onSuccess).catch(onError);
.catch(onError);
} }
}; };
return ( return (
<div <Formik<CustomerFormValues>
className={classNames(
CLASSES.PAGE_FORM,
CLASSES.PAGE_FORM_CUSTOMER,
className,
)}
>
<Formik
validationSchema={isNewMode ? CreateCustomerForm : EditCustomerForm} validationSchema={isNewMode ? CreateCustomerForm : EditCustomerForm}
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
> >
<Form> <Form>
<CustomerFormHeaderPrimary> <CustomerFormFields>
<CustomerFormPrimarySection /> <CustomerFormContent />
</CustomerFormHeaderPrimary> </CustomerFormFields>
<div className={'page-form__after-priamry-section'}>
<CustomerFormAfterPrimarySection />
</div>
<div className={classNames(CLASSES.PAGE_FORM_TABS)}>
<CustomersTabs />
</div>
<CustomerFloatingActions onCancel={onCancel} />
</Form> </Form>
</Formik> </Formik>
</div>
); );
} }
export const CustomerFormHeaderPrimary = styled.div` const CustomerFormFields = styled.div`
--x-border: #e4e4e4; .bp4-form-content,
.bp6-form-content {
.bp4-dark & { min-width: 300px;
--x-border: var(--color-dark-gray3); }
.bp4-form-group{
margin-bottom: 20px;
}
.bp4-form-group.bp4-inline label.bp4-label {
min-width: 140px;
} }
padding: 10px 0 0;
margin: 0 0 20px;
overflow: hidden;
border-bottom: 1px solid var(--x-border);
max-width: 1000px;
`; `;
export default compose(withCurrentOrganization())(CustomerFormFormik); export const CustomerFormFormik = compose(withCurrentOrganization(undefined))(CustomerFormFormikRoot);
@@ -0,0 +1,16 @@
import { Box, FFormGroup, FormattedMessage as T, FTextArea } from '@/components';
import { CustomerFormSectionTitle } from './CustomerFormSectionTitle';
export function CustomerFormNotesSection() {
return (
<Box data-section-id="notes">
<CustomerFormSectionTitle>
<T id={'notes'} />
</CustomerFormSectionTitle>
<FFormGroup name={'note'} label={<T id={'note'} />} inline>
<FTextArea name={'note'} fill />
</FFormGroup>
</Box>
);
}
@@ -2,73 +2,53 @@
import React from 'react'; import React from 'react';
import { useParams, useHistory } from 'react-router-dom'; import { useParams, useHistory } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { Box, DashboardCard, DashboardInsider } from '@/components';
import { DashboardCard, DashboardInsider } from '@/components'; import { CustomerFormFormik, ustomerFormFormik } from './CustomerFormFormik';
import CustomerFormFormik from './CustomerFormFormik';
import { import {
CustomerFormProvider, CustomerFormProvider,
useCustomerFormContext, useCustomerFormContext,
} from './CustomerFormProvider'; } from './CustomerFormProvider';
/**
* Customer form page loading.
* @returns {JSX}
*/
function CustomerFormPageLoading({ children }) {
const { isFormLoading } = useCustomerFormContext();
return (
<CustomerDashboardInsider loading={isFormLoading}>
{children}
</CustomerDashboardInsider>
);
}
/** /**
* Customer form page. * Customer form page.
* @returns {JSX} * @returns {JSX}
*/ */
export default function CustomerFormPage() { export default function CustomerFormPage() {
const history = useHistory();
const { id } = useParams(); const { id } = useParams();
const customerId = parseInt(id, 10); const customerId = parseInt(id, 10);
// Handle the form submit success.
const handleSubmitSuccess = (values, formArgs, submitPayload) => {
if (!submitPayload.noRedirect) {
history.push('/customers');
}
};
// Handle the form cancel button click.
const handleFormCancel = () => {
history.goBack();
};
return ( return (
<CustomerFormProvider customerId={customerId}> <CustomerFormProvider customerId={customerId}>
<CustomerFormPageLoading> <CustomerFormPageContent />
<DashboardCard page>
<CustomerFormPageFormik
onSubmitSuccess={handleSubmitSuccess}
onCancel={handleFormCancel}
/>
</DashboardCard>
</CustomerFormPageLoading>
</CustomerFormProvider> </CustomerFormProvider>
); );
} }
const CustomerFormPageFormik = styled(CustomerFormFormik)` function CustomerFormPageContent() {
.page-form { const history = useHistory();
&__floating-actions { const { isFormLoading } = useCustomerFormContext();
margin-left: -40px;
margin-right: -40px;
const handleSubmitSuccess = (values, formArgs, submitPayload) => {
if (!submitPayload.noRedirect) {
history.push('/customers');
} }
} }
`;
const CustomerDashboardInsider = styled(DashboardInsider)` // Handle the form cancel button click.
padding-bottom: 64px; const handleFormCancel = () => {
`; history.goBack();
};
return (
<DashboardInsider loading={isFormLoading}>
<Box mx={'auto'} maxWidth={800}>
<CustomerFormFormik
onSubmitSuccess={handleSubmitSuccess}
onCancel={handleFormCancel}
/>
</Box>
</DashboardInsider>
)
}
@@ -1,80 +0,0 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import classNames from 'classnames';
import { FormGroup, InputGroup, ControlGroup } from '@blueprintjs/core';
import { FastField, Field, ErrorMessage } from 'formik';
import {
Hint,
FieldRequiredHint,
SalutationList,
DisplayNameList,
FormattedMessage as T,
FInputGroup,
FFormGroup,
} from '@/components';
import CustomerTypeRadioField from './CustomerTypeRadioField';
import { CLASSES } from '@/constants/classes';
import { inputIntent } from '@/utils';
import { useAutofocus } from '@/hooks';
/**
* Customer form primary section.
*/
export default function CustomerFormPrimarySection({}) {
const firstNameFieldRef = useAutofocus();
return (
<div className={'customer-form__primary-section-content'}>
{/**-----------Customer type. -----------*/}
<CustomerTypeRadioField />
{/**----------- Contact name -----------*/}
<FFormGroup
name={'salutation'}
label={<T id={'contact_name'} />}
inline={true}
>
<ControlGroup>
<SalutationList
name={'salutation'}
popoverProps={{ minimal: true }}
/>
<FInputGroup
name={'first_name'}
placeholder={intl.get('first_name')}
inputRef={(ref) => (firstNameFieldRef.current = ref)}
/>
<FInputGroup name={'last_name'} placeholder={intl.get('last_name')} />
</ControlGroup>
</FFormGroup>
{/*----------- Company Name -----------*/}
<FFormGroup
name={'company_name'}
label={<T id={'company_name'} />}
inline={true}
>
<FInputGroup name={'company_name'} />
</FFormGroup>
{/*----------- Display Name -----------*/}
<FFormGroup
name={'display_name'}
label={
<>
<T id={'display_name'} />
<FieldRequiredHint />
<Hint />
</>
}
inline={true}
>
<DisplayNameList
name={'display_name'}
popoverProps={{ minimal: true }}
/>
</FFormGroup>
</div>
);
}
@@ -1,5 +1,4 @@
// @ts-nocheck import React, { createContext, useState } from 'react';
import React, { useState, createContext } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { import {
useCustomer, useCustomer,
@@ -12,10 +11,60 @@ import {
import { Features } from '@/constants'; import { Features } from '@/constants';
import { useFeatureCan } from '@/hooks/state'; import { useFeatureCan } from '@/hooks/state';
const CustomerFormContext = createContext(); type CustomerFormSubmitPayload = {
noRedirect?: boolean;
};
function CustomerFormProvider({ query, customerId, ...props }) { type Customer = {
const { state } = useLocation(); id: number;
[key: string]: any;
};
type Currency = {
currency_code: string;
[key: string]: any;
};
type Branch = {
id: number;
primary?: boolean;
[key: string]: any;
};
type CustomerFormContextValue = {
customerId?: number;
customer?: Customer;
currencies: Currency[];
branches: Branch[];
contactDuplicate?: Customer;
submitPayload: CustomerFormSubmitPayload;
isNewMode: boolean;
isCustomerLoading: boolean;
isCurrenciesLoading: boolean;
isBranchesSuccess: boolean;
isFormLoading: boolean;
setSubmitPayload: React.Dispatch<
React.SetStateAction<CustomerFormSubmitPayload>
>;
editCustomerMutate: (args: [number, any]) => Promise<any>;
createCustomerMutate: (values: any) => Promise<any>;
};
type CustomerFormProviderProps = {
query?: unknown;
customerId?: number;
children?: React.ReactNode;
};
const CustomerFormContext = createContext<CustomerFormContextValue | undefined>(
undefined,
);
export function CustomerFormProvider({ query, customerId, children }: CustomerFormProviderProps) {
const { state } = useLocation<{ action?: number | string }>();
const contactId = state?.action; const contactId = state?.action;
// Features guard. // Features guard.
@@ -33,7 +82,7 @@ function CustomerFormProvider({ query, customerId, ...props }) {
{ enabled: !!contactId }, { enabled: !!contactId },
); );
// Handle fetch Currencies data table // Handle fetch Currencies data table
const { data: currencies, isLoading: isCurrenciesLoading } = useCurrencies(); const { data: currencies, isLoading: isCurrenciesLoading } = useCurrencies(undefined);
// Fetches the branches list. // Fetches the branches list.
const { const {
@@ -43,23 +92,26 @@ function CustomerFormProvider({ query, customerId, ...props }) {
} = useBranches(query, { enabled: isBranchFeatureCan }); } = useBranches(query, { enabled: isBranchFeatureCan });
// Form submit payload. // Form submit payload.
const [submitPayload, setSubmitPayload] = useState({}); const [submitPayload, setSubmitPayload] = useState<CustomerFormSubmitPayload>({});
const { mutateAsync: editCustomerMutate } = useEditCustomer(); const editCustomerMutation = useEditCustomer(undefined) as any;
const { mutateAsync: createCustomerMutate } = useCreateCustomer(); const createCustomerMutation = useCreateCustomer(undefined) as any;
const editCustomerMutate = editCustomerMutation.mutateAsync as CustomerFormContextValue['editCustomerMutate'];
const createCustomerMutate =
createCustomerMutation.mutateAsync as CustomerFormContextValue['createCustomerMutate'];
// determines whether the form new or duplicate mode. // determines whether the form new or duplicate mode.
const isNewMode = contactId || !customerId; const isNewMode = Boolean(contactId) || !customerId;
const isFormLoading = const isFormLoading =
isCustomerLoading || isCurrenciesLoading || isBranchesLoading; isCustomerLoading || isCurrenciesLoading || isBranchesLoading;
const provider = { const provider: CustomerFormContextValue = {
customerId, customerId,
customer, customer: customer as Customer | undefined,
currencies, currencies: (currencies as Currency[]) ?? [],
branches, branches: (branches as Branch[]) ?? [],
contactDuplicate, contactDuplicate: contactDuplicate as Customer | undefined,
submitPayload, submitPayload,
isNewMode, isNewMode,
@@ -73,9 +125,19 @@ function CustomerFormProvider({ query, customerId, ...props }) {
createCustomerMutate, createCustomerMutate,
}; };
return <CustomerFormContext.Provider value={provider} {...props} />; return (
<CustomerFormContext.Provider value={provider}>
{children}
</CustomerFormContext.Provider>
);
} }
const useCustomerFormContext = () => React.useContext(CustomerFormContext); export const useCustomerFormContext = () => {
const ctx = React.useContext(CustomerFormContext);
export { CustomerFormProvider, useCustomerFormContext }; if (!ctx) {
throw new Error(
'useCustomerFormContext must be used within a CustomerFormProvider',
);
}
return ctx;
};
@@ -0,0 +1,13 @@
import React from 'react';
import { css } from '@emotion/css';
const customerFormSectionTitleClass = css`
font-size: 14px;
color: #8f99a8;
margin-bottom: 18px;
margin-top: 10px;
`;
export function CustomerFormSectionTitle({ children }: { children: React.ReactNode | string }) {
return <h4 className={customerFormSectionTitleClass}>{children}</h4>;
}
@@ -1,15 +1,11 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import { Classes } from '@blueprintjs/core';
import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components'; import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components';
export default function CustomerNotePanel({ errors, touched, getFieldProps }) { export default function CustomerNotePanel({ errors, touched, getFieldProps }) {
return ( return (
<div className={'tab-panel--note'}> <FFormGroup name={'note'} label={<T id={'note'} />} inline={false} fill>
<FFormGroup name={'note'} label={<T id={'note'} />} inline={false}> <FTextArea name={'note'} fill />
<FTextArea name={'note'} /> </FFormGroup>
</FFormGroup>
</div>
); );
} }
@@ -0,0 +1,82 @@
// @ts-nocheck
import React from 'react';
import { Box } from '@/components';
import {
FormattedMessage as T,
FFormGroup,
FInputGroup,
FTextArea,
} from '@/components';
import { CustomerFormSectionTitle } from './CustomerFormSectionTitle';
export function CustomerShippingAddress() {
return (
<Box data-section-id="shippingAddress">
<CustomerFormSectionTitle>
<T id={'shipping_address'} />
</CustomerFormSectionTitle>
<FFormGroup
name={'shipping_address_country'}
label={<T id={'country'} />}
inline
fill
>
<FInputGroup name={'shipping_address_country'} fill />
</FFormGroup>
<FFormGroup
name={'shipping_address1'}
label={<T id={'address_line_1'} />}
inline
fill
>
<FTextArea name={'shipping_address1'} fill />
</FFormGroup>
<FFormGroup
name={'shipping_address2'}
label={<T id={'address_line_2'} />}
inline
fill
>
<FTextArea name={'shipping_address2'} fill />
</FFormGroup>
<FFormGroup
name={'shipping_address_city'}
label={<T id={'city_town'} />}
inline
fill
>
<FInputGroup name={'shipping_address_city'} fill />
</FFormGroup>
<FFormGroup
name={'shipping_address_state'}
label={<T id={'state'} />}
inline
fill
>
<FInputGroup name={'shipping_address_state'} fill />
</FFormGroup>
<FFormGroup
name={'shipping_address_postcode'}
label={<T id={'zip_code'} />}
inline
fill
>
<FInputGroup name={'shipping_address_postcode'} fill />
</FFormGroup>
<FFormGroup
name={'shipping_address_phone'}
label={<T id={'phone'} />}
inline
fill
>
<FInputGroup name={'shipping_address_phone'} fill />
</FFormGroup>
</Box>
);
}
@@ -1,27 +1,52 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import classNames from 'classnames'; import { Button, ButtonGroup } from '@blueprintjs/core';
import { Radio } from '@blueprintjs/core'; import { FastField } from 'formik';
import { FormattedMessage as T, FFormGroup, FRadioGroup } from '@/components'; import { FormattedMessage as T, FFormGroup } from '@/components';
import { handleStringChange, saveInvoke } from '@/utils';
/** /**
* Customer type radio field. * Customer type selector (button group).
*/ */
export default function RadioCustomer() { export function CustomerTypeRadioField() {
return ( return (
<FFormGroup <FFormGroup
name={'customer_type'} name={'customer_type'}
label={<T id={'customer_type'} />} label={<T id={'customer_type'} />}
inline inline
fill
fastField fastField
> >
<FRadioGroup name={'customer_type'} inline> <FastField name="customer_type">
<Radio label={intl.get('business')} value="business" /> {({ field, form }) => (
<Radio label={intl.get('individual')} value="individual" /> <ButtonGroup>
</FRadioGroup> <Button
type="button"
outlined
small
active={field.value === 'business'}
onClick={() => {
form.setFieldValue('customer_type', 'business');
form.setFieldTouched('customer_type', true);
}}
>
{intl.get('business')}
</Button>
<Button
type="button"
outlined
small
active={field.value === 'individual'}
onClick={() => {
form.setFieldValue('customer_type', 'individual');
form.setFieldTouched('customer_type', true);
}}
>
{intl.get('individual')}
</Button>
</ButtonGroup>
)}
</FastField>
</FFormGroup> </FFormGroup>
); );
} }
@@ -5,7 +5,7 @@ import { Tabs, Tab } from '@blueprintjs/core';
import CustomerAddressTabs from './CustomerAddressTabs'; import CustomerAddressTabs from './CustomerAddressTabs';
import CustomerAttachmentTabs from './CustomerAttachmentTabs'; import CustomerAttachmentTabs from './CustomerAttachmentTabs';
import CustomerFinancialPanel from './CustomerFinancialPanel'; import CustomerFinancialPanel from './CustomerFormFinancialSection';
import CustomerNotePanel from './CustomerNotePanel'; import CustomerNotePanel from './CustomerNotePanel';
export default function CustomersTabs() { export default function CustomersTabs() {
@@ -14,6 +14,7 @@ export const defaultInitialValues = {
last_name: '', last_name: '',
company_name: '', company_name: '',
display_name: '', display_name: '',
code: '',
email: '', email: '',
work_phone: '', work_phone: '',
@@ -8,9 +8,7 @@ import {
CustomerFormProvider, CustomerFormProvider,
useCustomerFormContext, useCustomerFormContext,
} from '@/containers/Customers/CustomerForm/CustomerFormProvider'; } from '@/containers/Customers/CustomerForm/CustomerFormProvider';
import CustomerFormFormik, { import { CustomerFormFormik } from '@/containers/Customers/CustomerForm/CustomerFormFormik';
CustomerFormHeaderPrimary,
} from '@/containers/Customers/CustomerForm/CustomerFormFormik';
import { withDrawerActions } from '@/containers/Drawer/withDrawerActions'; import { withDrawerActions } from '@/containers/Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers'; import { DRAWERS } from '@/constants/drawers';
@@ -55,34 +53,14 @@ function QuickCustomerFormDrawer({
return ( return (
<CustomerFormProvider customerId={customerId}> <CustomerFormProvider customerId={customerId}>
<DrawerCustomerFormLoading> <DrawerCustomerFormLoading>
<CustomerFormCard>
<CustomerFormFormik <CustomerFormFormik
initialValues={{ first_name: displayName }} initialValues={{ first_name: displayName }}
onSubmitSuccess={handleSubmitSuccess} onSubmitSuccess={handleSubmitSuccess}
onCancel={handleCancelForm} onCancel={handleCancelForm}
/> />
</CustomerFormCard>
</DrawerCustomerFormLoading> </DrawerCustomerFormLoading>
</CustomerFormProvider> </CustomerFormProvider>
); );
} }
export default R.compose(withDrawerActions)(QuickCustomerFormDrawer); export default R.compose(withDrawerActions)(QuickCustomerFormDrawer);
const CustomerFormCard = styled(Card)`
margin: 15px;
padding: 25px;
margin-bottom: calc(15px + 65px);
${CustomerFormHeaderPrimary} {
padding-top: 0;
}
.page-form {
padding: 0;
&__floating-actions {
margin-left: -41px;
margin-right: -41px;
}
}
`;
@@ -8,8 +8,8 @@ import {
VendorFormProvider, VendorFormProvider,
useVendorFormContext, useVendorFormContext,
} from '@/containers/Vendors/VendorForm/VendorFormProvider'; } from '@/containers/Vendors/VendorForm/VendorFormProvider';
import VendorFormFormik, { import {
VendorFormHeaderPrimary, VendorFormFormik,
} from '@/containers/Vendors/VendorForm/VendorFormFormik'; } from '@/containers/Vendors/VendorForm/VendorFormFormik';
import { withDrawerActions } from '@/containers/Drawer/withDrawerActions'; import { withDrawerActions } from '@/containers/Drawer/withDrawerActions';
@@ -62,13 +62,11 @@ function QuickVendorFormDrawer({
return ( return (
<VendorFormProvider vendorId={vendorId}> <VendorFormProvider vendorId={vendorId}>
<DrawerVendorFormLoading> <DrawerVendorFormLoading>
<VendorFormCard>
<VendorFormFormik <VendorFormFormik
initialValues={{ first_name: displayName }} initialValues={{ first_name: displayName }}
onSubmitSuccess={handleSubmitSuccess} onSubmitSuccess={handleSubmitSuccess}
onCancel={handleCancelForm} onCancel={handleCancelForm}
/> />
</VendorFormCard>
</DrawerVendorFormLoading> </DrawerVendorFormLoading>
</VendorFormProvider> </VendorFormProvider>
); );
@@ -79,20 +77,3 @@ export default R.compose(
withDashboardActions, withDashboardActions,
)(QuickVendorFormDrawer); )(QuickVendorFormDrawer);
const VendorFormCard = styled(Card)`
margin: 15px;
padding: 25px;
margin-bottom: calc(15px + 65px);
${VendorFormHeaderPrimary} {
padding-top: 0;
}
.page-form {
padding: 0;
&__floating-actions {
margin-left: -41px;
margin-right: -41px;
}
}
`;
@@ -18,7 +18,6 @@ export default function QuickWriteVendorDrawerContent({ displayName, autofillRef
<DrawerHeaderContent <DrawerHeaderContent
name={DRAWERS.QUICK_CREATE_CUSTOMER} name={DRAWERS.QUICK_CREATE_CUSTOMER}
title={<T id={'create_a_new_vendor'} />} title={<T id={'create_a_new_vendor'} />}
/> />
<DrawerBody> <DrawerBody>
<QuickVendorFormDrawer displayName={displayName} autofillRef={autofillRef} /> <QuickVendorFormDrawer displayName={displayName} autofillRef={autofillRef} />
@@ -5,7 +5,7 @@ import { Dragzone, FormattedMessage as T } from '@/components';
/** /**
* Vendor Attachment Tab. * Vendor Attachment Tab.
*/ */
function VendorAttachmentTab() { export function VendorAttachmentTab() {
return ( return (
<div> <div>
<Dragzone <Dragzone
@@ -17,5 +17,3 @@ function VendorAttachmentTab() {
</div> </div>
); );
} }
export default VendorAttachmentTab;
@@ -0,0 +1,88 @@
// @ts-nocheck
import { Box } from '@/components';
import {
FormattedMessage as T,
FFormGroup,
FInputGroup,
FTextArea,
} from '@/components';
import { VendorFormSectionTitle } from './VendorFormSectionTitle';
export function VendorBillingAddress() {
return (
<Box data-section-id="billingAddress">
<VendorFormSectionTitle>
<T id={'billing_address'} />
</VendorFormSectionTitle>
<FFormGroup
name={'billing_address_country'}
label={<T id={'country'} />}
inline
fill
fastField
>
<FInputGroup name={'billing_address_country'} fill fastField />
</FFormGroup>
<FFormGroup
name={'billing_address1'}
label={<T id={'address_line_1'} />}
inline
fill
fastField
>
<FTextArea name={'billing_address1'} fill fastField />
</FFormGroup>
<FFormGroup
name={'billing_address2'}
label={<T id={'address_line_2'} />}
inline
fill
fastField
>
<FTextArea name={'billing_address2'} fill fastField />
</FFormGroup>
<FFormGroup
name={'billing_address_city'}
label={<T id={'city_town'} />}
inline
fill
fastField
>
<FInputGroup name={'billing_address_city'} fill fastField />
</FFormGroup>
<FFormGroup
name={'billing_address_state'}
label={<T id={'state'} />}
inline
fill
fastField
>
<FInputGroup name={'billing_address_state'} fill fastField />
</FFormGroup>
<FFormGroup
name={'billing_address_postcode'}
label={<T id={'zip_code'} />}
inline
fill
fastField
>
<FInputGroup name={'billing_address_postcode'} fill fastField />
</FFormGroup>
<FFormGroup
name={'billing_address_phone'}
label={<T id={'phone'} />}
inline
fill
fastField
>
<FInputGroup name={'billing_address_phone'} fill fastField />
</FFormGroup>
</Box>
);
}
@@ -28,7 +28,7 @@ import { useCurrentOrganization } from '@/hooks/state';
/** /**
* Vendor Finaniceal Panel Tab. * Vendor Finaniceal Panel Tab.
*/ */
export default function VendorFinanicalPanelTab() { export function VendorFinanicalPanelTab() {
const { currencies, branches } = useVendorFormContext(); const { currencies, branches } = useVendorFormContext();
// Sets the primary branch to form. // Sets the primary branch to form.
@@ -44,10 +44,12 @@ export default function VendorFinanicalPanelTab() {
label={<T id={'currency'} />} label={<T id={'currency'} />}
fastField fastField
inline inline
fastField
> >
<CurrencySelectList <CurrencySelectList
name="currency_code" name="currency_code"
items={currencies} items={currencies}
fastField
/> />
</FFormGroup> </FFormGroup>
@@ -93,16 +95,17 @@ function VendorOpeningBalanceField() {
<FFormGroup <FFormGroup
name={'opening_balance'} name={'opening_balance'}
label={<T id={'opening_balance'} />} label={<T id={'opening_balance'} />}
inline={true}
shouldUpdate={openingBalanceFieldShouldUpdate} shouldUpdate={openingBalanceFieldShouldUpdate}
shouldUpdateDeps={{ currencyCode: values.currency_code }} shouldUpdateDeps={{ currencyCode: values.currency_code }}
fastField={true} inline
fastField
> >
<ControlGroup> <ControlGroup>
<InputPrependText text={values.currency_code} /> <InputPrependText text={values.currency_code} />
<FMoneyInputGroup <FMoneyInputGroup
name={'opening_balance'} name={'opening_balance'}
inputGroupProps={{ fill: true }} inputGroupProps={{ fill: true }}
fastField
/> />
</ControlGroup> </ControlGroup>
</FFormGroup> </FFormGroup>
@@ -123,8 +126,9 @@ function VendorOpeningBalanceAtField() {
<FFormGroup <FFormGroup
name={'opening_balance_at'} name={'opening_balance_at'}
label={<T id={'opening_balance_at'} />} label={<T id={'opening_balance_at'} />}
inline={true}
helperText={<ErrorMessage name="opening_balance_at" />} helperText={<ErrorMessage name="opening_balance_at" />}
inline
fastField
> >
<FDateInput <FDateInput
name={'opening_balance_at'} name={'opening_balance_at'}
@@ -132,7 +136,8 @@ function VendorOpeningBalanceAtField() {
disabled={vendorId} disabled={vendorId}
formatDate={(date) => date.toLocaleDateString()} formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)} parseDate={(str) => new Date(str)}
fill={true} fill
fastField
/> />
</FFormGroup> </FFormGroup>
); );
@@ -156,12 +161,14 @@ function VendorOpeningBalanceExchangeRateField() {
<FFormGroup <FFormGroup
label={' '} label={' '}
name={'opening_balance_exchange_rate'} name={'opening_balance_exchange_rate'}
inline={true} inline
fastField
> >
<ExchangeRateInputGroup <ExchangeRateInputGroup
fromCurrency={values.currency_code} fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency} toCurrency={currentOrganization.base_currency}
name={'opening_balance_exchange_rate'} name={'opening_balance_exchange_rate'}
fastField
/> />
</FFormGroup> </FFormGroup>
); );
@@ -1,5 +1,4 @@
// @ts-nocheck // @ts-nocheck
import React from 'react';
import { import {
Intent, Intent,
Button, Button,
@@ -11,53 +10,37 @@ import {
MenuItem, MenuItem,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import styled from 'styled-components'; import styled from 'styled-components';
import classNames from 'classnames';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { Group, Icon, FormattedMessage as T } from '@/components'; import { Group, Icon, FormattedMessage as T } from '@/components';
import { CLASSES } from '@/constants/classes';
import { useVendorFormContext } from './VendorFormProvider'; import { useVendorFormContext } from './VendorFormProvider';
import { safeInvoke } from '@/utils';
/** /**
* Vendor floating actions bar. * Vendor floating actions bar.
*/ */
export default function VendorFloatingActions({ onCancel }) { export function VendorFloatingActions() {
// Formik context. // Formik context.
const { resetForm, isSubmitting, submitForm } = useFormikContext(); const { isSubmitting, submitForm } = useFormikContext();
// Vendor form context. // Vendor form context.
const { isNewMode, setSubmitPayload } = useVendorFormContext(); const { isNewMode, setSubmitPayload } = useVendorFormContext();
// Handle the submit button. // Handle the submit button.
const handleSubmitBtnClick = (event) => { const handleSubmitBtnClick = () => {
setSubmitPayload({ noRedirect: false }); setSubmitPayload({ noRedirect: false });
}; };
// Handle the submit & new button click. // Handle the submit & new button click.
const handleSubmitAndNewClick = (event) => { const handleSubmitAndNewClick = () => {
submitForm(); submitForm();
setSubmitPayload({ noRedirect: true }); setSubmitPayload({ noRedirect: true });
}; };
// Handle cancel button click.
const handleCancelBtnClick = (event) => {
safeInvoke(onCancel, event);
};
// Handle clear button click.
const handleClearBtnClick = (event) => {
resetForm();
};
return ( return (
<Group <FloatingActionsGroup spacing={10}>
spacing={10}
className={classNames(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}
>
<ButtonGroup> <ButtonGroup>
{/* ----------- Save and New ----------- */} {/* ----------- Save and New ----------- */}
<SaveButton <Button
disabled={isSubmitting} disabled={isSubmitting}
loading={isSubmitting} loading={isSubmitting}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
@@ -74,9 +57,9 @@ export default function VendorFloatingActions({ onCancel }) {
/> />
</Menu> </Menu>
} }
minimal={true}
interactionKind={PopoverInteractionKind.CLICK} interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT} position={Position.BOTTOM_RIGHT}
minimal
> >
<Button <Button
disabled={isSubmitting} disabled={isSubmitting}
@@ -85,24 +68,16 @@ export default function VendorFloatingActions({ onCancel }) {
/> />
</Popover> </Popover>
</ButtonGroup> </ButtonGroup>
{/* ----------- Clear & Reset----------- */} </FloatingActionsGroup>
<Button
className={'ml1'}
disabled={isSubmitting}
onClick={handleClearBtnClick}
text={!isNewMode ? <T id={'reset'} /> : <T id={'clear'} />}
/>
{/* ----------- Cancel ----------- */}
<Button
className={'ml1'}
disabled={isSubmitting}
onClick={handleCancelBtnClick}
text={<T id={'cancel'} />}
/>
</Group>
); );
} }
const SaveButton = styled(Button)` const FloatingActionsGroup = styled(Group)`
min-width: 100px; padding: 10px 0;
`; padding-left: 165px;
border-top: 1px solid #50555a;
position: sticky;
bottom: 0;
background: var(--color-card-background);
z-index: 1;
`;
@@ -2,21 +2,22 @@
import React from 'react'; import React from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { ControlGroup } from '@blueprintjs/core'; import { ControlGroup } from '@blueprintjs/core';
import { FormattedMessage as T, FFormGroup, FInputGroup } from '@/components'; import { FormattedMessage as T, FFormGroup, FInputGroup, Box } from '@/components';
/** /**
* Vendor form after primary section. * Vendor form after primary section.
*/ */
function VendorFormAfterPrimarySection() { export function VendorFormAfterPrimarySection() {
return ( return (
<div className={'customer-form__after-primary-section-content'}> <Box>
{/*------------ Vendor email -----------*/} {/*------------ Vendor email -----------*/}
<FFormGroup <FFormGroup
name={'email'} name={'email'}
label={<T id={'vendor_email'} />} label={<T id={'vendor_email'} />}
inline={true} inline
fastField
> >
<FInputGroup name={'email'} /> <FInputGroup name={'email'} fastField />
</FFormGroup> </FFormGroup>
{/*------------ Phone number -----------*/} {/*------------ Phone number -----------*/}
@@ -24,23 +25,23 @@ function VendorFormAfterPrimarySection() {
name={'work_phone'} name={'work_phone'}
className={'form-group--phone-number'} className={'form-group--phone-number'}
label={<T id={'phone_number'} />} label={<T id={'phone_number'} />}
inline={true} inline
fastField
> >
<ControlGroup> <ControlGroup>
<FInputGroup name={'work_phone'} placeholder={intl.get('work')} /> <FInputGroup name={'work_phone'} placeholder={intl.get('work')} fastField />
<FInputGroup <FInputGroup
name={'personal_phone'} name={'personal_phone'}
placeholder={intl.get('mobile')} placeholder={intl.get('mobile')}
fastField
/> />
</ControlGroup> </ControlGroup>
</FFormGroup> </FFormGroup>
{/*------------ Vendor website -----------*/} {/*------------ Vendor website -----------*/}
<FFormGroup name={'website'} label={<T id={'website'} />} inline={true}> <FFormGroup name={'website'} label={<T id={'website'} />} inline fastField>
<FInputGroup name={'website'} placeholder={'http://'} /> <FInputGroup name={'website'} placeholder={'http://'} fastField />
</FFormGroup> </FFormGroup>
</div> </Box>
); );
} }
export default VendorFormAfterPrimarySection;
@@ -0,0 +1,141 @@
// @ts-nocheck
import intl from 'react-intl-universal';
import { ControlGroup, Divider, Icon as BlueprintIcon } from '@blueprintjs/core';
import {
Hint,
FieldRequiredHint,
SalutationList,
DisplayNameList,
FormattedMessage as T,
FInputGroup,
FFormGroup,
Box,
Icon,
Stack,
} from '@/components';
import { VendorFormSectionTitle } from './VendorFormSectionTitle';
import { useAutofocus } from '@/hooks';
export function VendorFormBasicSection({}) {
const firstNameFieldRef = useAutofocus();
return (
<Box data-section-id="primary">
<VendorFormSectionTitle>Vendor details</VendorFormSectionTitle>
{/**----------- Contact name -----------*/}
<FFormGroup
name={'salutation'}
label={<T id={'contact_name'} />}
inline
fill
fastField
>
<ControlGroup fill>
<SalutationList
name={'salutation'}
popoverProps={{ minimal: true }}
fastField
/>
<FInputGroup
name={'first_name'}
placeholder={intl.get('first_name')}
inputRef={(ref) => (firstNameFieldRef.current = ref)}
fill
fastField
/>
<FInputGroup
name={'last_name'}
placeholder={intl.get('last_name')}
fill
fastField
/>
</ControlGroup>
</FFormGroup>
<FFormGroup
name={'code'}
label={'Vendor Code'}
helperText="Add a unique account number to identify, reference and search for the contact."
inline
fill
fastField
>
<FInputGroup name={'code'} fill fastField />
</FFormGroup>
{/*----------- Company Name -----------*/}
<FFormGroup
name={'company_name'}
label={<T id={'company_name'} />}
inline
fill
fastField
>
<FInputGroup name={'company_name'} fill fastField />
</FFormGroup>
{/*----------- Display Name -----------*/}
<FFormGroup
name={'display_name'}
label={<T id={'display_name'} />}
helperText="This is the name that appears on invoices and emails."
inline
fill
fastField
>
<DisplayNameList
name={'display_name'}
popoverProps={{ minimal: true }}
buttonProps={{ fill: true }}
fastField
/>
</FFormGroup>
<Divider style={{ margin: '20px 0' }} />
{/*------------ Vendor email -----------*/}
<FFormGroup
name={'email'}
label={<T id={'vendor_email'} />}
inline
fastField
>
<FInputGroup
name={'email'}
leftIcon={<Icon icon="envelope" />}
fastField
/>
</FFormGroup>
{/*------------ Phone number -----------*/}
<FFormGroup
name={'work_phone'}
className={'form-group--phone-number'}
label={<T id={'phone_number'} />}
inline
fastField
>
<Stack spacing={10}>
<FInputGroup name={'work_phone'} placeholder={intl.get('work')} leftIcon="phone" fastField
/>
<FInputGroup
name={'personal_phone'}
placeholder={intl.get('mobile')}
fastField
/>
</Stack>
</FFormGroup>
{/*------------ Vendor website -----------*/}
<FFormGroup name={'website'} label={<T id={'website'} />} inline fastField>
<FInputGroup
name={'website'}
placeholder={'http://'}
leftIcon={<BlueprintIcon icon="globe-network" />}
fastField
/>
</FFormGroup>
</Box>
);
}
@@ -0,0 +1,45 @@
// @ts-nocheck
import { Tab } from "@blueprintjs/core";
import { Card, Group } from "@/components";
import { Tabs } from "@blueprintjs/core";
import { useState } from "react";
import { css } from '@emotion/css';
import { VendorFloatingActions } from "./VendorFloatingActions";
import { VendorFormSections } from "./VendorFormFields";
export function VendorFormContent() {
const [selectedTabId, setSelectedTabId] = useState('primary');
const handleTabChange = (tabId: string) => {
const sectionId = String(tabId);
setSelectedTabId(sectionId);
const section = document.querySelector(
`[data-section-id="${sectionId}"]`,
);
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<Card className={css`padding-bottom: 0 !important;`}>
<Group verticalAlign={'top'} alignItems={'flex-start'} flexWrap={'nowrap'}>
<Tabs
selectedTabId={selectedTabId}
onChange={handleTabChange}
className={css`position: sticky; top: 20px;`}
vertical
>
<Tab id={'primary'} title={'Basic'} />
<Tab id={'financial'} title={'Financial'} />
<Tab id={'billingAddress'} title={'Billing address'} />
<Tab id={'shippingAddress'} title={'Shipping address'} />
<Tab id={'notes'} title={'Notes'} />
</Tabs>
<VendorFormSections />
</Group>
<VendorFloatingActions />
</Card>
)
}
@@ -0,0 +1,34 @@
// @ts-nocheck
import { Divider } from '@blueprintjs/core';
import { css } from '@emotion/css';
import { Box } from '@/components';
import { VendorFormBasicSection } from './VendorFormBasicSection';
import { VendorFormFinancialSection } from './VendorFormFinancialSection';
import { VendorBillingAddress } from './VendorBillingAddress';
import { VendorShippingAddress } from './VendorShippingAddress';
import { VendorFormNotesSection } from './VendorFormNotesSection';
const vendorFormSectionDividerClass = css`
margin: 20px 0;
`;
export function VendorFormSections() {
return (
<Box>
<VendorFormBasicSection />
<Divider className={vendorFormSectionDividerClass} />
<VendorFormFinancialSection />
<Divider className={vendorFormSectionDividerClass} />
<VendorBillingAddress />
<Divider className={vendorFormSectionDividerClass} />
<VendorShippingAddress />
<Divider className={vendorFormSectionDividerClass} />
<VendorFormNotesSection />
</Box>
);
}
@@ -0,0 +1,159 @@
// @ts-nocheck
import { FormGroup, Position, ControlGroup } from '@blueprintjs/core';
import { ErrorMessage, useFormikContext } from 'formik';
import { Features } from '@/constants';
import {
FFormGroup,
FormattedMessage as T,
InputPrependText,
CurrencySelectList,
BranchSelect,
FeatureCan,
FMoneyInputGroup,
ExchangeRateInputGroup,
FDateInput,
Icon,
Box,
} from '@/components';
import { useVendorFormContext } from './VendorFormProvider';
import {
openingBalanceFieldShouldUpdate,
useIsVendorForeignCurrency,
useSetPrimaryBranchToForm,
} from './utils';
import { useCurrentOrganization } from '@/hooks/state';
import { VendorFormSectionTitle } from './VendorFormSectionTitle';
export function VendorFormFinancialSection() {
const { currencies, vendorId, branches } = useVendorFormContext();
// Sets the primary branch to form.
useSetPrimaryBranchToForm();
return (
<Box data-section-id="financial">
<VendorFormSectionTitle>
<T id={'financial_details'} />
</VendorFormSectionTitle>
<FFormGroup
name={'currency_code'}
label={<T id={'currency'} />}
fastField
inline
fill
>
<CurrencySelectList
name="currency_code"
items={currencies}
disabled={vendorId}
fastField
/>
</FFormGroup>
<VendorOpeningBalanceField />
<VendorOpeningBalanceExchangeRateField />
<VendorOpeningBalanceAtField />
<FeatureCan feature={Features.Branches}>
<FFormGroup
label={<T id={'vendor.label.opening_branch'} />}
name={'opening_balance_branch_id'}
inline
fill
>
<BranchSelect
name={'opening_balance_branch_id'}
branches={branches}
popoverProps={{ minimal: true }}
/>
</FFormGroup>
</FeatureCan>
</Box>
);
}
/**
* Vendor opening balance at date field.
* @returns {JSX.Element}
*/
function VendorOpeningBalanceAtField() {
const { vendorId } = useVendorFormContext();
// Cannot continue if the vendor id is defined.
if (vendorId) return null;
return (
<FFormGroup
name={'opening_balance_at'}
label={<T id={'opening_balance_at'} />}
inline
fill
helperText={<ErrorMessage name="opening_balance_at" />}
>
<FDateInput
name={'opening_balance_at'}
popoverProps={{ position: Position.BOTTOM, minimal: true }}
disabled={vendorId}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{
leftIcon: <Icon icon={'date-range'} />,
}}
fill={true}
/>
</FFormGroup>
);
}
function VendorOpeningBalanceField() {
const { vendorId } = useVendorFormContext();
const { values } = useFormikContext();
// Cannot continue if the vendor id is defined.
if (vendorId) return null;
return (
<FFormGroup
label={<T id={'opening_balance'} />}
name={'opening_balance'}
inline
shouldUpdate={openingBalanceFieldShouldUpdate}
shouldUpdateDeps={{ currencyCode: values.currency_code }}
fastField={true}
fill
>
<ControlGroup fill>
<InputPrependText text={values.currency_code as string} />
<FMoneyInputGroup
name={'opening_balance'}
fastField
inputGroupProps={{ fill: true }}
/>
</ControlGroup>
</FFormGroup>
);
}
function VendorOpeningBalanceExchangeRateField() {
const { values } = useFormikContext();
const { vendorId } = useVendorFormContext();
const currentOrganization = useCurrentOrganization();
const isForeignVendor = useIsVendorForeignCurrency();
// Can't continue if the vendor is not foreign.
if (!isForeignVendor || vendorId) {
return null;
}
return (
<ExchangeRateInputGroup
fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency}
name={'opening_balance_exchange_rate'}
onRecalcConfirm={() => {}}
onCancel={() => {}}
formGroupProps={{ label: ' ' }}
/>
);
}
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import React, { useMemo } from 'react'; import { useMemo } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { Formik, Form } from 'formik'; import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
@@ -7,16 +7,13 @@ import classNames from 'classnames';
import styled from 'styled-components'; import styled from 'styled-components';
import { CLASSES } from '@/constants/classes'; import { CLASSES } from '@/constants/classes';
import { AppToaster } from '@/components'; import { AppToaster, Box } from '@/components';
import { import {
CreateVendorFormSchema, CreateVendorFormSchema,
EditVendorFormSchema, EditVendorFormSchema,
} from './VendorForm.schema'; } from './VendorForm.schema';
import VendorTabs from './VendorsTabs'; import { VendorFormContent } from './VendorFormContent';
import VendorFormPrimarySection from './VendorFormPrimarySection';
import VendorFormAfterPrimarySection from './VendorFormAfterPrimarySection';
import VendorFloatingActions from './VendorFloatingActions';
import { withCurrentOrganization } from '@/containers/Organization/withCurrentOrganization'; import { withCurrentOrganization } from '@/containers/Organization/withCurrentOrganization';
@@ -24,12 +21,10 @@ import { useVendorFormContext } from './VendorFormProvider';
import { compose, transformToForm, safeInvoke, parseBoolean } from '@/utils'; import { compose, transformToForm, safeInvoke, parseBoolean } from '@/utils';
import { defaultInitialValues } from './utils'; import { defaultInitialValues } from './utils';
import '@/style/pages/Vendors/Form.scss';
/** /**
* Vendor form. * Vendor form.
*/ */
function VendorFormFormik({ function VendorFormFormikBase({
// #withCurrentOrganization // #withCurrentOrganization
organization: { base_currency }, organization: { base_currency },
@@ -52,9 +47,6 @@ function VendorFormFormik({
isNewMode, isNewMode,
} = useVendorFormContext(); } = useVendorFormContext();
/**
* Initial values in create and edit mode.
*/
const initialFormValues = useMemo( const initialFormValues = useMemo(
() => ({ () => ({
...defaultInitialValues, ...defaultInitialValues,
@@ -106,51 +98,34 @@ function VendorFormFormik({
}; };
return ( return (
<div
className={classNames(
CLASSES.PAGE_FORM,
CLASSES.PAGE_FORM_VENDOR,
className,
)}
>
<Formik <Formik
validationSchema={ validationSchema={
isNewMode ? CreateVendorFormSchema : EditVendorFormSchema isNewMode ? CreateVendorFormSchema : EditVendorFormSchema
} }
initialValues={initialFormValues} initialValues={initialFormValues}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
> >
<Form> <Form>
<VendorFormHeaderPrimary> <VendorFormFields>
<VendorFormPrimarySection /> <VendorFormContent onCancel={onCancel} />
</VendorFormHeaderPrimary> </VendorFormFields>
<div className={'page-form__after-priamry-section'}>
<VendorFormAfterPrimarySection />
</div>
<div className={classNames(CLASSES.PAGE_FORM_TABS)}>
<VendorTabs vendor={vendorId} />
</div>
<VendorFloatingActions onCancel={onCancel} />
</Form> </Form>
</Formik> </Formik>
</div>
); );
} }
export const VendorFormHeaderPrimary = styled.div`
--x-color-border: #e4e4e4;
.bp4-dark & { const VendorFormFields = styled.div`
--x-color-border: var(--color-dark-gray3); .bp4-form-content,
.bp6-form-content {
min-width: 300px;
}
.bp4-form-group{
margin-bottom: 20px;
}
.bp4-form-group.bp4-inline label.bp4-label {
min-width: 140px;
} }
padding: 10px 0 0;
margin: 0 0 20px;
overflow: hidden;
border-bottom: 1px solid var(--x-color-border);
max-width: 1000px;
`; `;
export default compose(withCurrentOrganization())(VendorFormFormik); export const VendorFormFormik = compose(withCurrentOrganization())(VendorFormFormikBase);
@@ -0,0 +1,17 @@
// @ts-nocheck
import { Box, FFormGroup, FormattedMessage as T, FTextArea } from '@/components';
import { VendorFormSectionTitle } from './VendorFormSectionTitle';
export function VendorFormNotesSection() {
return (
<Box data-section-id="notes">
<VendorFormSectionTitle>
<T id={'notes'} />
</VendorFormSectionTitle>
<FFormGroup name={'note'} label={<T id={'note'} />} inline fill fastField>
<FTextArea name={'note'} fill fastField />
</FFormGroup>
</Box>
);
}
@@ -2,12 +2,9 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useParams, useHistory } from 'react-router-dom'; import { useParams, useHistory } from 'react-router-dom';
import { Box, DashboardCard, DashboardInsider } from '@/components';
import '@/style/pages/Vendors/PageForm.scss';
import { DashboardCard, DashboardInsider } from '@/components';
import { VendorFormProvider, useVendorFormContext } from './VendorFormProvider'; import { VendorFormProvider, useVendorFormContext } from './VendorFormProvider';
import VendorFormFormik from './VendorFormFormik'; import { VendorFormFormik } from './VendorFormFormik';
/** /**
* Vendor form page loading wrapper. * Vendor form page loading wrapper.
@@ -17,16 +14,16 @@ function VendorFormPageLoading({ children }) {
const { isFormLoading } = useVendorFormContext(); const { isFormLoading } = useVendorFormContext();
return ( return (
<VendorDashboardInsider loading={isFormLoading}> <DashboardInsider loading={isFormLoading}>
{children} {children}
</VendorDashboardInsider> </DashboardInsider>
); );
} }
/** /**
* Vendor form page. * Vendor form page.
*/ */
export default function VendorFormPage() { export function VendorFormPage() {
const history = useHistory(); const history = useHistory();
const { id } = useParams(); const { id } = useParams();
@@ -44,26 +41,13 @@ export default function VendorFormPage() {
return ( return (
<VendorFormProvider vendorId={id}> <VendorFormProvider vendorId={id}>
<VendorFormPageLoading> <VendorFormPageLoading>
<DashboardCard page> <Box mx={'auto'} maxWidth={800}>
<VendorFormPageFormik <VendorFormFormik
onSubmitSuccess={handleSubmitSuccess} onSubmitSuccess={handleSubmitSuccess}
onCancel={handleFormCancel} onCancel={handleFormCancel}
/> />
</DashboardCard> </Box>
</VendorFormPageLoading> </VendorFormPageLoading>
</VendorFormProvider> </VendorFormProvider>
); );
} }
const VendorFormPageFormik = styled(VendorFormFormik)`
.page-form {
&__floating-actions {
margin-left: -40px;
margin-right: -40px;
}
}
`;
const VendorDashboardInsider = styled(DashboardInsider)`
padding-bottom: 64px;
`;
@@ -1,84 +0,0 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import classNames from 'classnames';
import { ControlGroup } from '@blueprintjs/core';
import {
FormattedMessage as T,
FFormGroup,
FInputGroup,
Hint,
FieldRequiredHint,
SalutationList,
DisplayNameList,
} from '@/components';
import { CLASSES } from '@/constants/classes';
import { useAutofocus } from '@/hooks';
/**
* Vendor form primary section.
*/
function VendorFormPrimarySection() {
const firstNameFieldRef = useAutofocus();
return (
<div className={'customer-form__primary-section-content'}>
{/**----------- Vendor name -----------*/}
<FFormGroup
name={'salutation'}
className={classNames('form-group--contact_name')}
label={<T id={'contact_name'} />}
inline={true}
>
<ControlGroup>
<SalutationList
name={'salutation'}
popoverProps={{ minimal: true }}
/>
<FInputGroup
name={'first_name'}
placeholder={intl.get('first_name')}
className={classNames('input-group--first-name')}
inputRef={(ref) => (firstNameFieldRef.current = ref)}
/>
<FInputGroup
name={'last_name'}
placeholder={intl.get('last_name')}
className={classNames('input-group--last-name')}
/>
</ControlGroup>
</FFormGroup>
{/*----------- Company Name -----------*/}
<FFormGroup
name={'company_name'}
className={classNames('form-group--company_name')}
label={<T id={'company_name'} />}
inline={true}
>
<FInputGroup name={'company_name'} />
</FFormGroup>
{/*----------- Display Name -----------*/}
<FFormGroup
name={'display_name'}
label={
<>
<T id={'display_name'} />
<FieldRequiredHint />
<Hint />
</>
}
fastField
inline
>
<DisplayNameList
name={'display_name'}
popoverProps={{ minimal: true }}
/>
</FFormGroup>
</div>
);
}
export default VendorFormPrimarySection;
@@ -33,7 +33,6 @@ function VendorFormProvider({ query, vendorId, ...props }) {
const { data: vendor, isLoading: isVendorLoading } = useVendor(vendorId, { const { data: vendor, isLoading: isVendorLoading } = useVendor(vendorId, {
enabled: !!vendorId, enabled: !!vendorId,
}); });
// Handle fetch contact duplicate details. // Handle fetch contact duplicate details.
const { data: contactDuplicate, isLoading: isContactLoading } = useContact( const { data: contactDuplicate, isLoading: isContactLoading } = useContact(
contactId, contactId,
@@ -0,0 +1,12 @@
import { css } from '@emotion/css';
const vendorFormSectionTitleClass = css`
font-size: 14px;
color: #8f99a8;
margin-bottom: 18px;
margin-top: 10px;
`;
export function VendorFormSectionTitle({ children }: { children: React.ReactNode | string }) {
return <h4 className={vendorFormSectionTitleClass}>{children}</h4>;
}
@@ -0,0 +1,88 @@
// @ts-nocheck
import { Box } from '@/components';
import {
FormattedMessage as T,
FFormGroup,
FInputGroup,
FTextArea,
} from '@/components';
import { VendorFormSectionTitle } from './VendorFormSectionTitle';
export function VendorShippingAddress() {
return (
<Box data-section-id="shippingAddress">
<VendorFormSectionTitle>
<T id={'shipping_address'} />
</VendorFormSectionTitle>
<FFormGroup
name={'shipping_address_country'}
label={<T id={'country'} />}
inline
fill
fastField
>
<FInputGroup name={'shipping_address_country'} fill fastField />
</FFormGroup>
<FFormGroup
name={'shipping_address1'}
label={<T id={'address_line_1'} />}
inline
fill
fastField
>
<FTextArea name={'shipping_address1'} fill fastField />
</FFormGroup>
<FFormGroup
name={'shipping_address2'}
label={<T id={'address_line_2'} />}
inline
fill
fastField
>
<FTextArea name={'shipping_address2'} fill fastField />
</FFormGroup>
<FFormGroup
name={'shipping_address_city'}
label={<T id={'city_town'} />}
inline
fill
fastField
>
<FInputGroup name={'shipping_address_city'} fill fastField />
</FFormGroup>
<FFormGroup
name={'shipping_address_state'}
label={<T id={'state'} />}
inline
fill
fastField
>
<FInputGroup name={'shipping_address_state'} fill fastField />
</FFormGroup>
<FFormGroup
name={'shipping_address_postcode'}
label={<T id={'zip_code'} />}
inline
fill
fastField
>
<FInputGroup name={'shipping_address_postcode'} fill fastField />
</FFormGroup>
<FFormGroup
name={'shipping_address_phone'}
label={<T id={'phone'} />}
inline
fill
fastField
>
<FInputGroup name={'shipping_address_phone'} fill fastField />
</FFormGroup>
</Box>
);
}
@@ -1,43 +0,0 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import classNames from 'classnames';
import { Tabs, Tab } from '@blueprintjs/core';
import { CLASSES } from '@/constants/classes';
import VendorFinanicalPanelTab from './VendorFinanicalPanelTab';
import CustomerAddressTabs from '@/containers/Customers/CustomerForm/CustomerAddressTabs';
import CustomerNotePanel from '@/containers/Customers/CustomerForm/CustomerNotePanel';
/**
* Vendor form tabs.
*/
export default function VendorTabs() {
return (
<div className={classNames(CLASSES.PAGE_FORM_TABS)}>
<Tabs
animate={true}
id={'vendor-tabs'}
large={true}
defaultSelectedTabId="financial"
>
<Tab
id={'financial'}
title={intl.get('financial_details')}
panel={<VendorFinanicalPanelTab />}
/>
<Tab
id={'address'}
title={intl.get('address')}
panel={<CustomerAddressTabs />}
/>
<Tab
id="notes"
title={intl.get('notes')}
panel={<CustomerNotePanel />}
/>
</Tabs>
</div>
);
}
@@ -13,6 +13,7 @@ export const defaultInitialValues = {
last_name: '', last_name: '',
company_name: '', company_name: '',
display_name: '', display_name: '',
code: '',
email: '', email: '',
work_phone: '', work_phone: '',
@@ -37,8 +37,8 @@ export function useUncategorizeTransactionsBulkAction(
UncategorizeTransactionsBulkValues UncategorizeTransactionsBulkValues
>( >(
(value) => (value) =>
apiRequest.post(`/cashflow/transactions/uncategorize/bulk`, { apiRequest.delete(`/banking/categorize/bulk`, {
ids: value.ids, params: { uncategorizedTransactionIds: value.ids },
}), }),
{ {
onSuccess: (res, values) => { onSuccess: (res, values) => {
+2 -1
View File
@@ -1,6 +1,7 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import useApiRequest from './useRequest'; import useApiRequest from './useRequest';
import { normalizeApiPath } from '../utils';
export const useRequestPdf = (httpProps) => { export const useRequestPdf = (httpProps) => {
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
@@ -17,7 +18,7 @@ export const useRequestPdf = (httpProps) => {
headers: { accept: 'application/pdf' }, headers: { accept: 'application/pdf' },
responseType: 'blob', responseType: 'blob',
...httpProps, ...httpProps,
url: `/api/${httpProps?.url}`, url: `/api/${normalizeApiPath(httpProps?.url)}`,
}) })
.then((response) => { .then((response) => {
// Create a Blob from the PDF Stream. // Create a Blob from the PDF Stream.
+2 -2
View File
@@ -1997,8 +1997,8 @@
"vendor_opening_balance.label": "Edit Vendor Opening Balance", "vendor_opening_balance.label": "Edit Vendor Opening Balance",
"vendor_opening_balance.label.opening_balance": "Opening balance", "vendor_opening_balance.label.opening_balance": "Opening balance",
"vendor_opening_balance.label.opening_balance_at": "Opening balance at", "vendor_opening_balance.label.opening_balance_at": "Opening balance at",
"customer.label.opening_branch": "Opening Balance Branch", "customer.label.opening_branch": "Balance Branch",
"vendor.label.opening_branch": "Opening Balance Branch", "vendor.label.opening_branch": "Balance Branch",
"warehouse.error.warehouse_code_not_unique": "Warehouse code not unique", "warehouse.error.warehouse_code_not_unique": "Warehouse code not unique",
"warehouse.error.warehouse_has_associated_transactions": "You could not delete the warehouse that has associated transactions.", "warehouse.error.warehouse_has_associated_transactions": "You could not delete the warehouse that has associated transactions.",
"branche.error.warehouse_code_not_unique": "Branch code not unique", "branche.error.warehouse_code_not_unique": "Branch code not unique",
+2 -2
View File
@@ -619,7 +619,7 @@ export const getDashboardRoutes = () => [
{ {
path: `/vendors/:id/edit`, path: `/vendors/:id/edit`,
component: lazy( component: lazy(
() => import('@/containers/Vendors/VendorForm/VendorFormPage'), () => import('@/containers/Vendors/VendorForm/VendorFormPage').then(module => ({ default: module.VendorFormPage })),
), ),
name: 'vendor-edit', name: 'vendor-edit',
breadcrumb: intl.get('edit_vendor'), breadcrumb: intl.get('edit_vendor'),
@@ -631,7 +631,7 @@ export const getDashboardRoutes = () => [
{ {
path: `/vendors/new`, path: `/vendors/new`,
component: lazy( component: lazy(
() => import('@/containers/Vendors/VendorForm/VendorFormPage'), () => import('@/containers/Vendors/VendorForm/VendorFormPage').then(module => ({ default: module.VendorFormPage })),
), ),
name: 'vendor-new', name: 'vendor-new',
breadcrumb: intl.get('new_vendor'), breadcrumb: intl.get('new_vendor'),
@@ -8,6 +8,12 @@
min-height: 32px; min-height: 32px;
padding-left: 12px; padding-left: 12px;
padding-right: 12px; padding-right: 12px;
&.bp4-outlined {
.bp4-dark & {
border-color: rgba(255, 255, 255, 0.2);
}
}
} }
.bp4-button:not([class*='bp4-intent-']) { .bp4-button:not([class*='bp4-intent-']) {
@@ -1,156 +0,0 @@
@import '../../_base.scss';
.page-form--customer {
$self: '.page-form';
padding: 20px;
--x-color-tabs-border: #f0f0f0;
.bp4-dark & {
--x-color-tabs-border: var(--color-dark-gray3);
}
#{$self}__header {
padding: 0;
}
#{$self}__primary-section {
padding: 10px 0 0;
margin: 0 0 20px;
overflow: hidden;
border-bottom: 1px solid #e4e4e4;
max-width: 1000px;
}
.bp4-form-group {
max-width: 500px;
.bp4-control {
margin-top: 8px;
margin-bottom: 8px;
}
&.bp4-inline {
.bp4-label {
min-width: 150px;
}
}
.bp4-form-content {
width: 100%;
}
}
.form-group--contact_name {
max-width: 600px;
.bp4-control-group > * {
flex-shrink: unset;
&:not(:last-child) {
padding-right: 10px;
}
&.input-group--salutation-list {
width: 25%;
}
&.input-group--first-name,
&.input-group--last-name {
width: 37%;
}
}
}
.bp4-form-group {
margin-bottom: 14px;
}
.bp4-tab-panel {
margin-top: 26px;
}
.form-group--phone-number {
.bp4-control-group > * {
flex-shrink: unset;
padding-right: 5px;
padding-left: 5px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
#{$self}__tabs {
margin-top: 20px;
max-width: 1000px;
h4 {
font-weight: 500;
color: #888;
margin-bottom: 1.2rem;
font-size: 14px;
}
// Tab panels.
.tab-panel {
&--address {
.bp4-form-group {
max-width: 440px;
&.bp4-inline {
.bp4-label {
min-width: 145px;
}
}
.bp4-form-content {
width: 100%;
}
textarea.bp4-input {
max-width: 100%;
width: 100%;
min-height: 50px;
}
}
}
&--note {
.form-group--note {
.bp4-form-group {
max-width: 600px;
}
textarea {
width: 100%;
min-height: 100px;
}
}
}
}
.dropzone-container {
max-width: 600px;
}
}
.bp4-tabs {
.bp4-tab-list {
position: relative;
&:before {
content: '';
position: absolute;
bottom: 0;
width: 100%;
height: 2px;
background: var(--x-color-tabs-border);
}
> *:not(:last-child) {
margin-right: 25px;
}
&.bp4-large > .bp4-tab {
font-size: 15px;
}
}
}
}
+255 -21
View File
@@ -398,6 +398,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number for pagination",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Number of items per page",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -476,24 +494,6 @@
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Number of items per page",
"schema": {
"type": "number"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number for pagination",
"schema": {
"type": "number"
}
} }
], ],
"responses": { "responses": {
@@ -2999,6 +2999,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -4848,6 +4866,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -5964,6 +6000,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -6357,6 +6411,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -7140,6 +7212,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -7458,6 +7548,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -8027,6 +8135,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -8795,6 +8921,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -9385,6 +9529,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -10114,6 +10276,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -10489,6 +10669,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -11518,6 +11716,24 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (1-based)",
"schema": {
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"description": "Page size",
"schema": {
"type": "number"
}
},
{ {
"name": "customViewId", "name": "customViewId",
"required": false, "required": false,
@@ -15262,7 +15478,7 @@
}, },
"children": [ "children": [
{ {
"name": "Current Liabilties", "name": "Current Liabilities",
"id": "CURRENT_LIABILITY", "id": "CURRENT_LIABILITY",
"node_type": "AGGREGATE", "node_type": "AGGREGATE",
"type": "AGGREGATE", "type": "AGGREGATE",
@@ -15951,7 +16167,7 @@
"cells": [ "cells": [
{ {
"key": "name", "key": "name",
"value": "Current Liabilties" "value": "Current Liabilities"
}, },
{ {
"key": "total", "key": "total",
@@ -16079,7 +16295,7 @@
"cells": [ "cells": [
{ {
"key": "name", "key": "name",
"value": "Total Current Liabilties" "value": "Total Current Liabilities"
}, },
{ {
"key": "total", "key": "total",
@@ -29171,6 +29387,11 @@
"type": "boolean", "type": "boolean",
"description": "Active status", "description": "Active status",
"default": true "default": true
},
"code": {
"type": "string",
"description": "Customer code",
"example": "CUST-001"
} }
}, },
"required": [ "required": [
@@ -29293,6 +29514,10 @@
"active": { "active": {
"type": "boolean", "type": "boolean",
"description": "Active status" "description": "Active status"
},
"code": {
"type": "string",
"description": "Customer code"
} }
}, },
"required": [ "required": [
@@ -29528,6 +29753,11 @@
"type": "boolean", "type": "boolean",
"description": "Whether the vendor is active", "description": "Whether the vendor is active",
"default": true "default": true
},
"code": {
"type": "string",
"description": "Vendor code",
"example": "VEND-001"
} }
}, },
"required": [ "required": [
@@ -29644,6 +29874,10 @@
"active": { "active": {
"type": "boolean", "type": "boolean",
"description": "Whether the vendor is active" "description": "Whether the vendor is active"
},
"code": {
"type": "string",
"description": "Vendor code"
} }
} }
}, },
+69 -7
View File
@@ -8390,6 +8390,11 @@ export interface components {
* @default true * @default true
*/ */
active: boolean; active: boolean;
/**
* @description Customer code
* @example CUST-001
*/
code?: string;
}; };
EditCustomerDto: { EditCustomerDto: {
/** @description Billing address line 1 */ /** @description Billing address line 1 */
@@ -8448,6 +8453,8 @@ export interface components {
note?: string; note?: string;
/** @description Active status */ /** @description Active status */
active?: boolean; active?: boolean;
/** @description Customer code */
code?: string;
}; };
CustomerOpeningBalanceEditDto: { CustomerOpeningBalanceEditDto: {
/** /**
@@ -8588,6 +8595,11 @@ export interface components {
* @default true * @default true
*/ */
active: boolean; active: boolean;
/**
* @description Vendor code
* @example VEND-001
*/
code?: string;
}; };
EditVendorDto: { EditVendorDto: {
/** @description Billing address line 1 */ /** @description Billing address line 1 */
@@ -8644,6 +8656,8 @@ export interface components {
note?: string; note?: string;
/** @description Whether the vendor is active */ /** @description Whether the vendor is active */
active?: boolean; active?: boolean;
/** @description Vendor code */
code?: string;
}; };
VendorOpeningBalanceEditDto: { VendorOpeningBalanceEditDto: {
/** /**
@@ -14608,6 +14622,10 @@ export interface operations {
ItemsController_getItems: { ItemsController_getItems: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number for pagination */
page?: number;
/** @description Number of items per page */
pageSize?: number;
/** @description Custom view ID for filtering */ /** @description Custom view ID for filtering */
customViewId?: number; customViewId?: number;
/** @description Array of filter roles */ /** @description Array of filter roles */
@@ -14624,10 +14642,6 @@ export interface operations {
viewSlug?: string; viewSlug?: string;
/** @description Filter for inactive items */ /** @description Filter for inactive items */
inactiveMode?: boolean; inactiveMode?: boolean;
/** @description Number of items per page */
pageSize?: number;
/** @description Page number for pagination */
page?: number;
}; };
header: { header: {
/** @description Value must be 'Bearer <token>' where <token> is an API key prefixed with 'bc_' or a JWT token. */ /** @description Value must be 'Bearer <token>' where <token> is an API key prefixed with 'bc_' or a JWT token. */
@@ -16083,6 +16097,10 @@ export interface operations {
SaleInvoicesController_getSaleInvoices: { SaleInvoicesController_getSaleInvoices: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -17210,6 +17228,10 @@ export interface operations {
PaymentReceivesController_getPaymentsReceived: { PaymentReceivesController_getPaymentsReceived: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -17870,6 +17892,10 @@ export interface operations {
ItemCategoryController_getItemCategories: { ItemCategoryController_getItemCategories: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -18071,6 +18097,10 @@ export interface operations {
ExpensesController_getExpenses: { ExpensesController_getExpenses: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -18510,6 +18540,10 @@ export interface operations {
CustomersController_getCustomers: { CustomersController_getCustomers: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -18666,6 +18700,10 @@ export interface operations {
VendorsController_getVendors: { VendorsController_getVendors: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -18945,6 +18983,10 @@ export interface operations {
SaleEstimatesController_getSaleEstimates: { SaleEstimatesController_getSaleEstimates: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -19369,6 +19411,10 @@ export interface operations {
SaleReceiptsController_getSaleReceipts: { SaleReceiptsController_getSaleReceipts: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -19682,6 +19728,10 @@ export interface operations {
BillsController_getBills: { BillsController_getBills: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -20070,6 +20120,10 @@ export interface operations {
ManualJournalsController_getManualJournals: { ManualJournalsController_getManualJournals: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -20288,6 +20342,10 @@ export interface operations {
CreditNotesController_getCreditNotes: { CreditNotesController_getCreditNotes: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -20910,6 +20968,10 @@ export interface operations {
VendorCreditsController_getVendorCredits: { VendorCreditsController_getVendorCredits: {
parameters: { parameters: {
query?: { query?: {
/** @description Page number (1-based) */
page?: number;
/** @description Page size */
pageSize?: number;
/** @description Custom view ID */ /** @description Custom view ID */
customViewId?: number; customViewId?: number;
/** @description Filter roles */ /** @description Filter roles */
@@ -23039,7 +23101,7 @@ export interface operations {
* }, * },
* "children": [ * "children": [
* { * {
* "name": "Current Liabilties", * "name": "Current Liabilities",
* "id": "CURRENT_LIABILITY", * "id": "CURRENT_LIABILITY",
* "node_type": "AGGREGATE", * "node_type": "AGGREGATE",
* "type": "AGGREGATE", * "type": "AGGREGATE",
@@ -23726,7 +23788,7 @@ export interface operations {
* "cells": [ * "cells": [
* { * {
* "key": "name", * "key": "name",
* "value": "Current Liabilties" * "value": "Current Liabilities"
* }, * },
* { * {
* "key": "total", * "key": "total",
@@ -23854,7 +23916,7 @@ export interface operations {
* "cells": [ * "cells": [
* { * {
* "key": "name", * "key": "name",
* "value": "Total Current Liabilties" * "value": "Total Current Liabilities"
* }, * },
* { * {
* "key": "total", * "key": "total",