1
0
This commit is contained in:
Ahmed Bouhuolia
2026-03-19 21:18:44 +02:00
parent e55f4cb605
commit 25c3fc019f
17 changed files with 310 additions and 129 deletions
@@ -1,9 +1,17 @@
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt } from 'class-validator';
export class InventoryAdjustmentsFilterDto {
@ApiPropertyOptional({ example: 1 })
@IsInt()
@ToNumber()
@IsOptional()
page?: number;
@ApiPropertyOptional({ example: 12 })
@IsInt()
@ToNumber()
@IsOptional()
pageSize?: number;
}
@@ -24,6 +24,6 @@ export class ItemCategoriesExportable extends Exportable {
return this.itemCategoryApp
.getItemCategories(parsedQuery)
.then((output) => output.data);
.then((output) => output);
}
}
@@ -42,7 +42,4 @@ export interface IItemCategoriesFilter extends IDynamicListFilter {
filterQuery?: (trx: Knex.Transaction) => void;
}
export interface GetItemCategoriesResponse {
data: ItemCategory[];
// filterMeta: IFilterMeta;
}
export type GetItemCategoriesResponse = ItemCategory[];
@@ -59,6 +59,6 @@ export class GetItemCategoriesService {
);
dynamicList.buildQuery()(query);
});
return { data };
return data;
}
}
@@ -35,6 +35,7 @@ import { GetSaleEstimatesQueryDto } from './dtos/GetSaleEstimatesQuery.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { SaleEstiamteStateResponseDto } from './dtos/SaleEstimateStateResponse.dto';
import { SaleEstimateHtmlContentResponseDto } from './dtos/SaleEstimateHtmlResponse.dto';
import { SaleEstimateMailStateResponseDto } from './dtos/SaleEstimateMailStateResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
@@ -48,12 +49,14 @@ import { SaleEstimateAction } from './types/SaleEstimates.types';
@Controller('sale-estimates')
@ApiTags('Sale Estimates')
@ApiExtraModels(SaleEstimateResponseDto)
@ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(SaleEstiamteStateResponseDto)
@ApiExtraModels(SaleEstimateHtmlContentResponseDto)
@ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiExtraModels(
SaleEstimateResponseDto,
PaginatedResponseDto,
SaleEstiamteStateResponseDto,
SaleEstimateHtmlContentResponseDto,
ValidateBulkDeleteResponseDto,
SaleEstimateMailStateResponseDto,
)
@UseGuards(AuthorizationGuard, PermissionGuard)
export class SaleEstimatesController {
@Post('validate-bulk-delete')
@@ -302,6 +305,11 @@ export class SaleEstimatesController {
@Get(':id/mail')
@RequirePermission(SaleEstimateAction.View, AbilitySubject.SaleEstimate)
@ApiOperation({ summary: 'Retrieves the sale estimate mail state.' })
@ApiResponse({
status: 200,
description: 'Retrieves the sale estimate mail state.',
schema: { $ref: getSchemaPath(SaleEstimateMailStateResponseDto) },
})
@ApiParam({
name: 'id',
required: true,
@@ -0,0 +1,127 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
class AddressItemDto {
@ApiProperty()
label: string;
@ApiProperty()
mail: string;
@ApiPropertyOptional()
primary?: boolean;
}
class SaleEstimateEntryMailDto {
@ApiProperty()
name: string;
@ApiProperty()
quantity: number;
@ApiProperty()
unitPrice: number;
@ApiProperty()
unitPriceFormatted: string;
@ApiProperty()
total: number;
@ApiProperty()
totalFormatted: string;
}
export class SaleEstimateMailStateResponseDto {
@ApiProperty({ type: [String] })
from: string[];
@ApiProperty({ type: [String] })
to: string[];
@ApiPropertyOptional({ type: [String] })
cc?: string[];
@ApiPropertyOptional({ type: [String] })
bcc?: string[];
@ApiProperty()
subject: string;
@ApiProperty()
message: string;
@ApiPropertyOptional()
formatArgs?: { customerName: string; estimateAmount: string };
@ApiProperty({ type: [AddressItemDto] })
toOptions: AddressItemDto[];
@ApiProperty({ type: [AddressItemDto] })
fromOptions: AddressItemDto[];
@ApiPropertyOptional()
attachEstimate?: boolean;
@ApiProperty()
estimateDate: string;
@ApiProperty()
estimateDateFormatted: string;
@ApiProperty()
expirationDate: string;
@ApiProperty()
expirationDateFormatted: string;
@ApiProperty()
total: number;
@ApiProperty()
totalFormatted: string;
@ApiProperty()
subtotal: number;
@ApiProperty()
subtotalFormatted: string;
@ApiProperty()
discountAmount: number;
@ApiProperty()
discountAmountFormatted: string;
@ApiProperty()
discountPercentage: number | null;
@ApiProperty()
discountPercentageFormatted: string;
@ApiProperty()
discountLabel: string;
@ApiProperty()
adjustment: number;
@ApiProperty()
adjustmentFormatted: string;
@ApiProperty()
estimateNumber: string;
@ApiProperty({ type: [SaleEstimateEntryMailDto] })
entries: SaleEstimateEntryMailDto[];
@ApiProperty()
companyName: string;
@ApiProperty()
companyLogoUri: string | null;
@ApiProperty()
primaryColor: string | null;
@ApiProperty()
customerName: string;
}
@@ -46,7 +46,7 @@ function ItemsCategoryTable({
<DataTable
noInitialFetch={true}
columns={columns}
data={itemsCategories}
data={itemsCategories || []}
loading={isCategoriesLoading}
headerLoading={isCategoriesLoading}
progressBarLoading={isCategoriesFetching}
@@ -22,9 +22,6 @@ const commonInvalidateQueries = (queryClient: ReturnType<typeof useQueryClient>)
queryClient.invalidateQueries({ queryKey: contactsKeys.all() });
};
/**
* Retrieve the contact by ID (for duplicate/form).
*/
export function useContact(
id: number | string | undefined | null,
props?: Omit<UseQueryOptions<ContactResponse>, 'queryKey' | 'queryFn'>
@@ -41,9 +38,6 @@ export function useContact(
});
}
/**
* Retrieve the auto-complete contacts.
*/
export function useAutoCompleteContacts(
props?: Omit<UseQueryOptions<Awaited<ReturnType<typeof fetchContactsAutoComplete>>>, 'queryKey' | 'queryFn'>
) {
@@ -57,9 +51,6 @@ export function useAutoCompleteContacts(
});
}
/**
* Activate the given Contact.
*/
export function useActivateContact(
props?: UseMutationOptions<void, Error, number>
) {
@@ -76,9 +67,6 @@ export function useActivateContact(
});
}
/**
* Inactivate the given contact.
*/
export function useInactivateContact(
props?: UseMutationOptions<void, Error, number>
) {
@@ -86,11 +86,7 @@ export function useBulkDeleteCustomers(
return useMutation({
...props,
mutationFn: ({
ids,
skipUndeletable = false,
}: {
ids: number[];
mutationFn: ({ ids, skipUndeletable = false }: { ids: number[];
skipUndeletable?: boolean;
}) =>
bulkDeleteCustomers(fetcher, {
@@ -12,6 +12,10 @@ import type {
CreateSaleEstimateBody,
EditSaleEstimateBody,
SaleEstimateHtmlContentResponse,
BulkDeleteEstimatesBody,
ValidateBulkDeleteEstimatesResponse,
SaleEstimatesStateResponse,
SaleEstimateMailStateResponse,
} from '@bigcapital/sdk-ts';
import {
fetchSaleEstimates,
@@ -36,14 +40,6 @@ import { estimatesKeys, EstimatesQueryKeys } from './query-keys';
import { itemsKeys } from '../items/query-keys';
import { useRequestPdf } from '../../useRequestPdf';
export type BulkDeleteEstimatesBody = { ids: number[]; skipUndeletable?: boolean };
export type ValidateBulkDeleteEstimatesResponse = {
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
};
// Keys that don't have factory methods yet - keeping inline
const SETTING = 'SETTING';
const SETTING_ESTIMATES = 'SETTING_ESTIMATES';
@@ -264,41 +260,6 @@ export function useSendSaleEstimateMail(
});
}
export interface SaleEstimateMailStateResponse {
attachEstimate: boolean;
companyLogoUri: string;
companyName: string;
customerName: string;
entries: Array<unknown>;
estimateDate: string;
estimateDateFormatted: string;
expirationDate: string;
expirationDateFormatted: string;
primaryColor: string;
total: number;
totalFormatted: string;
subtotal: number;
subtotalFormatted: string;
discountAmount: number;
discountAmountFormatted: string;
discountLabel: string;
discountPercentage: number | null;
discountPercentageFormatted: string;
adjustment: number;
adjustmentFormatted: string;
estimateNumber: string;
formatArgs: {
customerName: string;
estimateAmount: string;
};
from: Array<string>;
fromOptions: Array<unknown>;
message: string;
subject: string;
to: Array<string>;
toOptions: Array<unknown>;
}
export function useSaleEstimateMailState(
estimateId: number,
props?: UseQueryOptions<SaleEstimateMailStateResponse, Error>
@@ -307,23 +268,19 @@ export function useSaleEstimateMailState(
return useQuery({
...props,
queryKey: [EstimatesQueryKeys.SALE_ESTIMATE_MAIL_OPTIONS, estimateId],
queryFn: () => fetchSaleEstimateMail(fetcher, estimateId) as Promise<SaleEstimateMailStateResponse>,
queryFn: () => fetchSaleEstimateMail(fetcher, estimateId),
});
}
export interface ISaleEstimatesStateResponse {
defaultTemplateId: number;
}
export function useGetSaleEstimatesState(
options?: UseQueryOptions<ISaleEstimatesStateResponse, Error>
): UseQueryResult<ISaleEstimatesStateResponse, Error> {
options?: UseQueryOptions<SaleEstimatesStateResponse, Error>
): UseQueryResult<SaleEstimatesStateResponse, Error> {
const fetcher = useApiFetcher({ enableCamelCaseTransform: true });
return useQuery({
...options,
queryKey: ['SALE_ESTIMATE_STATE'],
queryFn: () => fetchSaleEstimatesState(fetcher) as Promise<ISaleEstimatesStateResponse>,
queryFn: () => fetchSaleEstimatesState(fetcher),
});
}
@@ -15,9 +15,6 @@ const commonInvalidateQueries = (queryClient) => {
queryClient.invalidateQueries({ queryKey: inventoryAdjustmentsKeys.all() });
};
/**
* Creates the inventory adjustment to the given item.
*/
export function useCreateInventoryAdjustment(props) {
const queryClient = useQueryClient();
const fetcher = useApiFetcher();
@@ -31,9 +28,6 @@ export function useCreateInventoryAdjustment(props) {
});
}
/**
* Deletes the inventory adjustment transaction.
*/
export function useDeleteInventoryAdjustment(props) {
const queryClient = useQueryClient();
const fetcher = useApiFetcher();
@@ -47,10 +41,6 @@ export function useDeleteInventoryAdjustment(props) {
});
}
/**
* Retrieve inventory adjustment list with pagination meta.
* Uses useRequestQuery because list endpoint query params may not be in OpenAPI.
*/
export function useInventoryAdjustments(query, props) {
const fetcher = useApiFetcher();
return useQuery({
@@ -60,9 +50,6 @@ export function useInventoryAdjustments(query, props) {
});
}
/**
* Publishes the given inventory adjustment.
*/
export function usePublishInventoryAdjustment(props) {
const queryClient = useQueryClient();
const fetcher = useApiFetcher();
@@ -77,10 +64,6 @@ export function usePublishInventoryAdjustment(props) {
});
}
/**
* Retrieve the inventory adjustment details.
* @param {number} id - inventory adjustment id.
*/
export function useInventoryAdjustment(id, props) {
const fetcher = useApiFetcher();
@@ -4,9 +4,6 @@ import { acceptInvite, fetchInviteCheck, resendInvite } from '@bigcapital/sdk-ts
import { useApiFetcher } from '../../useRequest';
import { inviteKeys } from './query-keys';
/**
* Authentication invite accept.
*/
export function useAuthInviteAccept(
props?: UseMutationOptions<unknown, Error, [Record<string, unknown>, string]>
) {
@@ -18,9 +15,6 @@ export function useAuthInviteAccept(
});
}
/**
* Retrieve the invite meta by the given token.
*/
export function useInviteMetaByToken(
token: string | null | undefined,
props?: Omit<UseQueryOptions<unknown>, 'queryKey' | 'queryFn'>
@@ -34,9 +28,6 @@ export function useInviteMetaByToken(
});
}
/**
* Resend invitation to user.
*/
export function useResendInvitation(
props?: UseMutationOptions<void, Error, number>
) {
@@ -24,11 +24,7 @@ import {
editSettings,
} from '@bigcapital/sdk-ts';
import { useApiFetcher } from '../../useRequest';
import {
settingsKeys,
SETTING,
SETTING_SMS_NOTIFICATIONS,
} from './query-keys';
import { settingsKeys } from './query-keys';
export function useSaveSettings(
props?: UseMutationOptions<void, Error, SaveSettingsBody>
@@ -40,7 +36,7 @@ export function useSaveSettings(
...props,
mutationFn: (values: SaveSettingsBody) => editSettings(fetcher, values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [SETTING] });
queryClient.invalidateQueries({ queryKey: settingsKeys.all() });
},
});
}
@@ -196,7 +192,7 @@ export function useSettingSMSNotification(
return useQuery({
...props,
queryKey: [SETTING_SMS_NOTIFICATIONS, key],
queryKey: settingsKeys.smsNotification(key),
queryFn: () => fetchSettingSMSNotification(fetcher, key),
enabled: !!key,
});
@@ -213,7 +209,7 @@ export function useSettingEditSMSNotification(
mutationFn: ({ key, values }: { key: string; values: Record<string, unknown> }) =>
editSettingSMSNotification(fetcher, key, values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [SETTING_SMS_NOTIFICATIONS] });
queryClient.invalidateQueries({ queryKey: settingsKeys.smsNotifications() });
},
});
}
+9 -2
View File
@@ -1,14 +1,17 @@
import { Fetcher } from 'openapi-typescript-fetch';
import type { paths } from './schema';
import { createCamelCaseMiddleware } from './middleware/camel-case-middleware';
import { createSnakeCaseRequestMiddleware } from './middleware/snake-case-request-middleware';
export type ApiFetcher = ReturnType<typeof Fetcher.for<paths>>;
export interface CreateApiFetcherConfig {
baseUrl?: string;
init?: RequestInit;
/** Set to true to disable automatic snake_case to camelCase transformation */
/** Set to true to disable automatic snake_case to camelCase transformation on responses */
disableCamelCaseTransform?: boolean;
/** Set to true to disable automatic camelCase to snake_case transformation on requests */
disableSnakeCaseTransform?: boolean;
}
/**
@@ -22,13 +25,17 @@ export function createApiFetcher(config?: CreateApiFetcherConfig): ApiFetcher {
const parsedConfig = {
baseUrl: '',
disableCamelCaseTransform: true,
disableSnakeCaseTransform: false,
...config,
};
const fetcher = Fetcher.for<paths>();
fetcher.configure({
baseUrl: parsedConfig.baseUrl,
init: parsedConfig?.init,
use: parsedConfig.disableCamelCaseTransform ? [] : [createCamelCaseMiddleware()],
use: [
...(parsedConfig.disableSnakeCaseTransform ? [] : [createSnakeCaseRequestMiddleware()]),
...(parsedConfig.disableCamelCaseTransform ? [] : [createCamelCaseMiddleware()]),
],
});
return fetcher;
}
@@ -0,0 +1,31 @@
import type { Middleware } from 'openapi-typescript-fetch';
import { camelToSnakeCase, transformKeysToSnakeCase } from '../utils/case-transform';
export function createSnakeCaseRequestMiddleware(): Middleware {
return async (url, init, next) => {
// Transform query string keys
const [base, search] = url.split('?');
let transformedUrl = base;
if (search) {
const params = new URLSearchParams(search);
const newParams = new URLSearchParams();
for (const [key, value] of params.entries()) {
newParams.append(camelToSnakeCase(key), value);
}
transformedUrl = `${base}?${newParams.toString()}`;
}
// Transform JSON body keys
let transformedInit = init;
const contentType = (init.headers as Record<string, string>)?.['content-type'] ?? '';
if (init.body && contentType.includes('application/json')) {
const parsed = JSON.parse(init.body as string);
transformedInit = {
...init,
body: JSON.stringify(transformKeysToSnakeCase(parsed)),
};
}
return next(transformedUrl, transformedInit);
};
}
+46 -12
View File
@@ -1,6 +1,6 @@
import type { ApiFetcher } from './fetch-utils';
import { rawRequest } from './fetch-utils';
import { paths } from './schema';
import { paths, components } from './schema';
import { OpForPath, OpQueryParams, OpRequestBody, OpResponseBody } from './utils';
export const SALE_ESTIMATES_ROUTES = {
@@ -23,6 +23,14 @@ export type CreateSaleEstimateBody = OpRequestBody<OpForPath<typeof SALE_ESTIMAT
export type EditSaleEstimateBody = OpRequestBody<OpForPath<typeof SALE_ESTIMATES_ROUTES.BY_ID, 'put'>>;
export type GetSaleEstimatesQuery = OpQueryParams<OpForPath<typeof SALE_ESTIMATES_ROUTES.LIST, 'get'>>;
export type SaleEstimateHtmlContentResponse = { htmlContent: string };
export type SaleEstimatesStateResponse = components['schemas']['SaleEstiamteStateResponseDto'];
export type BulkDeleteEstimatesBody = { ids: number[]; skipUndeletable?: boolean };
export type ValidateBulkDeleteEstimatesResponse = {
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
};
export async function fetchSaleEstimates(
fetcher: ApiFetcher,
@@ -61,13 +69,39 @@ export async function deleteSaleEstimate(fetcher: ApiFetcher, id: number): Promi
await del({ id });
}
export type BulkDeleteEstimatesBody = { ids: number[]; skipUndeletable?: boolean };
export type ValidateBulkDeleteEstimatesResponse = {
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
};
export interface SaleEstimateMailStateResponse {
from: string[];
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
message: string;
formatArgs?: { customerName: string; estimateAmount: string };
toOptions: Array<{ label: string; mail: string; primary?: boolean }>;
fromOptions: Array<{ label: string; mail: string; primary?: boolean }>;
attachEstimate?: boolean;
estimateDate: string;
estimateDateFormatted: string;
expirationDate: string;
expirationDateFormatted: string;
total: number;
totalFormatted: string;
subtotal: number;
subtotalFormatted: string;
discountAmount: number;
discountAmountFormatted: string;
discountPercentage: number | null;
discountPercentageFormatted: string;
discountLabel: string;
adjustment: number;
adjustmentFormatted: string;
estimateNumber: string;
entries: Array<{ name: string; quantity: number; unitPrice: number; unitPriceFormatted: string; total: number; totalFormatted: string }>;
companyName: string;
companyLogoUri: string | null;
primaryColor: string | null;
customerName: string;
}
export async function bulkDeleteSaleEstimates(
fetcher: ApiFetcher,
@@ -119,10 +153,10 @@ export async function fetchSaleEstimateSmsDetails(
return data;
}
export async function fetchSaleEstimateMail(fetcher: ApiFetcher, id: number): Promise<unknown> {
export async function fetchSaleEstimateMail(fetcher: ApiFetcher, id: number): Promise<SaleEstimateMailStateResponse> {
const get = fetcher.path(SALE_ESTIMATES_ROUTES.MAIL).method('get').create();
const { data } = await get({ id });
return data;
return data as SaleEstimateMailStateResponse;
}
export async function sendSaleEstimateMail(
@@ -134,10 +168,10 @@ export async function sendSaleEstimateMail(
await post({ id, ...(body ?? {}) } as never);
}
export async function fetchSaleEstimatesState(fetcher: ApiFetcher): Promise<unknown> {
export async function fetchSaleEstimatesState(fetcher: ApiFetcher): Promise<SaleEstimatesStateResponse> {
const get = fetcher.path(SALE_ESTIMATES_ROUTES.STATE).method('get').create();
const { data } = await get({});
return data;
return data as SaleEstimatesStateResponse;
}
export async function fetchSaleEstimateHtmlContent(
+58
View File
@@ -74,3 +74,61 @@ export function transformKeysToCamelCase<T>(value: unknown, cache?: TransformCac
return result as T;
}
/**
* Converts a camelCase string to snake_case.
*/
export function camelToSnakeCase(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Deeply transforms all keys in an object from camelCase to snake_case.
* Handles nested objects, arrays, null, undefined, and primitive values.
* Uses WeakMap caching to handle circular references safely.
*/
export function transformKeysToSnakeCase<T>(value: unknown, cache?: TransformCache): T {
if (value === null || value === undefined) {
return value as T;
}
if (typeof value !== 'object') {
return value as T;
}
if (value instanceof Date) {
return value as T;
}
if (value instanceof Blob) {
return value as T;
}
const localCache = cache ?? new WeakMap();
if (localCache.has(value as object)) {
return localCache.get(value as object);
}
if (Array.isArray(value)) {
const result: unknown[] = [];
localCache.set(value, result);
for (const item of value) {
result.push(transformKeysToSnakeCase(item, localCache));
}
return result as T;
}
const result: Record<string, unknown> = {};
localCache.set(value as object, result);
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
const snakeKey = camelToSnakeCase(key);
const itemValue = (value as Record<string, unknown>)[key];
result[snakeKey] = transformKeysToSnakeCase(itemValue, localCache);
}
}
return result as T;
}