fix: passing number format to reports

This commit is contained in:
Ahmed Bouhuolia
2025-12-11 00:19:55 +02:00
parent d006362be2
commit 340b78d968
20 changed files with 308 additions and 163 deletions

View File

@@ -7,53 +7,46 @@ import {
} from '@nestjs/common';
import { type Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { mapKeysDeep } from '@/utils/deepdash';
export function camelToSnake<T = any>(value: T) {
export function camelToSnake<T = any>(value: T): T {
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map(camelToSnake);
}
if (typeof value === 'object' && !(value instanceof Date)) {
return Object.fromEntries(
Object.entries(value).map(([key, value]) => [
key
.split(/(?=[A-Z])/)
.join('_')
.toLowerCase(),
camelToSnake(value),
]),
);
}
return value;
return mapKeysDeep(
value,
(_value: string, key: any, parent: any, context: any) => {
if (Array.isArray(parent)) {
// tell mapKeysDeep to skip mapping inside this branch
context.skipChildren = true;
return key;
}
return key
.split(/(?=[A-Z])/)
.join('_')
.toLowerCase();
},
) as T;
}
export function snakeToCamel<T = any>(value: T) {
export function snakeToCamel<T = any>(value: T): T {
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map(snakeToCamel);
}
const impl = (str: string) => {
const converted = str.replace(/([-_]\w)/g, (group) =>
group[1].toUpperCase(),
);
return converted[0].toLowerCase() + converted.slice(1);
};
if (typeof value === 'object' && !(value instanceof Date)) {
return Object.fromEntries(
Object.entries(value).map(([key, value]) => [
impl(key),
snakeToCamel(value),
]),
);
}
return value;
return mapKeysDeep(
value,
(_value: string, key: any, parent: any, context: any) => {
if (Array.isArray(parent)) {
// tell mapKeysDeep to skip mapping inside this branch
context.skipChildren = true;
return key;
}
const converted = key.replace(/([-_]\w)/g, (group) =>
group[1].toUpperCase(),
);
return converted[0].toLowerCase() + converted.slice(1);
},
) as T;
}
export const DEFAULT_STRATEGY = {
@@ -63,7 +56,7 @@ export const DEFAULT_STRATEGY = {
@Injectable()
export class SerializeInterceptor implements NestInterceptor<any, any> {
constructor(@Optional() readonly strategy = DEFAULT_STRATEGY) {}
constructor(@Optional() readonly strategy = DEFAULT_STRATEGY) { }
intercept(
context: ExecutionContext,

View File

@@ -8,6 +8,7 @@ import { ServiceErrorFilter } from './common/filters/service-error.filter';
import { ModelHasRelationsFilter } from './common/filters/model-has-relations.filter';
import { ValidationPipe } from './common/pipes/ClassValidation.pipe';
import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor';
import { NestExpressApplication } from '@nestjs/platform-express';
global.__public_dirname = path.join(__dirname, '..', 'public');
global.__static_dirname = path.join(__dirname, '../static');
@@ -15,7 +16,10 @@ global.__views_dirname = path.join(global.__static_dirname, '/views');
global.__images_dirname = path.join(global.__static_dirname, '/images');
async function bootstrap() {
const app = await NestFactory.create(AppModule, { rawBody: true });
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
rawBody: true,
});
app.set('query parser', 'extended');
app.setGlobalPrefix('/api');
// create and mount the middleware manually here

View File

@@ -1,4 +1,4 @@
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
@@ -7,6 +7,7 @@ import {
IsPositive,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { parseBoolean } from '@/utils/parse-boolean';
export class NumberFormatQueryDto {
@ApiPropertyOptional({
@@ -24,6 +25,7 @@ export class NumberFormatQueryDto {
example: false,
})
@IsBoolean()
@Transform(({ value }) => parseBoolean(value, false))
@IsOptional()
readonly divideOn1000: boolean;
@@ -32,6 +34,7 @@ export class NumberFormatQueryDto {
example: true,
})
@IsBoolean()
@Transform(({ value }) => parseBoolean(value, false))
@IsOptional()
readonly showZero: boolean;

View File

@@ -81,6 +81,7 @@ export class CashFlowStatementQueryDto extends FinancialSheetBranchesQueryDto {
})
@ValidateNested()
@Type(() => NumberFormatQueryDto)
@IsOptional()
numberFormat: NumberFormatQueryDto;
@ApiProperty({

View File

@@ -32,7 +32,7 @@ export class InventoryItemDetailsQueryDto {
@ApiPropertyOptional({
description: 'Number format for the inventory item details',
})
numberFormat: INumberFormatQuery;
numberFormat: NumberFormatQueryDto;
@Transform(({ value }) => parseBoolean(value, false))
@IsBoolean()

View File

@@ -7,11 +7,13 @@ import {
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { ToNumber } from '@/common/decorators/Validators';
import { parseBoolean } from '@/utils/parse-boolean';
import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto';
export class ProfitLossSheetQueryDto extends FinancialSheetBranchesQueryDto {
@IsString()
@@ -30,8 +32,10 @@ export class ProfitLossSheetQueryDto extends FinancialSheetBranchesQueryDto {
toDate: moment.MomentInput;
@ApiProperty({ description: 'Number format configuration' })
@Type(() => Object)
numberFormat: INumberFormatQuery;
@ValidateNested()
@Type(() => NumberFormatQueryDto)
@IsOptional()
numberFormat: NumberFormatQueryDto;
@IsBoolean()
@Transform(({ value }) => parseBoolean(value, false))

View File

@@ -78,7 +78,7 @@
"plaid-threads": "^11.4.3",
"polished": "^4.3.1",
"prop-types": "15.8.1",
"query-string": "^7.1.1",
"qs": "^6.14.0",
"ramda": "^0.27.1",
"react": "^18.2.0",
"react-body-classname": "^1.3.1",

View File

@@ -22,6 +22,7 @@ export const getDefaultAPAgingSummaryQuery = () => {
filterByOption: 'without-zero-balance',
vendorsIds: [],
branchesIds: [],
numberFormat: {},
};
};

View File

@@ -21,6 +21,7 @@ export const getDefaultARAgingSummaryQuery = () => {
filterByOption: 'without-zero-balance',
customersIds: [],
branchesIds: [],
numberFormat: {},
};
};

View File

@@ -33,6 +33,7 @@ export const getDefaultBalanceSheetQuery = () => ({
percentageOfRow: false,
branchesIds: [],
numberFormat: {},
});
/**

View File

@@ -17,6 +17,7 @@ export const getDefaultCashFlowSheetQuery = () => {
displayColumnsType: 'total',
filterByOption: 'with-transactions',
branchesIds: [],
numberFormat: {},
};
};

View File

@@ -18,6 +18,7 @@ export const getInventoryItemDetailsDefaultQuery = () => ({
itemsIds: [],
warehousesIds: [],
branchesIds: [],
numberFormat: {},
});
/**

View File

@@ -35,6 +35,7 @@ export const getDefaultProfitLossQuery = () => ({
percentageExpense: false,
branchesIds: [],
numberFormat: {},
});
/**
@@ -50,7 +51,6 @@ const parseProfitLossQuery = (locationQuery) => {
return {
...transformed,
// Ensures the branches ids is always array.
branchesIds: castArray(transformed.branchesIds),
};

View File

@@ -17,6 +17,7 @@ export function getDefaultTrialBalanceQuery() {
basis: 'accrual',
filterByOption: 'with-transactions',
branchesIds: [],
numberFormat: {},
};
}

View File

@@ -42,7 +42,6 @@ export const transformAccountsFilter = (form) => {
*/
export const transformFilterFormToQuery = (form) => {
return R.compose(
R.curry(flatten)({ safe: true }),
transfromToSnakeCase,
transformAccountsFilter,
transformDisplayColumnsType,

View File

@@ -1,16 +1,10 @@
// @ts-nocheck
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import {
ParseOptions,
ParsedQuery,
StringifyOptions,
parse,
stringify,
} from 'query-string';
import * as qs from 'qs';
import { useHistory } from 'react-router';
export interface QueryStringResult {
[0]: ParsedQuery;
[0]: Record<string, any>;
[1]: Dispatch<SetStateAction<Record<string, any>>>;
}
@@ -20,6 +14,61 @@ type NavigateCallback = (
stringifedParams: string,
) => void;
type ParseOptions = {
parseNumbers?: boolean;
parseBooleans?: boolean;
[key: string]: any;
};
type StringifyOptions = qs.IStringifyOptions;
/**
* Checks if a string represents a number (including negatives, decimals, scientific notation)
*/
const isNumber = (val: string): boolean => {
return !isNaN(parseFloat(val)) && isFinite(Number(val)) && val !== '';
};
/**
* Checks if a string represents a boolean
*/
const isBoolean = (val: string): boolean => {
return val === 'false' || val === 'true';
};
/**
* Custom decoder for qs to parse numbers and booleans
* Based on query-types library approach: https://github.com/xpepermint/query-types
*/
const createDecoder = (parseNumbers: boolean, parseBooleans: boolean) => {
return (str: string, defaultDecoder?: any, charset?: string, type?: 'key' | 'value') => {
// Only decode values, not keys
if (type === 'key') {
return defaultDecoder ? defaultDecoder(str, defaultDecoder, charset) : str;
}
// First decode using default decoder
const decoded = defaultDecoder ? defaultDecoder(str, defaultDecoder, charset) : decodeURIComponent(str);
// Handle empty strings and undefined
if (typeof decoded === 'undefined' || decoded === '') {
return null;
}
// Parse booleans first (before numbers, as 'true'/'false' are strings)
if (parseBooleans && isBoolean(decoded)) {
return decoded === 'true';
}
// Parse numbers if enabled (handles integers, decimals, negatives, scientific notation)
if (parseNumbers && isNumber(decoded)) {
return Number(decoded);
}
return decoded;
};
};
/**
* Query string.
* @param {Location} location
@@ -35,14 +84,28 @@ export function useQueryString(
stringifyOptions?: StringifyOptions,
): QueryStringResult {
const isFirst = useRef(true);
const [state, setState] = useState(parse(location.search, parseOptions));
// Extract parseNumbers and parseBooleans from parseOptions
const { parseNumbers = false, parseBooleans = false, ...qsParseOptions } = parseOptions || {};
// Create decoder if needed
const parseConfig = {
...qsParseOptions,
...(parseNumbers || parseBooleans ? {
decoder: createDecoder(parseNumbers, parseBooleans),
} : {}),
};
const [state, setState] = useState(
qs.parse(location.search.substring(1), parseConfig)
);
useEffect((): void => {
if (isFirst.current) {
isFirst.current = false;
} else {
const pathname = location.pathname;
const stringifedParams = stringify(state, stringifyOptions);
const stringifedParams = qs.stringify(state, stringifyOptions);
const pathnameWithParams = pathname + '?' + stringifedParams;
navigate(pathnameWithParams, pathname, stringifedParams);
@@ -52,7 +115,7 @@ export function useQueryString(
const setQuery: typeof setState = (values): void => {
const nextState = typeof values === 'function' ? values(state) : values;
setState(
(state): ParsedQuery => ({
(state): Record<string, any> => ({
...state,
...nextState,
}),
@@ -78,7 +141,6 @@ export const useAppQueryString = (
window.location,
(pathnameWithParams, pathname, stringifiedParams) => {
history.push({ pathname, search: stringifiedParams });
navigate && navigate(pathnameWithParams, pathname, stringifiedParams);
},
{