feat: implement sales management module with product and order handling
- Introduced a new Sales module, consolidating routes for managing sales requests and orders. - Added a Products module for handling product details, including creation, editing, and viewing functionalities. - Implemented UI components for product forms and detail views, with validation schemas for product data. - Integrated language support for English and Indonesian in the new modules. - Developed unit tests for sales and product remote data services and transformers to ensure functionality and reliability. These changes enhance the application by providing a structured approach to sales management, improving user experience and data handling.
This commit is contained in:
@@ -9,7 +9,7 @@ const SystemInformation = lazy(() => import('./modules/system/information'));
|
||||
const SystemNotification = lazy(() => import('./modules/system/notification'));
|
||||
const PrivilegesModule = lazy(() => import('./modules/system/privileges/presentation/factory'));
|
||||
const ConfigurationModule = lazy(() => import('./modules/configuration'));
|
||||
const SalesFieldModule = lazy(() => import('./modules/field/sales'));
|
||||
const SalesModule = lazy(() => import('./modules/sales'));
|
||||
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
|
||||
|
||||
export default function AppModule() {
|
||||
@@ -23,7 +23,7 @@ export default function AppModule() {
|
||||
<Route path="/system/notifications" element={<SystemNotification />} />
|
||||
<Route path="/system/privileges/*" element={<PrivilegesModule />} />
|
||||
<Route path="/configuration/*" element={<ConfigurationModule />} />
|
||||
<Route path="/sales/*" element={<SalesFieldModule />} />
|
||||
<Route path="/sales/*" element={<SalesModule />} />
|
||||
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
Shield,
|
||||
FileSearch,
|
||||
Repeat,
|
||||
Package,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import type { MenuItemType } from '../types/menu.types';
|
||||
|
||||
@@ -80,11 +82,19 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
icon: FileText,
|
||||
path: '/app/sales/quotations',
|
||||
},
|
||||
{
|
||||
key: 'sales-requests',
|
||||
label: 'nav:sales-requests',
|
||||
icon: ClipboardList,
|
||||
path: '/app/sales/requests/index',
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
},
|
||||
{
|
||||
key: 'sales-orders',
|
||||
label: 'nav:sales-orders',
|
||||
icon: Box,
|
||||
path: '/app/sales/orders',
|
||||
path: '/app/sales/orders/index',
|
||||
moduleKey: 'SALES.ORDER',
|
||||
},
|
||||
{
|
||||
key: 'sales-invoices',
|
||||
@@ -308,6 +318,13 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
path: '/app/configuration/employees/index',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
},
|
||||
{
|
||||
key: 'configuration-products',
|
||||
label: 'nav:configuration-products',
|
||||
icon: Package,
|
||||
path: '/app/configuration/products/index',
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"crm-contacts": "Contacts",
|
||||
"sales": "Sales",
|
||||
"sales-quotations": "Quotations",
|
||||
"sales-requests": "Sales Requests",
|
||||
"sales-orders": "Sales Orders",
|
||||
"sales-invoices": "Invoices",
|
||||
"supply-chain": "Supply Chain",
|
||||
@@ -44,5 +45,6 @@
|
||||
"logistics": "Logistics",
|
||||
"logistics-cycles": "Logistics Cycles",
|
||||
"logistics-plans": "Logistics Plans",
|
||||
"configuration-employees": "Employees"
|
||||
"configuration-employees": "Employees",
|
||||
"configuration-products": "Products"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"crm-contacts": "Kontak",
|
||||
"sales": "Penjualan",
|
||||
"sales-quotations": "Penawaran",
|
||||
"sales-requests": "Permintaan Penjualan",
|
||||
"sales-orders": "Pesanan Penjualan",
|
||||
"sales-invoices": "Faktur",
|
||||
"supply-chain": "Rantai Pasok",
|
||||
@@ -44,5 +45,6 @@
|
||||
"logistics": "Logistik",
|
||||
"logistics-cycles": "Siklus Logistik",
|
||||
"logistics-plans": "Rencana Logistik",
|
||||
"configuration-employees": "Karyawan"
|
||||
"configuration-employees": "Karyawan",
|
||||
"configuration-products": "Produk"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const DivisionsModule = lazy(() => import('./divisions/presentation/factory'));
|
||||
const BranchesModule = lazy(() => import('./branches/presentation/factory'));
|
||||
const CustomersModule = lazy(() => import('./customers/presentation/factory'));
|
||||
const EmployeesModule = lazy(() => import('./employees/presentation/factory'));
|
||||
const ProductsModule = lazy(() => import('./products/presentation/factory'));
|
||||
|
||||
export default function ConfigurationModule() {
|
||||
return (
|
||||
@@ -13,6 +14,7 @@ export default function ConfigurationModule() {
|
||||
<Route path="/branches/*" element={<BranchesModule />} />
|
||||
<Route path="/customers/*" element={<CustomersModule />} />
|
||||
<Route path="/employees/*" element={<EmployeesModule />} />
|
||||
<Route path="/products/*" element={<ProductsModule />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import { ProductsRemoteDataServices } from './product.remote.service';
|
||||
import { ProductsRemoteDataTransformer } from '../domain/transformers/product.remote.transformer';
|
||||
|
||||
function createMockHttpClient(): AxiosInstance {
|
||||
return {
|
||||
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
|
||||
defaults: {} as AxiosInstance['defaults'],
|
||||
interceptors: {
|
||||
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
},
|
||||
getUri: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
head: vi.fn(),
|
||||
options: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
putForm: vi.fn(),
|
||||
patchForm: vi.fn(),
|
||||
} as unknown as AxiosInstance;
|
||||
}
|
||||
|
||||
describe('ProductsRemoteDataServices', () => {
|
||||
let httpClient: AxiosInstance;
|
||||
let service: ProductsRemoteDataServices;
|
||||
|
||||
beforeEach(() => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new ProductsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/products',
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
transformer: new ProductsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses PATCH when editing a product', async () => {
|
||||
await service.edit('prd-1', { name: 'Widget', code: 'SKU_001' } as any);
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/products/prd-1', method: 'PATCH' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('bulk-deletes via POST /products/bulk-delete', async () => {
|
||||
await service.batchDelete(['prd-1']);
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/products/bulk-delete', method: 'POST' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type { DataServicesConfig } from '@repo/core-api/data-services';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../../core/lib/trackgo-remote-data-services';
|
||||
import type { ProductEntity } from '../domain/entities';
|
||||
|
||||
export class ProductsRemoteDataServices extends TrackGoRemoteDataServices<ProductEntity> {
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<ProductEntity>) {
|
||||
super(httpClient, {
|
||||
...config,
|
||||
apiUrl: config.apiUrl ?? '/products',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './product.constants';
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { ProductEntity } from '../entities';
|
||||
|
||||
export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = {
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
translationNamespace: 'PRODUCTS',
|
||||
apiUrl: '/products',
|
||||
webUrl: '/app/configuration/products',
|
||||
moduleCategory: 'FULL_PAGE',
|
||||
moduleType: 'MASTER_DATA',
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './product.entity';
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
import type { ConfigurationStatus } from '../../../divisions/domain/entities';
|
||||
|
||||
export interface ProductEntity extends BaseEntity {
|
||||
code: string;
|
||||
name: string;
|
||||
unit?: string | null;
|
||||
price?: string | null;
|
||||
brand?: string | null;
|
||||
status?: ConfigurationStatus;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
unit?: string | null;
|
||||
price?: string | null;
|
||||
brand?: string | null;
|
||||
status?: ConfigurationStatus;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { ProductsRemoteDataServices } from '../../data/product.remote.service';
|
||||
import { productsModuleConfig } from '../constants/product.constants';
|
||||
import { ProductsRemoteDataTransformer } from '../transformers/product.remote.transformer';
|
||||
|
||||
export const productsDataTransformer = new ProductsRemoteDataTransformer();
|
||||
|
||||
export const productsDataService = new ProductsRemoteDataServices(apiClient, {
|
||||
apiUrl: productsModuleConfig.apiUrl,
|
||||
moduleKey: productsModuleConfig.moduleKey,
|
||||
transformer: productsDataTransformer,
|
||||
});
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ProductsRemoteDataTransformer } from './product.remote.transformer';
|
||||
import type { ProductEntity } from '../entities';
|
||||
|
||||
const transformer = new ProductsRemoteDataTransformer();
|
||||
|
||||
const dto = {
|
||||
id: 'prd-1',
|
||||
code: 'SKU_001',
|
||||
name: 'Widget Plus (2.0)',
|
||||
unit: 'PCS',
|
||||
price: '12500.0000',
|
||||
brand: 'Acme',
|
||||
status: 'active' as const,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
createdBy: 'u1',
|
||||
updatedBy: 'u2',
|
||||
};
|
||||
|
||||
describe('ProductsRemoteDataTransformer', () => {
|
||||
it('maps dto fields onto the entity', () => {
|
||||
const entity = transformer.transformToEntity(dto);
|
||||
expect(entity).toMatchObject({
|
||||
id: 'prd-1',
|
||||
code: 'SKU_001',
|
||||
name: 'Widget Plus (2.0)',
|
||||
unit: 'PCS',
|
||||
price: '12500.0000',
|
||||
brand: 'Acme',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits empty optional fields on create', () => {
|
||||
const entity: ProductEntity = {
|
||||
...dto,
|
||||
unit: '',
|
||||
price: '',
|
||||
brand: '',
|
||||
status: 'draft',
|
||||
};
|
||||
const payload = transformer.transformCreatePayload(entity);
|
||||
expect(payload).toEqual({
|
||||
code: 'SKU_001',
|
||||
name: 'Widget Plus (2.0)',
|
||||
});
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('converts empty optional fields to null on edit', () => {
|
||||
const payload = transformer.transformEditPayload({
|
||||
...dto,
|
||||
unit: '',
|
||||
price: '',
|
||||
brand: '',
|
||||
});
|
||||
expect(payload).toEqual({
|
||||
code: 'SKU_001',
|
||||
name: 'Widget Plus (2.0)',
|
||||
unit: null,
|
||||
price: null,
|
||||
brand: null,
|
||||
});
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
expect(payload).not.toHaveProperty('id');
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import type { ProductDto, ProductEntity } from '../entities';
|
||||
|
||||
export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEntity> {
|
||||
transformToEntity(dto: ProductDto | ProductEntity): ProductEntity {
|
||||
return {
|
||||
id: dto.id,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
unit: dto.unit ?? null,
|
||||
price: dto.price ?? null,
|
||||
brand: dto.brand ?? null,
|
||||
status: dto.status,
|
||||
createdAt: dto.createdAt,
|
||||
updatedAt: dto.updatedAt,
|
||||
createdBy: dto.createdBy,
|
||||
updatedBy: dto.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
transformToDTO(entity: ProductEntity): ProductEntity {
|
||||
return { ...entity };
|
||||
}
|
||||
|
||||
transformCreatePayload(entity: Partial<ProductEntity>): Partial<ProductEntity> {
|
||||
return omitEmptyFields({
|
||||
code: entity.code,
|
||||
name: entity.name,
|
||||
unit: entity.unit,
|
||||
price: entity.price,
|
||||
brand: entity.brand,
|
||||
});
|
||||
}
|
||||
|
||||
transformEditPayload(entity: Partial<ProductEntity>): Partial<ProductEntity> {
|
||||
return {
|
||||
code: entity.code,
|
||||
name: entity.name,
|
||||
unit: emptyToNull(entity.unit) as string | null,
|
||||
price: emptyToNull(entity.price) as string | null,
|
||||
brand: emptyToNull(entity.brand) as string | null,
|
||||
};
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createProductSchema } from './product.validator';
|
||||
|
||||
describe('createProductSchema', () => {
|
||||
const t = (key: string) => key;
|
||||
const schema = createProductSchema(t);
|
||||
const valid = {
|
||||
code: 'SKU_001',
|
||||
name: 'Widget Plus (2.0)',
|
||||
unit: 'PCS',
|
||||
price: '12500.0000',
|
||||
brand: 'Acme',
|
||||
};
|
||||
|
||||
it('accepts a complete payload', () => {
|
||||
expect(schema.safeParse(valid).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts optional unit, price, and brand as empty', () => {
|
||||
expect(schema.safeParse({ code: 'SKU_002', name: 'Plain Widget' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty code', () => {
|
||||
expect(schema.safeParse({ ...valid, code: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a name with unsupported characters', () => {
|
||||
expect(schema.safeParse({ ...valid, name: 'Widget @ Home' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a non-decimal price', () => {
|
||||
expect(schema.safeParse({ ...valid, price: '12.34567' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
import { compose, required, maxLength } from '@repo/ui/validators';
|
||||
import { configCodeSchema } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import { optionalDecimalStringSchema } from '../../../../../../../core/domain/decimal-string.schema';
|
||||
|
||||
const PRODUCT_NAME_MAX = 128;
|
||||
const PRODUCT_NAME_PATTERN = /^[A-Za-z0-9+\-./()]+(?: [A-Za-z0-9+\-./()]+)*$/;
|
||||
const PRODUCT_CODE_MAX = 32;
|
||||
|
||||
function emptyToUndefined(value: unknown) {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function productNameSchema(t: (key: string) => string) {
|
||||
return compose(
|
||||
z.string(),
|
||||
required(t('common:fields.name')),
|
||||
maxLength(PRODUCT_NAME_MAX, t('common:fields.name')),
|
||||
).regex(PRODUCT_NAME_PATTERN, {
|
||||
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.name') } }),
|
||||
});
|
||||
}
|
||||
|
||||
export const createProductSchema = (t: (key: string) => string) => {
|
||||
return z.object({
|
||||
code: configCodeSchema(t, PRODUCT_CODE_MAX),
|
||||
name: productNameSchema(t),
|
||||
unit: z.preprocess(
|
||||
emptyToUndefined,
|
||||
compose(z.string(), maxLength(16, t('common:fields.unit'))).optional(),
|
||||
),
|
||||
price: optionalDecimalStringSchema(t, 'common:fields.price'),
|
||||
brand: z.preprocess(
|
||||
emptyToUndefined,
|
||||
compose(z.string(), maxLength(64, t('common:fields.brand'))).optional(),
|
||||
),
|
||||
});
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import type { ProductEntity } from '../../../domain/entities';
|
||||
|
||||
export function DetailGeneral() {
|
||||
const { detailData } = useDetailPageContext<ProductEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const data = detailData;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||
<FieldValue label={t('common:fields.code')} value={data?.code} />
|
||||
<FieldValue label={t('common:fields.name')} value={data?.name} />
|
||||
<FieldValue label={t('common:fields.unit')} value={data?.unit} />
|
||||
<FieldValue label={t('common:fields.price')} value={data?.price} />
|
||||
<FieldValue label={t('common:fields.brand')} value={data?.brand} />
|
||||
<FieldValue
|
||||
label={t('common:fields.status')}
|
||||
value={data?.status}
|
||||
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.createdAt')}
|
||||
value={data?.createdAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.updatedAt')}
|
||||
value={data?.updatedAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { Box, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
|
||||
export function FormGeneral() {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="code"
|
||||
label={t('common:fields.code')}
|
||||
placeholder="e.g. SKU_001"
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
name="name"
|
||||
control={formControl.control}
|
||||
label={t('common:fields.name')}
|
||||
placeholder="e.g. Widget Plus (2.0)"
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="unit"
|
||||
label={t('common:fields.unit')}
|
||||
placeholder="e.g. PCS"
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="price"
|
||||
label={t('common:fields.price')}
|
||||
placeholder="12500.0000"
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="brand"
|
||||
label={t('common:fields.brand')}
|
||||
placeholder="e.g. Acme"
|
||||
radius="md"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { SimpleGrid } from '@repo/ui/components';
|
||||
import { FieldTextInput, FieldSelect } from '@repo/ui/form';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import { statusFilterOptions } from '../../../../shared/status-filter-options';
|
||||
|
||||
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="code"
|
||||
label={t('common:fields.code')}
|
||||
placeholder={`Enter ${t('common:fields.code')}`}
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="name"
|
||||
label={t('common:fields.name')}
|
||||
placeholder={`Enter ${t('common:fields.name')}`}
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="unit"
|
||||
label={t('common:fields.unit')}
|
||||
placeholder={`Enter ${t('common:fields.unit')}`}
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="brand"
|
||||
label={t('common:fields.brand')}
|
||||
placeholder={`Enter ${t('common:fields.brand')}`}
|
||||
/>
|
||||
<FieldSelect
|
||||
control={form.control}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
clearable
|
||||
data={statusFilterOptions(t)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { productsModuleConfig } from '../../domain/constants';
|
||||
import { productsDataService } from '../../domain/factories';
|
||||
import { ProductEntity } from '../../domain/entities';
|
||||
import { productsStore } from '../store';
|
||||
|
||||
import productsId from '../languages/id/products.json';
|
||||
import productsEn from '../languages/en/products.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/product.page.index'));
|
||||
const FormPage = lazy(() => import('../pages/product.page.form'));
|
||||
const DetailPage = lazy(() => import('../pages/product.page.detail'));
|
||||
|
||||
registerModuleNamespace(productsModuleConfig.translationNamespace, {
|
||||
id: productsId,
|
||||
en: productsEn,
|
||||
});
|
||||
|
||||
export default function ProductsModule() {
|
||||
return (
|
||||
<EnterpriseModuleProvider<ProductEntity>
|
||||
config={productsModuleConfig}
|
||||
dataServices={productsDataService}
|
||||
store={productsStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||
<Route path="/" element={<Navigate to={`${productsModuleConfig.webUrl}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"title": "Products",
|
||||
"detail_page_title": "Product Detail",
|
||||
"create_page_title": "New Product",
|
||||
"edit_page_title": "Edit Product",
|
||||
"duplicate_page_title": "Duplicate Product",
|
||||
"description": "Manage <1>products</1> used on sales requests and sales orders.",
|
||||
"detail_page_description": "Review product identity, unit, price, and brand.",
|
||||
"create_page_description": "Create a product with a unique code and name.",
|
||||
"edit_page_description": "Update product identity, unit, price, and brand.",
|
||||
"duplicate_page_description": "Copy an existing product to create a new one.",
|
||||
"section_general": "General",
|
||||
"status_draft": "Draft",
|
||||
"status_active": "Active",
|
||||
"status_archived": "Archived"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"title": "Produk",
|
||||
"detail_page_title": "Detail Produk",
|
||||
"create_page_title": "Produk Baru",
|
||||
"edit_page_title": "Ubah Produk",
|
||||
"duplicate_page_title": "Duplikat Produk",
|
||||
"description": "Kelola <1>produk</1> yang dipakai pada permintaan penjualan dan pesanan penjualan.",
|
||||
"detail_page_description": "Tinjau identitas, satuan, harga, dan merek produk.",
|
||||
"create_page_description": "Buat produk dengan kode unik dan nama.",
|
||||
"edit_page_description": "Perbarui identitas, satuan, harga, dan merek produk.",
|
||||
"duplicate_page_description": "Salin produk yang ada untuk membuat data baru.",
|
||||
"section_general": "Umum",
|
||||
"status_draft": "Draft",
|
||||
"status_active": "Aktif",
|
||||
"status_archived": "Diarsipkan"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { productsModuleConfig } from '../../domain/constants';
|
||||
import { DetailGeneral } from '../components/detail-component/detail-general';
|
||||
|
||||
export default function ProductPageDetail() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
return (
|
||||
<EnterpriseDetailPageProvider
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: t('detail_page_title'),
|
||||
description: t('detail_page_description'),
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:configuration'), type: 'text' },
|
||||
{ label: t('nav:configuration-products'), type: 'link', href: `${productsModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<DetailGeneral />
|
||||
</EnterpriseDetailPageProvider>
|
||||
);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { productsModuleConfig } from '../../domain/constants';
|
||||
import { createProductSchema } from '../../domain/validators/product.validator';
|
||||
import { FormGeneral } from '../components/form-component/form-general';
|
||||
|
||||
export default function ProductPageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (formPageType === 'CREATE') {
|
||||
return { title: t('create_page_title'), description: t('create_page_description') };
|
||||
}
|
||||
if (formPageType === 'EDIT') {
|
||||
return { title: t('edit_page_title'), description: t('edit_page_description') };
|
||||
}
|
||||
if (formPageType === 'DUPLICATE') {
|
||||
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
|
||||
}
|
||||
return { title: '', description: '' };
|
||||
}, [formPageType, t]);
|
||||
|
||||
const validator = useMemo(() => createProductSchema(t), [t]);
|
||||
const formControl = useForm({ resolver: zodResolver(validator) });
|
||||
|
||||
return (
|
||||
<EnterpriseFormPageProvider
|
||||
formControl={formControl}
|
||||
formPageType={formPageType}
|
||||
ignoreKeyDuplicate={['code']}
|
||||
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: title?.title,
|
||||
description: title?.description,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:configuration'), type: 'text' },
|
||||
{ label: t('nav:configuration-products'), type: 'link', href: `${productsModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<FormGeneral />
|
||||
</EnterpriseFormPageProvider>
|
||||
);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
EnterpriseIndexPageProvider,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
EnterpriseDataTable,
|
||||
} from '@repo/ui/foundations';
|
||||
import { ColDef, Text } from '@repo/ui/components';
|
||||
import { Trans } from '@repo/core-i18n';
|
||||
import { Package } from 'lucide-react';
|
||||
import { FilterFormContent } from '../components/index-component/filter-content';
|
||||
import type { ProductEntity } from '../../domain/entities';
|
||||
|
||||
export default function ProductPageIndex() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
const columnDefs: ColDef<ProductEntity>[] = useMemo(() => {
|
||||
return [
|
||||
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
|
||||
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
|
||||
{ field: 'unit', headerName: t('common:fields.unit'), minWidth: 100 },
|
||||
{ field: 'price', headerName: t('common:fields.price'), minWidth: 140 },
|
||||
{ field: 'brand', headerName: t('common:fields.brand'), minWidth: 140 },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const filterConfig = useMemo(() => {
|
||||
return {
|
||||
renderBody: (form: any) => {
|
||||
if (!form) return null;
|
||||
return <FilterFormContent form={form} t={t} />;
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<EnterpriseIndexPageProvider
|
||||
pageHeaderProps={{
|
||||
title: t('title'),
|
||||
description: (
|
||||
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||
),
|
||||
icon: Package,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:configuration'), type: 'text' },
|
||||
{ label: t('nav:configuration-products'), type: 'text' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
|
||||
</EnterpriseIndexPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import { ProductEntity } from '../../domain/entities';
|
||||
|
||||
export interface ProductsStoreState extends EnterpriseModuleState<ProductEntity> {}
|
||||
|
||||
export const productsStore = create<ProductsStoreState>((set) => ({
|
||||
metaData: { limit: 15 },
|
||||
setMetaData: (data) => set({ metaData: data }),
|
||||
|
||||
filterData: {},
|
||||
setFilterData: (data) => set({ filterData: data }),
|
||||
|
||||
selectedRows: [],
|
||||
setSelectedRows: (rows) => set({ selectedRows: rows }),
|
||||
|
||||
privileges: [],
|
||||
setPrivileges: (privileges) => set({ privileges }),
|
||||
|
||||
tableConfig: null,
|
||||
setTableConfig: (config) => set({ tableConfig: config }),
|
||||
}));
|
||||
@@ -1,15 +0,0 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
|
||||
const PlansModule = lazy(() => import('../plans/presentation/factory'));
|
||||
|
||||
export default function SalesFieldModule() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/cycles/*" element={<CyclesModule purpose="sales" />} />
|
||||
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
const RequestsModule = lazy(() => import('./requests/presentation/factory'));
|
||||
const OrdersModule = lazy(() => import('./orders/presentation/factory'));
|
||||
const CyclesModule = lazy(() => import('../field/cycles/presentation/factory'));
|
||||
const PlansModule = lazy(() => import('../field/plans/presentation/factory'));
|
||||
|
||||
export default function SalesModule() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/requests/*" element={<RequestsModule />} />
|
||||
<Route path="/orders/*" element={<OrdersModule />} />
|
||||
<Route path="/cycles/*" element={<CyclesModule purpose="sales" />} />
|
||||
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import { SalesOrdersRemoteDataServices } from './sales-order.remote.service';
|
||||
import { SalesOrdersRemoteDataTransformer } from '../domain/transformers/sales-order.remote.transformer';
|
||||
|
||||
function createMockHttpClient(): AxiosInstance {
|
||||
return {
|
||||
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
|
||||
defaults: {} as AxiosInstance['defaults'],
|
||||
interceptors: {
|
||||
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
},
|
||||
getUri: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
head: vi.fn(),
|
||||
options: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
putForm: vi.fn(),
|
||||
patchForm: vi.fn(),
|
||||
} as unknown as AxiosInstance;
|
||||
}
|
||||
|
||||
describe('SalesOrdersRemoteDataServices', () => {
|
||||
let httpClient: AxiosInstance;
|
||||
let service: SalesOrdersRemoteDataServices;
|
||||
|
||||
beforeEach(() => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SalesOrdersRemoteDataServices(httpClient, {
|
||||
apiUrl: '/sales-orders',
|
||||
moduleKey: 'SALES.ORDER',
|
||||
transformer: new SalesOrdersRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses PATCH when editing', async () => {
|
||||
await service.edit('so-1', { address: 'Jl Sudirman 1' } as any);
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/sales-orders/so-1', method: 'PATCH' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('bulk-changes status via POST /bulk-status', async () => {
|
||||
await service.bulkChangeStatus(['so-1'], 'processed');
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/sales-orders/bulk-status',
|
||||
method: 'POST',
|
||||
data: { ids: ['so-1'], status: 'processed' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type { DataServicesConfig } from '@repo/core-api/data-services';
|
||||
import { SalesDocumentRemoteDataServices } from '../../shared/sales-document.remote.service';
|
||||
import type { SalesOrderEntity } from '../domain/entities';
|
||||
|
||||
export class SalesOrdersRemoteDataServices extends SalesDocumentRemoteDataServices<SalesOrderEntity> {
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<SalesOrderEntity>) {
|
||||
super(httpClient, {
|
||||
...config,
|
||||
apiUrl: config.apiUrl ?? '/sales-orders',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sales-order.constants';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { SalesOrderEntity } from '../entities';
|
||||
|
||||
export const salesOrdersModuleConfig: ModuleConfigEntity<SalesOrderEntity> = {
|
||||
moduleKey: 'SALES.ORDER',
|
||||
translationNamespace: 'SALES_ORDERS',
|
||||
apiUrl: '/sales-orders',
|
||||
webUrl: '/app/sales/orders',
|
||||
moduleCategory: 'FULL_PAGE',
|
||||
moduleType: 'TRANSACTION',
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sales-order.entity';
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SalesDocumentDto, SalesDocumentEntity } from '../../../shared/sales-document.entity';
|
||||
|
||||
export interface SalesOrderEntity extends SalesDocumentEntity {
|
||||
salesRequestId?: string | null;
|
||||
salesRequest?: { id: string; code?: string; name?: string } | null;
|
||||
}
|
||||
|
||||
export interface SalesOrderDto extends SalesDocumentDto {
|
||||
salesRequestId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { SalesOrdersRemoteDataServices } from '../../data/sales-order.remote.service';
|
||||
import { salesOrdersModuleConfig } from '../constants/sales-order.constants';
|
||||
import { SalesOrdersRemoteDataTransformer } from '../transformers/sales-order.remote.transformer';
|
||||
|
||||
export const salesOrdersDataTransformer = new SalesOrdersRemoteDataTransformer();
|
||||
|
||||
export const salesOrdersDataService = new SalesOrdersRemoteDataServices(apiClient, {
|
||||
apiUrl: salesOrdersModuleConfig.apiUrl,
|
||||
moduleKey: salesOrdersModuleConfig.moduleKey,
|
||||
transformer: salesOrdersDataTransformer,
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SalesOrdersRemoteDataTransformer } from './sales-order.remote.transformer';
|
||||
|
||||
const transformer = new SalesOrdersRemoteDataTransformer();
|
||||
|
||||
describe('SalesOrdersRemoteDataTransformer', () => {
|
||||
it('includes salesRequestId on create and omits it on edit', () => {
|
||||
const entity = {
|
||||
date: '2026-08-26',
|
||||
salesPerson: { id: 'emp-1' } as any,
|
||||
branch: { id: 'br-1' } as any,
|
||||
division: { id: 'div-1' } as any,
|
||||
customer: { id: 'cus-1' } as any,
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ product: { id: 'prd-1' } as any, quantity: '1', price: '10.0000' }],
|
||||
salesRequest: { id: 'sr-1' } as any,
|
||||
};
|
||||
|
||||
const createPayload = transformer.transformCreatePayload(entity);
|
||||
const editPayload = transformer.transformEditPayload(entity);
|
||||
|
||||
expect(createPayload.salesRequestId).toBe('sr-1');
|
||||
expect(editPayload).not.toHaveProperty('salesRequestId');
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import {
|
||||
mapSalesDocumentFromDto,
|
||||
toRelationStub,
|
||||
toSalesFilterPayload,
|
||||
toSalesWritePayload,
|
||||
} from '../../../shared/sales-document.mapper';
|
||||
import type { SalesOrderDto, SalesOrderEntity } from '../entities';
|
||||
|
||||
export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer<SalesOrderEntity> {
|
||||
transformToEntity(dto: SalesOrderDto | SalesOrderEntity): SalesOrderEntity {
|
||||
const base = mapSalesDocumentFromDto(dto as SalesOrderDto);
|
||||
return {
|
||||
...base,
|
||||
salesRequestId: (dto as SalesOrderDto).salesRequestId ?? null,
|
||||
salesRequest: toRelationStub((dto as SalesOrderDto).salesRequestId),
|
||||
};
|
||||
}
|
||||
|
||||
transformToDTO(entity: SalesOrderEntity): SalesOrderEntity {
|
||||
return { ...entity };
|
||||
}
|
||||
|
||||
transformCreatePayload(entity: Partial<SalesOrderEntity>): Partial<SalesOrderEntity> {
|
||||
return omitEmptyFields(toSalesWritePayload(entity, { includeSalesRequestId: true })) as Partial<SalesOrderEntity>;
|
||||
}
|
||||
|
||||
transformEditPayload(entity: Partial<SalesOrderEntity>): Partial<SalesOrderEntity> {
|
||||
return toSalesWritePayload(entity) as Partial<SalesOrderEntity>;
|
||||
}
|
||||
|
||||
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
|
||||
return toSalesFilterPayload(filter);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Box, FieldAsyncSelect, Paper, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { loadSalesRequestOptions } from '../../../../shared/load-sales-request-options';
|
||||
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||
import { salesRequestToFormValues } from '../../../../shared/sales-document.mapper';
|
||||
import { salesRequestsDataService } from '../../../../requests/domain/factories';
|
||||
import type { SalesRequestEntity } from '../../../../requests/domain/entities';
|
||||
|
||||
export function FormSalesRequest() {
|
||||
const { formControl, isCreate } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const salesRequest = formControl.watch('salesRequest');
|
||||
const appliedId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = salesRequest?.id;
|
||||
if (!isCreate || !id || appliedId.current === id) return;
|
||||
appliedId.current = id;
|
||||
void salesRequestsDataService.getOne(id).then((result: { data?: { data?: SalesRequestEntity } }) => {
|
||||
const entity = (result.data as { data?: SalesRequestEntity })?.data;
|
||||
if (!entity) return;
|
||||
formControl.reset({
|
||||
...formControl.getValues(),
|
||||
salesRequest,
|
||||
...salesRequestToFormValues(entity),
|
||||
});
|
||||
});
|
||||
}, [formControl, isCreate, salesRequest]);
|
||||
|
||||
if (!isCreate) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_source')}
|
||||
</Text>
|
||||
<Box>
|
||||
<FieldAsyncSelect<SalesRequestEntity>
|
||||
control={formControl.control}
|
||||
name="salesRequest"
|
||||
label={t('common:fields.salesRequest')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
clearable
|
||||
loadOptions={loadSalesRequestOptions}
|
||||
defaultOptions={salesRequest ? [salesRequest] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { salesOrdersModuleConfig } from '../../domain/constants';
|
||||
import { salesOrdersDataService } from '../../domain/factories';
|
||||
import { SalesOrderEntity } from '../../domain/entities';
|
||||
import { salesOrdersStore } from '../store';
|
||||
|
||||
import salesOrdersId from '../languages/id/sales-orders.json';
|
||||
import salesOrdersEn from '../languages/en/sales-orders.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/sales-order.page.index'));
|
||||
const FormPage = lazy(() => import('../pages/sales-order.page.form'));
|
||||
const DetailPage = lazy(() => import('../pages/sales-order.page.detail'));
|
||||
|
||||
registerModuleNamespace(salesOrdersModuleConfig.translationNamespace, {
|
||||
id: salesOrdersId,
|
||||
en: salesOrdersEn,
|
||||
});
|
||||
|
||||
export default function SalesOrdersModule() {
|
||||
return (
|
||||
<EnterpriseModuleProvider<SalesOrderEntity>
|
||||
config={salesOrdersModuleConfig}
|
||||
dataServices={salesOrdersDataService}
|
||||
store={salesOrdersStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||
<Route path="/" element={<Navigate to={`${salesOrdersModuleConfig.webUrl}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Sales Orders",
|
||||
"detail_page_title": "Sales Order Detail",
|
||||
"create_page_title": "New Sales Order",
|
||||
"edit_page_title": "Edit Sales Order",
|
||||
"duplicate_page_title": "Duplicate Sales Order",
|
||||
"description": "Create <1>sales orders</1> from a sales request or as a standalone document.",
|
||||
"detail_page_description": "Review order header, location, products, and images.",
|
||||
"create_page_description": "Create a sales order, optionally sourced from a sales request.",
|
||||
"edit_page_description": "Update order header, location, products, and images.",
|
||||
"duplicate_page_description": "Copy an existing sales order to create a new one.",
|
||||
"section_general": "General",
|
||||
"section_source": "Source",
|
||||
"section_location": "Location",
|
||||
"section_products": "Products",
|
||||
"section_images": "Images",
|
||||
"section_notes": "Notes",
|
||||
"add_line": "Add line",
|
||||
"remove_line": "Remove line",
|
||||
"add_image": "Add image",
|
||||
"remove_image": "Remove image",
|
||||
"empty_products": "No product lines.",
|
||||
"empty_images": "No images.",
|
||||
"change_status": "Change status",
|
||||
"import_csv": "Import CSV",
|
||||
"csv_file": "CSV file",
|
||||
"import_success": "CSV imported.",
|
||||
"status_updated": "Status updated.",
|
||||
"action_process": "Process",
|
||||
"action_complete": "Complete",
|
||||
"action_cancel": "Cancel",
|
||||
"status_draft": "Draft",
|
||||
"status_processed": "Processed",
|
||||
"status_completed": "Completed",
|
||||
"status_cancelled": "Cancelled"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Pesanan Penjualan",
|
||||
"detail_page_title": "Detail Pesanan Penjualan",
|
||||
"create_page_title": "Pesanan Penjualan Baru",
|
||||
"edit_page_title": "Ubah Pesanan Penjualan",
|
||||
"duplicate_page_title": "Duplikat Pesanan Penjualan",
|
||||
"description": "Buat <1>pesanan penjualan</1> dari permintaan penjualan atau sebagai dokumen mandiri.",
|
||||
"detail_page_description": "Tinjau header, lokasi, produk, dan gambar pesanan.",
|
||||
"create_page_description": "Buat pesanan penjualan, opsional dari permintaan penjualan.",
|
||||
"edit_page_description": "Perbarui header, lokasi, produk, dan gambar pesanan.",
|
||||
"duplicate_page_description": "Salin pesanan penjualan yang ada untuk membuat data baru.",
|
||||
"section_general": "Umum",
|
||||
"section_source": "Sumber",
|
||||
"section_location": "Lokasi",
|
||||
"section_products": "Produk",
|
||||
"section_images": "Gambar",
|
||||
"section_notes": "Catatan",
|
||||
"add_line": "Tambah baris",
|
||||
"remove_line": "Hapus baris",
|
||||
"add_image": "Tambah gambar",
|
||||
"remove_image": "Hapus gambar",
|
||||
"empty_products": "Tidak ada baris produk.",
|
||||
"empty_images": "Tidak ada gambar.",
|
||||
"change_status": "Ubah status",
|
||||
"import_csv": "Impor CSV",
|
||||
"csv_file": "File CSV",
|
||||
"import_success": "CSV berhasil diimpor.",
|
||||
"status_updated": "Status diperbarui.",
|
||||
"action_process": "Proses",
|
||||
"action_complete": "Selesaikan",
|
||||
"action_cancel": "Batalkan",
|
||||
"status_draft": "Draft",
|
||||
"status_processed": "Diproses",
|
||||
"status_completed": "Selesai",
|
||||
"status_cancelled": "Dibatalkan"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { salesOrdersModuleConfig } from '../../domain/constants';
|
||||
import { DetailGeneral } from '../../../shared/detail-general';
|
||||
import { DetailLocation } from '../../../shared/detail-location';
|
||||
import { DetailProducts } from '../../../shared/detail-products';
|
||||
import { DetailImages } from '../../../shared/detail-images';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
|
||||
import type { SalesOrderEntity } from '../../domain/entities';
|
||||
|
||||
export default function SalesOrderPageDetail() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const actions = useSalesDocumentActions('order');
|
||||
|
||||
return (
|
||||
<EnterpriseDetailPageProvider
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: t('detail_page_title'),
|
||||
description: t('detail_page_description'),
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:sales'), type: 'text' },
|
||||
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
customPageActions={(data, pageActions) => actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
|
||||
<DetailLocation />
|
||||
<DetailProducts />
|
||||
<DetailImages />
|
||||
</Stack>
|
||||
{actions.modals}
|
||||
</EnterpriseDetailPageProvider>
|
||||
);
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { salesOrdersModuleConfig } from '../../domain/constants';
|
||||
import { createSalesOrderSchema } from '../../../shared/sales-document.validator';
|
||||
import { FormSalesRequest } from '../components/form-component/form-sales-request';
|
||||
import { FormGeneral } from '../../../shared/form-general';
|
||||
import { FormLocation } from '../../../shared/form-location';
|
||||
import { FormProducts } from '../../../shared/form-products';
|
||||
import { FormImages } from '../../../shared/form-images';
|
||||
import { FormNotes } from '../../../shared/form-notes';
|
||||
import { salesRequestsDataService } from '../../../requests/domain/factories';
|
||||
import { salesRequestToFormValues } from '../../../shared/sales-document.mapper';
|
||||
import type { SalesRequestEntity } from '../../../requests/domain/entities';
|
||||
|
||||
export default function SalesOrderPageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const prefilled = useRef(false);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (formPageType === 'CREATE') {
|
||||
return { title: t('create_page_title'), description: t('create_page_description') };
|
||||
}
|
||||
if (formPageType === 'EDIT') {
|
||||
return { title: t('edit_page_title'), description: t('edit_page_description') };
|
||||
}
|
||||
if (formPageType === 'DUPLICATE') {
|
||||
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
|
||||
}
|
||||
return { title: '', description: '' };
|
||||
}, [formPageType, t]);
|
||||
|
||||
const validator = useMemo(() => createSalesOrderSchema(t), [t]);
|
||||
const formControl = useForm({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
products: [{ quantity: '1', price: '' }],
|
||||
images: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const salesRequestId = searchParams.get('salesRequestId');
|
||||
if (formPageType !== 'CREATE' || !salesRequestId || prefilled.current) return;
|
||||
prefilled.current = true;
|
||||
void salesRequestsDataService.getOne(salesRequestId).then((result) => {
|
||||
const entity = (result.data as { data?: SalesRequestEntity })?.data;
|
||||
if (!entity) return;
|
||||
formControl.reset({
|
||||
salesRequest: { id: entity.id as string, code: entity.code ?? undefined },
|
||||
...salesRequestToFormValues(entity),
|
||||
} as any);
|
||||
});
|
||||
}, [formControl, formPageType, searchParams]);
|
||||
|
||||
return (
|
||||
<EnterpriseFormPageProvider
|
||||
formControl={formControl}
|
||||
formPageType={formPageType}
|
||||
ignoreKeyDuplicate={['code']}
|
||||
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'salesRequestId', 'salesRequest']}
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: title?.title,
|
||||
description: title?.description,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:sales'), type: 'text' },
|
||||
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<FormSalesRequest />
|
||||
<FormGeneral />
|
||||
<FormLocation />
|
||||
<FormProducts />
|
||||
<FormImages />
|
||||
<FormNotes />
|
||||
</Stack>
|
||||
</EnterpriseFormPageProvider>
|
||||
);
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
EnterpriseIndexPageProvider,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
EnterpriseDataTable,
|
||||
} from '@repo/ui/foundations';
|
||||
import { ColDef, Text } from '@repo/ui/components';
|
||||
import { Trans } from '@repo/core-i18n';
|
||||
import { ShoppingCart } from 'lucide-react';
|
||||
import { SalesFilterFormContent } from '../../../shared/filter-content';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { relationLabel } from '../../../../field/shared/relation-label';
|
||||
import type { SalesOrderEntity } from '../../domain/entities';
|
||||
|
||||
export default function SalesOrderPageIndex() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const actions = useSalesDocumentActions('order');
|
||||
|
||||
const columnDefs: ColDef<SalesOrderEntity>[] = useMemo(
|
||||
() => [
|
||||
{ field: 'code', headerName: t('common:fields.code'), minWidth: 160 },
|
||||
{ field: 'date', headerName: t('common:fields.date'), minWidth: 140 },
|
||||
{
|
||||
field: 'customerId',
|
||||
headerName: t('common:fields.customer'),
|
||||
minWidth: 180,
|
||||
valueGetter: ({ data }) => relationLabel(data?.customer) || data?.customerId,
|
||||
},
|
||||
{
|
||||
field: 'salesPersonId',
|
||||
headerName: t('common:fields.salesPerson'),
|
||||
minWidth: 180,
|
||||
valueGetter: ({ data }) => relationLabel(data?.salesPerson) || data?.salesPersonId,
|
||||
},
|
||||
{
|
||||
field: 'branchId',
|
||||
headerName: t('common:fields.branch'),
|
||||
minWidth: 160,
|
||||
valueGetter: ({ data }) => relationLabel(data?.branch) || data?.branchId,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const filterConfig = useMemo(
|
||||
() => ({
|
||||
renderBody: (form: any) => (form ? <SalesFilterFormContent form={form} t={t} documentType="order" /> : null),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<EnterpriseIndexPageProvider
|
||||
pageHeaderProps={{
|
||||
title: t('title'),
|
||||
description: (
|
||||
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||
),
|
||||
icon: ShoppingCart,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:sales'), type: 'text' },
|
||||
{ label: t('nav:sales-orders'), type: 'text' },
|
||||
],
|
||||
}}
|
||||
customPageActions={(pageActions) =>
|
||||
actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions
|
||||
}
|
||||
>
|
||||
<EnterpriseDataTable
|
||||
columnDefs={columnDefs}
|
||||
filterConfig={filterConfig}
|
||||
customRowActions={(data, defaultActions) => actions.namedRowActions(data, defaultActions)}
|
||||
customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)}
|
||||
/>
|
||||
{actions.modals}
|
||||
</EnterpriseIndexPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import { SalesOrderEntity } from '../../domain/entities';
|
||||
|
||||
export interface SalesOrdersStoreState extends EnterpriseModuleState<SalesOrderEntity> {}
|
||||
|
||||
export const salesOrdersStore = create<SalesOrdersStoreState>((set) => ({
|
||||
metaData: { limit: 15 },
|
||||
setMetaData: (data) => set({ metaData: data }),
|
||||
filterData: {},
|
||||
setFilterData: (data) => set({ filterData: data }),
|
||||
selectedRows: [],
|
||||
setSelectedRows: (rows) => set({ selectedRows: rows }),
|
||||
privileges: [],
|
||||
setPrivileges: (privileges) => set({ privileges }),
|
||||
tableConfig: null,
|
||||
setTableConfig: (config) => set({ tableConfig: config }),
|
||||
}));
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import { SalesRequestsRemoteDataServices } from './sales-request.remote.service';
|
||||
import { SalesRequestsRemoteDataTransformer } from '../domain/transformers/sales-request.remote.transformer';
|
||||
|
||||
function createMockHttpClient(): AxiosInstance {
|
||||
return {
|
||||
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
|
||||
defaults: {} as AxiosInstance['defaults'],
|
||||
interceptors: {
|
||||
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
},
|
||||
getUri: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
head: vi.fn(),
|
||||
options: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
putForm: vi.fn(),
|
||||
patchForm: vi.fn(),
|
||||
} as unknown as AxiosInstance;
|
||||
}
|
||||
|
||||
describe('SalesRequestsRemoteDataServices', () => {
|
||||
let httpClient: AxiosInstance;
|
||||
let service: SalesRequestsRemoteDataServices;
|
||||
|
||||
beforeEach(() => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SalesRequestsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/sales-requests',
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
transformer: new SalesRequestsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses PATCH when editing', async () => {
|
||||
await service.edit('sr-1', { address: 'Jl Sudirman 1' } as any);
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/sales-requests/sr-1', method: 'PATCH' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('changes status via PATCH /:id/status', async () => {
|
||||
await service.changeStatus('sr-1', 'pending');
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/sales-requests/sr-1/status', method: 'PATCH' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type { DataServicesConfig } from '@repo/core-api/data-services';
|
||||
import { SalesDocumentRemoteDataServices } from '../../shared/sales-document.remote.service';
|
||||
import type { SalesRequestEntity } from '../domain/entities';
|
||||
|
||||
export class SalesRequestsRemoteDataServices extends SalesDocumentRemoteDataServices<SalesRequestEntity> {
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<SalesRequestEntity>) {
|
||||
super(httpClient, {
|
||||
...config,
|
||||
apiUrl: config.apiUrl ?? '/sales-requests',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sales-request.constants';
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { SalesRequestEntity } from '../entities';
|
||||
|
||||
export const salesRequestsModuleConfig: ModuleConfigEntity<SalesRequestEntity> = {
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
translationNamespace: 'SALES_REQUESTS',
|
||||
apiUrl: '/sales-requests',
|
||||
webUrl: '/app/sales/requests',
|
||||
moduleCategory: 'FULL_PAGE',
|
||||
moduleType: 'TRANSACTION',
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sales-request.entity';
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SalesDocumentDto, SalesDocumentEntity } from '../../../shared/sales-document.entity';
|
||||
|
||||
export type SalesRequestEntity = SalesDocumentEntity;
|
||||
export type SalesRequestDto = SalesDocumentDto;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { SalesRequestsRemoteDataServices } from '../../data/sales-request.remote.service';
|
||||
import { salesRequestsModuleConfig } from '../constants/sales-request.constants';
|
||||
import { SalesRequestsRemoteDataTransformer } from '../transformers/sales-request.remote.transformer';
|
||||
|
||||
export const salesRequestsDataTransformer = new SalesRequestsRemoteDataTransformer();
|
||||
|
||||
export const salesRequestsDataService = new SalesRequestsRemoteDataServices(apiClient, {
|
||||
apiUrl: salesRequestsModuleConfig.apiUrl,
|
||||
moduleKey: salesRequestsModuleConfig.moduleKey,
|
||||
transformer: salesRequestsDataTransformer,
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SalesRequestsRemoteDataTransformer } from './sales-request.remote.transformer';
|
||||
|
||||
const transformer = new SalesRequestsRemoteDataTransformer();
|
||||
|
||||
describe('SalesRequestsRemoteDataTransformer', () => {
|
||||
it('maps unix date onto a calendar string', () => {
|
||||
const entity = transformer.transformToEntity({
|
||||
id: 'sr-1',
|
||||
date: Date.UTC(2026, 7, 26),
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ productId: 'prd-1', quantity: '1', price: '10.0000' }],
|
||||
});
|
||||
expect(entity.date).toBe('2026-08-26');
|
||||
expect(entity.salesPerson).toEqual({ id: 'emp-1' });
|
||||
});
|
||||
|
||||
it('writes nested products and omits status', () => {
|
||||
const payload = transformer.transformCreatePayload({
|
||||
date: '2026-08-26',
|
||||
salesPerson: { id: 'emp-1' } as any,
|
||||
branch: { id: 'br-1' } as any,
|
||||
division: { id: 'div-1' } as any,
|
||||
customer: { id: 'cus-1' } as any,
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ product: { id: 'prd-1' } as any, quantity: '2', price: '10.0000' }],
|
||||
status: 'draft',
|
||||
});
|
||||
expect(payload.products).toEqual([{ productId: 'prd-1', quantity: '2', price: '10.0000' }]);
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
});
|
||||
});
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import {
|
||||
mapSalesDocumentFromDto,
|
||||
toSalesFilterPayload,
|
||||
toSalesWritePayload,
|
||||
} from '../../../shared/sales-document.mapper';
|
||||
import type { SalesRequestDto, SalesRequestEntity } from '../entities';
|
||||
|
||||
export class SalesRequestsRemoteDataTransformer extends BaseDataTransformer<SalesRequestEntity> {
|
||||
transformToEntity(dto: SalesRequestDto | SalesRequestEntity): SalesRequestEntity {
|
||||
return mapSalesDocumentFromDto(dto as SalesRequestDto);
|
||||
}
|
||||
|
||||
transformToDTO(entity: SalesRequestEntity): SalesRequestEntity {
|
||||
return { ...entity };
|
||||
}
|
||||
|
||||
transformCreatePayload(entity: Partial<SalesRequestEntity>): Partial<SalesRequestEntity> {
|
||||
return omitEmptyFields(toSalesWritePayload(entity)) as Partial<SalesRequestEntity>;
|
||||
}
|
||||
|
||||
transformEditPayload(entity: Partial<SalesRequestEntity>): Partial<SalesRequestEntity> {
|
||||
return toSalesWritePayload(entity) as Partial<SalesRequestEntity>;
|
||||
}
|
||||
|
||||
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
|
||||
return toSalesFilterPayload(filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { salesRequestsModuleConfig } from '../../domain/constants';
|
||||
import { salesRequestsDataService } from '../../domain/factories';
|
||||
import { SalesRequestEntity } from '../../domain/entities';
|
||||
import { salesRequestsStore } from '../store';
|
||||
|
||||
import salesRequestsId from '../languages/id/sales-requests.json';
|
||||
import salesRequestsEn from '../languages/en/sales-requests.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/sales-request.page.index'));
|
||||
const FormPage = lazy(() => import('../pages/sales-request.page.form'));
|
||||
const DetailPage = lazy(() => import('../pages/sales-request.page.detail'));
|
||||
|
||||
registerModuleNamespace(salesRequestsModuleConfig.translationNamespace, {
|
||||
id: salesRequestsId,
|
||||
en: salesRequestsEn,
|
||||
});
|
||||
|
||||
export default function SalesRequestsModule() {
|
||||
return (
|
||||
<EnterpriseModuleProvider<SalesRequestEntity>
|
||||
config={salesRequestsModuleConfig}
|
||||
dataServices={salesRequestsDataService}
|
||||
store={salesRequestsStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||
<Route path="/" element={<Navigate to={`${salesRequestsModuleConfig.webUrl}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Sales Requests",
|
||||
"detail_page_title": "Sales Request Detail",
|
||||
"create_page_title": "New Sales Request",
|
||||
"edit_page_title": "Edit Sales Request",
|
||||
"duplicate_page_title": "Duplicate Sales Request",
|
||||
"description": "Capture <1>sales requests</1> as the optional source of sales orders.",
|
||||
"detail_page_description": "Review request header, location, products, and images.",
|
||||
"create_page_description": "Create a sales request with customer, location, and product lines.",
|
||||
"edit_page_description": "Update request header, location, products, and images.",
|
||||
"duplicate_page_description": "Copy an existing sales request to create a new one.",
|
||||
"section_general": "General",
|
||||
"section_location": "Location",
|
||||
"section_products": "Products",
|
||||
"section_images": "Images",
|
||||
"section_notes": "Notes",
|
||||
"add_line": "Add line",
|
||||
"remove_line": "Remove line",
|
||||
"add_image": "Add image",
|
||||
"remove_image": "Remove image",
|
||||
"empty_products": "No product lines.",
|
||||
"empty_images": "No images.",
|
||||
"change_status": "Change status",
|
||||
"import_csv": "Import CSV",
|
||||
"csv_file": "CSV file",
|
||||
"import_success": "CSV imported.",
|
||||
"status_updated": "Status updated.",
|
||||
"create_sales_order": "Create Sales Order",
|
||||
"action_submit": "Submit",
|
||||
"action_approve": "Approve",
|
||||
"action_reject": "Reject",
|
||||
"status_draft": "Draft",
|
||||
"status_pending": "Pending",
|
||||
"status_approved": "Approved",
|
||||
"status_rejected": "Rejected"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Permintaan Penjualan",
|
||||
"detail_page_title": "Detail Permintaan Penjualan",
|
||||
"create_page_title": "Permintaan Penjualan Baru",
|
||||
"edit_page_title": "Ubah Permintaan Penjualan",
|
||||
"duplicate_page_title": "Duplikat Permintaan Penjualan",
|
||||
"description": "Catat <1>permintaan penjualan</1> sebagai sumber opsional pesanan penjualan.",
|
||||
"detail_page_description": "Tinjau header, lokasi, produk, dan gambar permintaan.",
|
||||
"create_page_description": "Buat permintaan penjualan dengan pelanggan, lokasi, dan baris produk.",
|
||||
"edit_page_description": "Perbarui header, lokasi, produk, dan gambar permintaan.",
|
||||
"duplicate_page_description": "Salin permintaan penjualan yang ada untuk membuat data baru.",
|
||||
"section_general": "Umum",
|
||||
"section_location": "Lokasi",
|
||||
"section_products": "Produk",
|
||||
"section_images": "Gambar",
|
||||
"section_notes": "Catatan",
|
||||
"add_line": "Tambah baris",
|
||||
"remove_line": "Hapus baris",
|
||||
"add_image": "Tambah gambar",
|
||||
"remove_image": "Hapus gambar",
|
||||
"empty_products": "Tidak ada baris produk.",
|
||||
"empty_images": "Tidak ada gambar.",
|
||||
"change_status": "Ubah status",
|
||||
"import_csv": "Impor CSV",
|
||||
"csv_file": "File CSV",
|
||||
"import_success": "CSV berhasil diimpor.",
|
||||
"status_updated": "Status diperbarui.",
|
||||
"create_sales_order": "Buat Pesanan Penjualan",
|
||||
"action_submit": "Kirim",
|
||||
"action_approve": "Setujui",
|
||||
"action_reject": "Tolak",
|
||||
"status_draft": "Draft",
|
||||
"status_pending": "Menunggu",
|
||||
"status_approved": "Disetujui",
|
||||
"status_rejected": "Ditolak"
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { salesRequestsModuleConfig } from '../../domain/constants';
|
||||
import { DetailGeneral } from '../../../shared/detail-general';
|
||||
import { DetailLocation } from '../../../shared/detail-location';
|
||||
import { DetailProducts } from '../../../shared/detail-products';
|
||||
import { DetailImages } from '../../../shared/detail-images';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { salesOrdersModuleConfig } from '../../../orders/domain/constants';
|
||||
import type { SalesRequestEntity } from '../../domain/entities';
|
||||
|
||||
export default function SalesRequestPageDetail() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const navigate = useNavigate();
|
||||
const actions = useSalesDocumentActions('request');
|
||||
|
||||
return (
|
||||
<EnterpriseDetailPageProvider
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: t('detail_page_title'),
|
||||
description: t('detail_page_description'),
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:sales'), type: 'text' },
|
||||
{ label: t('nav:sales-requests'), type: 'link', href: `${salesRequestsModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
customPageActions={(data, pageActions) => {
|
||||
const createOrder = actions.createOrderAction(data as SalesRequestEntity, () => {
|
||||
navigate(`${salesOrdersModuleConfig.webUrl}/create?salesRequestId=${data.id}`);
|
||||
});
|
||||
const withStatus = actions.detailStatusActions(data as SalesRequestEntity, pageActions ?? []);
|
||||
return createOrder ? [createOrder, ...withStatus] : withStatus;
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DetailGeneral />
|
||||
<DetailLocation />
|
||||
<DetailProducts />
|
||||
<DetailImages />
|
||||
</Stack>
|
||||
{actions.modals}
|
||||
</EnterpriseDetailPageProvider>
|
||||
);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { salesRequestsModuleConfig } from '../../domain/constants';
|
||||
import { createSalesRequestSchema } from '../../../shared/sales-document.validator';
|
||||
import { FormGeneral } from '../../../shared/form-general';
|
||||
import { FormLocation } from '../../../shared/form-location';
|
||||
import { FormProducts } from '../../../shared/form-products';
|
||||
import { FormImages } from '../../../shared/form-images';
|
||||
import { FormNotes } from '../../../shared/form-notes';
|
||||
|
||||
export default function SalesRequestPageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (formPageType === 'CREATE') {
|
||||
return { title: t('create_page_title'), description: t('create_page_description') };
|
||||
}
|
||||
if (formPageType === 'EDIT') {
|
||||
return { title: t('edit_page_title'), description: t('edit_page_description') };
|
||||
}
|
||||
if (formPageType === 'DUPLICATE') {
|
||||
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
|
||||
}
|
||||
return { title: '', description: '' };
|
||||
}, [formPageType, t]);
|
||||
|
||||
const validator = useMemo(() => createSalesRequestSchema(t), [t]);
|
||||
const formControl = useForm({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
products: [{ quantity: '1', price: '' }],
|
||||
images: [],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<EnterpriseFormPageProvider
|
||||
formControl={formControl}
|
||||
formPageType={formPageType}
|
||||
ignoreKeyDuplicate={['code']}
|
||||
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: title?.title,
|
||||
description: title?.description,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:sales'), type: 'text' },
|
||||
{ label: t('nav:sales-requests'), type: 'link', href: `${salesRequestsModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<FormGeneral />
|
||||
<FormLocation />
|
||||
<FormProducts />
|
||||
<FormImages />
|
||||
<FormNotes />
|
||||
</Stack>
|
||||
</EnterpriseFormPageProvider>
|
||||
);
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
EnterpriseIndexPageProvider,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
EnterpriseDataTable,
|
||||
} from '@repo/ui/foundations';
|
||||
import { ColDef, Text } from '@repo/ui/components';
|
||||
import { Trans } from '@repo/core-i18n';
|
||||
import { ClipboardList } from 'lucide-react';
|
||||
import { SalesFilterFormContent } from '../../../shared/filter-content';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { relationLabel } from '../../../../field/shared/relation-label';
|
||||
import type { SalesRequestEntity } from '../../domain/entities';
|
||||
|
||||
export default function SalesRequestPageIndex() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const actions = useSalesDocumentActions('request');
|
||||
|
||||
const columnDefs: ColDef<SalesRequestEntity>[] = useMemo(
|
||||
() => [
|
||||
{ field: 'code', headerName: t('common:fields.code'), minWidth: 160 },
|
||||
{ field: 'date', headerName: t('common:fields.date'), minWidth: 140 },
|
||||
{
|
||||
field: 'customerId',
|
||||
headerName: t('common:fields.customer'),
|
||||
minWidth: 180,
|
||||
valueGetter: ({ data }) => relationLabel(data?.customer) || data?.customerId,
|
||||
},
|
||||
{
|
||||
field: 'salesPersonId',
|
||||
headerName: t('common:fields.salesPerson'),
|
||||
minWidth: 180,
|
||||
valueGetter: ({ data }) => relationLabel(data?.salesPerson) || data?.salesPersonId,
|
||||
},
|
||||
{
|
||||
field: 'branchId',
|
||||
headerName: t('common:fields.branch'),
|
||||
minWidth: 160,
|
||||
valueGetter: ({ data }) => relationLabel(data?.branch) || data?.branchId,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const filterConfig = useMemo(
|
||||
() => ({
|
||||
renderBody: (form: any) => (form ? <SalesFilterFormContent form={form} t={t} documentType="request" /> : null),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<EnterpriseIndexPageProvider
|
||||
pageHeaderProps={{
|
||||
title: t('title'),
|
||||
description: (
|
||||
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||
),
|
||||
icon: ClipboardList,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:sales'), type: 'text' },
|
||||
{ label: t('nav:sales-requests'), type: 'text' },
|
||||
],
|
||||
}}
|
||||
customPageActions={(pageActions) =>
|
||||
actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions
|
||||
}
|
||||
>
|
||||
<EnterpriseDataTable
|
||||
columnDefs={columnDefs}
|
||||
filterConfig={filterConfig}
|
||||
customRowActions={(data, defaultActions) => actions.namedRowActions(data, defaultActions)}
|
||||
customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)}
|
||||
/>
|
||||
{actions.modals}
|
||||
</EnterpriseIndexPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import { SalesRequestEntity } from '../../domain/entities';
|
||||
|
||||
export interface SalesRequestsStoreState extends EnterpriseModuleState<SalesRequestEntity> {}
|
||||
|
||||
export const salesRequestsStore = create<SalesRequestsStoreState>((set) => ({
|
||||
metaData: { limit: 15 },
|
||||
setMetaData: (data) => set({ metaData: data }),
|
||||
filterData: {},
|
||||
setFilterData: (data) => set({ filterData: data }),
|
||||
selectedRows: [],
|
||||
setSelectedRows: (rows) => set({ selectedRows: rows }),
|
||||
privileges: [],
|
||||
setPrivileges: (privileges) => set({ privileges }),
|
||||
tableConfig: null,
|
||||
setTableConfig: (config) => set({ tableConfig: config }),
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button, FieldSelect, Group, Modal, Stack } from '@repo/ui/components';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { documentStatuses, type SalesDocumentType } from './sales-status';
|
||||
|
||||
export function ChangeStatusModal({
|
||||
opened,
|
||||
onClose,
|
||||
documentType,
|
||||
onSubmit,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
documentType: SalesDocumentType;
|
||||
onSubmit: (status: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const form = useForm<{ status: string }>();
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await onSubmit(values.status);
|
||||
form.reset();
|
||||
onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t('change_status')}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<FieldSelect
|
||||
control={form.control as any}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
required
|
||||
data={documentStatuses(documentType).map((value) => ({ value, label: t(`status_${value}`) }))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" type="button" onClick={onClose}>
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('change_status')}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Anchor, Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
|
||||
export function DetailGeneral({
|
||||
salesRequestHref,
|
||||
}: {
|
||||
salesRequestHref?: (id: string) => string;
|
||||
} = {}) {
|
||||
const { detailData } = useDetailPageContext<SalesDocumentEntity & { salesRequestId?: string | null; salesRequest?: { id?: string; code?: string } | null }>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const data = detailData;
|
||||
const salesRequestId = data?.salesRequestId ?? data?.salesRequest?.id;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||
<FieldValue label={t('common:fields.code')} value={data?.code} />
|
||||
<FieldValue
|
||||
label={t('common:fields.date')}
|
||||
value={data?.date}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
<FieldValue label={t('common:fields.salesPerson')} value={relationLabel(data?.salesPerson) || data?.salesPersonId} />
|
||||
<FieldValue label={t('common:fields.branch')} value={relationLabel(data?.branch) || data?.branchId} />
|
||||
<FieldValue label={t('common:fields.division')} value={relationLabel(data?.division) || data?.divisionId} />
|
||||
<FieldValue label={t('common:fields.customer')} value={relationLabel(data?.customer) || data?.customerId} />
|
||||
{salesRequestHref && (
|
||||
<FieldValue
|
||||
label={t('common:fields.salesRequest')}
|
||||
value={salesRequestId}
|
||||
render={() =>
|
||||
salesRequestId ? (
|
||||
<Anchor href={salesRequestHref(salesRequestId)}>
|
||||
{data?.salesRequest?.code || salesRequestId}
|
||||
</Anchor>
|
||||
) : (
|
||||
'-'
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FieldValue
|
||||
label={t('common:fields.status')}
|
||||
value={data?.status}
|
||||
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.createdAt')}
|
||||
value={data?.createdAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.updatedAt')}
|
||||
value={data?.updatedAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
<FieldValue label={t('common:fields.notes')} value={data?.notes} />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Anchor, Box, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
|
||||
export function DetailImages() {
|
||||
const { detailData } = useDetailPageContext<SalesDocumentEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const images = detailData?.images ?? [];
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_images')}
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('common:fields.url')}</Table.Th>
|
||||
<Table.Th>{t('common:fields.description')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{images.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('empty_images')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
images.map((image, index) => (
|
||||
<Table.Tr key={image.id ?? `${image.url}-${index}`}>
|
||||
<Table.Td>
|
||||
<Anchor href={image.url} target="_blank" rel="noreferrer">
|
||||
{image.url}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>{image.description || '-'}</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Paper, SimpleGrid, FieldValue, Stack, Text } from '@repo/ui/components';
|
||||
import { LocationMap } from '@repo/ui/map';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
|
||||
export function DetailLocation() {
|
||||
const { detailData } = useDetailPageContext<SalesDocumentEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const data = detailData;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_location')}
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<LocationMap
|
||||
latitude={data?.latitude}
|
||||
longitude={data?.longitude}
|
||||
emptyLabel={t('common:map.noLocation')}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||
<FieldValue label={t('common:fields.address')} value={data?.address} />
|
||||
<FieldValue label={t('common:fields.latitude')} value={data?.latitude} />
|
||||
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { CurrencyUtils } from '@repo/utils';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
|
||||
const currency = new CurrencyUtils({ decimalScale: 4 });
|
||||
|
||||
export function DetailProducts() {
|
||||
const { detailData } = useDetailPageContext<SalesDocumentEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const lines = detailData?.products ?? [];
|
||||
const grandTotal = lines.reduce((sum, line) => {
|
||||
const qty = Number(line.quantity);
|
||||
const price = Number(line.price);
|
||||
if (!Number.isFinite(qty) || !Number.isFinite(price)) return sum;
|
||||
return sum + qty * price;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_products')}
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('common:fields.product')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.quantity')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.price')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.lineTotal')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{lines.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('empty_products')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
lines.map((line, index) => {
|
||||
const qty = Number(line.quantity);
|
||||
const price = Number(line.price);
|
||||
const total = Number.isFinite(qty) && Number.isFinite(price) ? qty * price : 0;
|
||||
return (
|
||||
<Table.Tr key={line.id ?? `${line.productId}-${index}`}>
|
||||
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
|
||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||
<Table.Td ta="right">{line.price ? currency.format(line.price) : '-'}</Table.Td>
|
||||
<Table.Td ta="right">{currency.format(total)}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Text fw={600} ta="right" mt="md">
|
||||
{t('common:fields.total')}: {currency.format(grandTotal)}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { SimpleGrid } from '@repo/ui/components';
|
||||
import { FieldAsyncSelect, FieldSelect, FieldTextInput } from '@repo/ui/form';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import { loadEmployeeOptions } from '../../field/shared/load-employee-options';
|
||||
import { loadBranchOptions } from '../../field/shared/load-branch-options';
|
||||
import { loadCustomerOptions } from '../../field/shared/load-customer-options';
|
||||
import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
import { documentStatusFilterOptions, type SalesDocumentType } from './sales-status';
|
||||
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
|
||||
import type { BranchEntity } from '../../configuration/branches/domain/entities';
|
||||
import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
|
||||
import type { CustomerEntity } from '../../configuration/customers/domain/entities';
|
||||
|
||||
export function SalesFilterFormContent({
|
||||
form,
|
||||
t,
|
||||
documentType,
|
||||
}: {
|
||||
form: UseFormReturn<any>;
|
||||
t: (key: string) => string;
|
||||
documentType: SalesDocumentType;
|
||||
}) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput control={form.control} name="code" label={t('common:fields.code')} />
|
||||
<FieldSelect
|
||||
control={form.control}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
clearable
|
||||
data={documentStatusFilterOptions(t, documentType)}
|
||||
/>
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={form.control}
|
||||
name="customer"
|
||||
label={t('common:fields.customer')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
clearable
|
||||
loadOptions={loadCustomerOptions}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={form.control}
|
||||
name="salesPerson"
|
||||
label={t('common:fields.salesPerson')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
clearable
|
||||
loadOptions={loadEmployeeOptions}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={form.control}
|
||||
name="branch"
|
||||
label={t('common:fields.branch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
clearable
|
||||
loadOptions={loadBranchOptions}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<DivisionEntity>
|
||||
control={form.control}
|
||||
name="division"
|
||||
label={t('common:fields.division')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
clearable
|
||||
loadOptions={loadDivisionOptions}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Box, FieldAsyncSelect, FieldDatePicker, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { loadEmployeeOptions } from '../../field/shared/load-employee-options';
|
||||
import { loadBranchOptions } from '../../field/shared/load-branch-options';
|
||||
import { loadCustomerOptions } from '../../field/shared/load-customer-options';
|
||||
import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
|
||||
import type { BranchEntity } from '../../configuration/branches/domain/entities';
|
||||
import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
|
||||
import type { CustomerEntity } from '../../configuration/customers/domain/entities';
|
||||
|
||||
export function FormGeneral() {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const salesPerson = formControl.watch('salesPerson');
|
||||
const branch = formControl.watch('branch');
|
||||
const division = formControl.watch('division');
|
||||
const customer = formControl.watch('customer');
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="code"
|
||||
label={t('common:fields.code')}
|
||||
placeholder="e.g. SR-20260826-0001"
|
||||
radius="md"
|
||||
/>
|
||||
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required radius="md" />
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={formControl.control}
|
||||
name="salesPerson"
|
||||
label={t('common:fields.salesPerson')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadEmployeeOptions}
|
||||
defaultOptions={salesPerson ? [salesPerson] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="branch"
|
||||
label={t('common:fields.branch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={branch ? [branch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<DivisionEntity>
|
||||
control={formControl.control}
|
||||
name="division"
|
||||
label={t('common:fields.division')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadDivisionOptions}
|
||||
defaultOptions={division ? [division] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={formControl.control}
|
||||
name="customer"
|
||||
label={t('common:fields.customer')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadCustomerOptions}
|
||||
defaultOptions={customer ? [customer] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ActionIcon, Box, Button, FieldTextInput, Group, Paper, Stack, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { useFieldArray } from '@repo/ui/form';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export function FormImages() {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: formControl.control,
|
||||
name: 'images',
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{t('section_images')}</Text>
|
||||
<Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={() => append({ url: '', description: '' })}>
|
||||
{t('add_image')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{fields.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('empty_images')}
|
||||
</Text>
|
||||
) : (
|
||||
fields.map((field, index) => (
|
||||
<Paper key={field.id} withBorder radius="md" p="md">
|
||||
<Group justify="flex-end" mb="xs">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove(index)} aria-label={t('remove_image')}>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Box>
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name={`images.${index}.url`}
|
||||
label={t('common:fields.url')}
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<Box mt="md">
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name={`images.${index}.description`}
|
||||
label={t('common:fields.description')}
|
||||
radius="md"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box, FieldNumberInput, FieldTextarea, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
|
||||
import { LocationMap } from '@repo/ui/map';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
|
||||
export function FormLocation() {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const latitude = formControl.watch('latitude');
|
||||
const longitude = formControl.watch('longitude');
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_location')}
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<FieldTextarea
|
||||
control={formControl.control}
|
||||
name="address"
|
||||
label={t('common:fields.address')}
|
||||
required
|
||||
minRows={3}
|
||||
radius="md"
|
||||
/>
|
||||
<LocationMap
|
||||
latitude={latitude}
|
||||
longitude={longitude}
|
||||
helperLabel={t('common:map.pickLocation')}
|
||||
onChange={(point) => {
|
||||
formControl.setValue('latitude', point.latitude, { shouldDirty: true, shouldValidate: true });
|
||||
formControl.setValue('longitude', point.longitude, { shouldDirty: true, shouldValidate: true });
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldNumberInput
|
||||
control={formControl.control}
|
||||
name="latitude"
|
||||
label={t('common:fields.latitude')}
|
||||
placeholder="-6.2"
|
||||
decimalScale={6}
|
||||
radius="md"
|
||||
/>
|
||||
<FieldNumberInput
|
||||
control={formControl.control}
|
||||
name="longitude"
|
||||
label={t('common:fields.longitude')}
|
||||
placeholder="106.8"
|
||||
decimalScale={6}
|
||||
radius="md"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FieldTextarea, Paper, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
|
||||
export function FormNotes() {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_notes')}
|
||||
</Text>
|
||||
<FieldTextarea
|
||||
control={formControl.control}
|
||||
name="notes"
|
||||
label={t('common:fields.notes')}
|
||||
minRows={3}
|
||||
radius="md"
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ActionIcon, Box, Button, FieldAsyncSelect, FieldTextInput, Group, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { useFieldArray, useWatch } from '@repo/ui/form';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { CurrencyUtils } from '@repo/utils';
|
||||
import { loadProductOptions } from './load-product-options';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
import type { ProductEntity } from '../../configuration/products/domain/entities';
|
||||
|
||||
const currency = new CurrencyUtils({ decimalScale: 4 });
|
||||
|
||||
function lineTotal(quantity?: string, price?: string) {
|
||||
const qty = Number(quantity);
|
||||
const unitPrice = Number(price);
|
||||
if (!Number.isFinite(qty) || !Number.isFinite(unitPrice)) return 0;
|
||||
return qty * unitPrice;
|
||||
}
|
||||
|
||||
export function FormProducts() {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: formControl.control,
|
||||
name: 'products',
|
||||
});
|
||||
const products = useWatch({ control: formControl.control, name: 'products' }) ?? [];
|
||||
const grandTotal = products.reduce(
|
||||
(sum: number, line: { quantity?: string; price?: string }) => sum + lineTotal(line?.quantity, line.price),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_products')}
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('common:fields.product')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.quantity')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.price')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.lineTotal')}</Table.Th>
|
||||
<Table.Th w={48} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{fields.map((field, index) => {
|
||||
const line = products[index];
|
||||
return (
|
||||
<Table.Tr key={field.id}>
|
||||
<Table.Td miw={240}>
|
||||
<FieldAsyncSelect<ProductEntity>
|
||||
control={formControl.control}
|
||||
name={`products.${index}.product`}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
loadOptions={loadProductOptions}
|
||||
defaultOptions={line?.product ? [line.product] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td miw={120}>
|
||||
<FieldTextInput control={formControl.control} name={`products.${index}.quantity`} radius="md" />
|
||||
</Table.Td>
|
||||
<Table.Td miw={140}>
|
||||
<FieldTextInput control={formControl.control} name={`products.${index}.price`} radius="md" />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{currency.format(lineTotal(line?.quantity, line?.price))}</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove(index)} aria-label={t('remove_line')}>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={() => append({ product: undefined, quantity: '1', price: '' })}
|
||||
>
|
||||
{t('add_line')}
|
||||
</Button>
|
||||
<Text fw={600}>
|
||||
{t('common:fields.total')}: {currency.format(grandTotal)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Button, FieldFileInput, Group, Modal, Stack } from '@repo/ui/components';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
|
||||
export function ImportCsvModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (file: File) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const form = useForm<{ file: File | null }>({ defaultValues: { file: null } });
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
if (!values.file) return;
|
||||
await onSubmit(values.file);
|
||||
form.reset();
|
||||
onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t('import_csv')}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<FieldFileInput
|
||||
control={form.control as any}
|
||||
name="file"
|
||||
label={t('csv_file')}
|
||||
accept=".csv,text/csv"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" type="button" onClick={onClose}>
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('import_csv')}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { productsDataService } from '../../configuration/products/domain/factories';
|
||||
import type { ProductEntity } from '../../configuration/products/domain/entities';
|
||||
import { createOptionLoader } from '../../field/shared/create-option-loader';
|
||||
|
||||
export const loadProductOptions = createOptionLoader<ProductEntity>((config) => productsDataService.getMany(config));
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createOptionLoader } from '../../field/shared/create-option-loader';
|
||||
import { salesRequestsDataService } from '../requests/domain/factories';
|
||||
import type { SalesRequestEntity } from '../requests/domain/entities';
|
||||
|
||||
export const loadSalesRequestOptions = createOptionLoader<SalesRequestEntity>((config) =>
|
||||
salesRequestsDataService.getMany(config),
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
|
||||
export interface LookupStub {
|
||||
id: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface SalesLineEntity {
|
||||
id?: string;
|
||||
productId?: string;
|
||||
product?: LookupStub | null;
|
||||
quantity: string;
|
||||
price?: string | null;
|
||||
}
|
||||
|
||||
export interface SalesImageEntity {
|
||||
id?: string;
|
||||
url: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SalesDocumentEntity extends BaseEntity {
|
||||
code?: string | null;
|
||||
date: string;
|
||||
salesPersonId?: string;
|
||||
salesPerson?: LookupStub | null;
|
||||
branchId?: string;
|
||||
branch?: LookupStub | null;
|
||||
divisionId?: string;
|
||||
division?: LookupStub | null;
|
||||
customerId?: string;
|
||||
customer?: LookupStub | null;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: SalesLineEntity[];
|
||||
images?: SalesImageEntity[];
|
||||
status?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SalesDocumentDto {
|
||||
id?: string;
|
||||
code?: string | null;
|
||||
date: string | number;
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: Array<{
|
||||
id?: string;
|
||||
productId: string;
|
||||
quantity: string;
|
||||
price?: string | null;
|
||||
}>;
|
||||
images?: Array<{
|
||||
id?: string;
|
||||
url: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
status?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mapSalesDocumentFromDto, toSalesFilterPayload, toSalesWritePayload } from './sales-document.mapper';
|
||||
|
||||
describe('sales document mapper', () => {
|
||||
it('maps unix date and relation stubs onto the entity', () => {
|
||||
const entity = mapSalesDocumentFromDto({
|
||||
id: 'sr-1',
|
||||
code: 'SR-1',
|
||||
date: Date.UTC(2026, 7, 26),
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ id: 'line-1', productId: 'prd-1', quantity: '2.0000', price: '12500.0000' }],
|
||||
images: [{ id: 'img-1', url: 'https://cdn.example/a.png', description: 'photo' }],
|
||||
status: 'draft',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
createdBy: 'u1',
|
||||
updatedBy: 'u2',
|
||||
});
|
||||
|
||||
expect(entity.date).toBe('2026-08-26');
|
||||
expect(entity.salesPerson).toEqual({ id: 'emp-1' });
|
||||
expect(entity.products?.[0]).toMatchObject({ productId: 'prd-1', product: { id: 'prd-1' }, quantity: '2.0000' });
|
||||
});
|
||||
|
||||
it('writes relation ids and nested lines on create', () => {
|
||||
const payload = toSalesWritePayload(
|
||||
{
|
||||
date: '2026-08-26',
|
||||
salesPerson: { id: 'emp-1' } as any,
|
||||
branch: { id: 'br-1' } as any,
|
||||
division: { id: 'div-1' } as any,
|
||||
customer: { id: 'cus-1' } as any,
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ product: { id: 'prd-1' } as any, quantity: '2', price: '12500.0000' }],
|
||||
images: [{ url: 'https://cdn.example/a.png', description: '' }],
|
||||
salesRequest: { id: 'sr-1' } as any,
|
||||
} as any,
|
||||
{ includeSalesRequestId: true },
|
||||
);
|
||||
|
||||
expect(payload.salesPersonId).toBe('emp-1');
|
||||
expect(payload.salesRequestId).toBe('sr-1');
|
||||
expect(payload.products).toEqual([{ productId: 'prd-1', quantity: '2', price: '12500.0000' }]);
|
||||
expect(payload.images).toEqual([{ url: 'https://cdn.example/a.png' }]);
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('omits salesRequestId unless requested', () => {
|
||||
const payload = toSalesWritePayload({
|
||||
date: '2026-08-26',
|
||||
salesPerson: { id: 'emp-1' } as any,
|
||||
branch: { id: 'br-1' } as any,
|
||||
division: { id: 'div-1' } as any,
|
||||
customer: { id: 'cus-1' } as any,
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ product: { id: 'prd-1' } as any, quantity: '1' }],
|
||||
salesRequest: { id: 'sr-1' } as any,
|
||||
} as any);
|
||||
expect(payload).not.toHaveProperty('salesRequestId');
|
||||
});
|
||||
|
||||
it('flattens relation objects in filters', () => {
|
||||
expect(
|
||||
toSalesFilterPayload({
|
||||
customer: { id: 'cus-1' },
|
||||
status: 'draft',
|
||||
}),
|
||||
).toEqual({ customerId: 'cus-1', status: 'draft' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
||||
import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators';
|
||||
import type { SalesDocumentDto, SalesDocumentEntity, SalesImageEntity, SalesLineEntity } from './sales-document.entity';
|
||||
|
||||
export function relationId(value: unknown): string | undefined {
|
||||
if (value && typeof value === 'object' && 'id' in value) {
|
||||
const id = (value as { id?: unknown }).id;
|
||||
return id == null ? undefined : String(id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toRelationStub(id?: string | null) {
|
||||
return id ? { id } : null;
|
||||
}
|
||||
|
||||
export function mapSalesLinesFromDto(dto: SalesDocumentDto): SalesLineEntity[] {
|
||||
return (dto.products ?? []).map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: toRelationStub(line.productId),
|
||||
quantity: line.quantity,
|
||||
price: line.price ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[] {
|
||||
return (dto.images ?? []).map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEntity {
|
||||
return {
|
||||
id: dto.id,
|
||||
code: dto.code ?? null,
|
||||
date: formatDateValue(parseDateValue(dto.date) ?? undefined),
|
||||
salesPersonId: dto.salesPersonId,
|
||||
salesPerson: toRelationStub(dto.salesPersonId),
|
||||
branchId: dto.branchId,
|
||||
branch: toRelationStub(dto.branchId),
|
||||
divisionId: dto.divisionId,
|
||||
division: toRelationStub(dto.divisionId),
|
||||
customerId: dto.customerId,
|
||||
customer: toRelationStub(dto.customerId),
|
||||
address: dto.address,
|
||||
latitude: dto.latitude ?? null,
|
||||
longitude: dto.longitude ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
products: mapSalesLinesFromDto(dto),
|
||||
images: mapSalesImagesFromDto(dto),
|
||||
status: dto.status,
|
||||
createdAt: dto.createdAt,
|
||||
updatedAt: dto.updatedAt,
|
||||
createdBy: dto.createdBy,
|
||||
updatedBy: dto.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
export function toSalesWritePayload(
|
||||
entity: Partial<SalesDocumentEntity>,
|
||||
options?: { includeSalesRequestId?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const products = (entity.products ?? [])
|
||||
.map((line) => {
|
||||
const productId = relationId(line.product) ?? line.productId;
|
||||
if (!productId) return null;
|
||||
return omitEmptyFields({
|
||||
productId,
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const images = (entity.images ?? [])
|
||||
.filter((image) => Boolean(image?.url))
|
||||
.map((image) => omitEmptyFields({ url: image.url, description: image.description }));
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
code: entity.code,
|
||||
date: formatDateValue(entity.date),
|
||||
salesPersonId: relationId(entity.salesPerson) ?? entity.salesPersonId,
|
||||
branchId: relationId(entity.branch) ?? entity.branchId,
|
||||
divisionId: relationId(entity.division) ?? entity.divisionId,
|
||||
customerId: relationId(entity.customer) ?? entity.customerId,
|
||||
address: entity.address,
|
||||
latitude: emptyToNull(entity.latitude),
|
||||
longitude: emptyToNull(entity.longitude),
|
||||
notes: emptyToNull(entity.notes),
|
||||
products,
|
||||
images,
|
||||
};
|
||||
|
||||
if (options?.includeSalesRequestId) {
|
||||
payload.salesRequestId = relationId((entity as { salesRequest?: unknown }).salesRequest) ?? (entity as { salesRequestId?: string }).salesRequestId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function toSalesFilterPayload(filter: Record<string, any>): Record<string, any> {
|
||||
const next = { ...filter };
|
||||
if (next.salesPerson && typeof next.salesPerson === 'object') {
|
||||
next.salesPersonId = next.salesPerson.id;
|
||||
delete next.salesPerson;
|
||||
}
|
||||
if (next.branch && typeof next.branch === 'object') {
|
||||
next.branchId = next.branch.id;
|
||||
delete next.branch;
|
||||
}
|
||||
if (next.division && typeof next.division === 'object') {
|
||||
next.divisionId = next.division.id;
|
||||
delete next.division;
|
||||
}
|
||||
if (next.customer && typeof next.customer === 'object') {
|
||||
next.customerId = next.customer.id;
|
||||
delete next.customer;
|
||||
}
|
||||
if (next.salesRequest && typeof next.salesRequest === 'object') {
|
||||
next.salesRequestId = next.salesRequest.id;
|
||||
delete next.salesRequest;
|
||||
}
|
||||
return omitEmptyFields(next);
|
||||
}
|
||||
|
||||
export function salesRequestToFormValues(request: SalesDocumentEntity) {
|
||||
return {
|
||||
date: request.date,
|
||||
salesPerson: request.salesPerson ?? toRelationStub(request.salesPersonId),
|
||||
branch: request.branch ?? toRelationStub(request.branchId),
|
||||
division: request.division ?? toRelationStub(request.divisionId),
|
||||
customer: request.customer ?? toRelationStub(request.customerId),
|
||||
address: request.address,
|
||||
latitude: request.latitude ?? null,
|
||||
longitude: request.longitude ?? null,
|
||||
notes: request.notes ?? '',
|
||||
products: (request.products ?? []).map((line) => ({
|
||||
product: line.product ?? toRelationStub(line.productId),
|
||||
quantity: line.quantity,
|
||||
price: line.price ?? '',
|
||||
})),
|
||||
images: (request.images ?? []).map((image) => ({
|
||||
url: image.url,
|
||||
description: image.description ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
|
||||
|
||||
function createMockHttpClient(): AxiosInstance {
|
||||
return {
|
||||
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
|
||||
defaults: {} as AxiosInstance['defaults'],
|
||||
interceptors: {
|
||||
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
},
|
||||
getUri: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
head: vi.fn(),
|
||||
options: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
putForm: vi.fn(),
|
||||
patchForm: vi.fn(),
|
||||
} as unknown as AxiosInstance;
|
||||
}
|
||||
|
||||
describe('SalesDocumentRemoteDataServices', () => {
|
||||
let httpClient: AxiosInstance;
|
||||
let service: SalesDocumentRemoteDataServices<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SalesDocumentRemoteDataServices(httpClient, {
|
||||
apiUrl: '/sales-requests',
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
});
|
||||
});
|
||||
|
||||
it('patches status via /:id/status', async () => {
|
||||
await service.changeStatus('sr-1', 'pending');
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/sales-requests/sr-1/status',
|
||||
method: 'PATCH',
|
||||
data: { status: 'pending' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('posts bulk status via /bulk-status', async () => {
|
||||
await service.bulkChangeStatus(['sr-1', 'sr-2'], 'approved');
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/sales-requests/bulk-status',
|
||||
method: 'POST',
|
||||
data: { ids: ['sr-1', 'sr-2'], status: 'approved' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('imports csv via multipart POST /import', async () => {
|
||||
const file = new File(['code\nSR-1'], 'import.csv', { type: 'text/csv' });
|
||||
await service.importCsv(file);
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/sales-requests/import',
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import { REQUEST_ACTION, type DataServicesConfig } from '@repo/core-api/data-services';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../core/lib/trackgo-remote-data-services';
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
|
||||
export class SalesDocumentRemoteDataServices<E extends BaseEntity> extends TrackGoRemoteDataServices<E> {
|
||||
protected readonly resourceUrl: string;
|
||||
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<E>) {
|
||||
super(httpClient, config);
|
||||
this.resourceUrl = config.apiUrl ?? '';
|
||||
}
|
||||
|
||||
changeStatus(id: string, status: string) {
|
||||
return this.customRequest({
|
||||
url: `${this.resourceUrl}/${id}/status`,
|
||||
method: 'PATCH',
|
||||
data: { status },
|
||||
headers: { 'ex-module-action': REQUEST_ACTION.EDIT },
|
||||
});
|
||||
}
|
||||
|
||||
bulkChangeStatus(ids: Array<string | number>, status: string) {
|
||||
return this.customRequest({
|
||||
url: `${this.resourceUrl}/bulk-status`,
|
||||
method: 'POST',
|
||||
data: { ids, status },
|
||||
headers: { 'ex-module-action': REQUEST_ACTION.EDIT },
|
||||
});
|
||||
}
|
||||
|
||||
importCsv(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.customRequest({
|
||||
url: `${this.resourceUrl}/import`,
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: { 'ex-module-action': REQUEST_ACTION.CREATE },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSalesOrderSchema, createSalesRequestSchema } from './sales-document.validator';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
const relation = { id: 'rel-1', code: 'X', name: 'Name' };
|
||||
|
||||
const validRequest = {
|
||||
date: '2026-08-26',
|
||||
salesPerson: relation,
|
||||
branch: relation,
|
||||
division: relation,
|
||||
customer: relation,
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ product: relation, quantity: '2.0000', price: '12500.0000' }],
|
||||
};
|
||||
|
||||
describe('createSalesRequestSchema', () => {
|
||||
const schema = createSalesRequestSchema(t);
|
||||
|
||||
it('accepts a complete document', () => {
|
||||
expect(schema.safeParse(validRequest).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a document with no product lines', () => {
|
||||
expect(schema.safeParse({ ...validRequest, products: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a non-decimal quantity', () => {
|
||||
expect(
|
||||
schema.safeParse({
|
||||
...validRequest,
|
||||
products: [{ product: relation, quantity: 'abc', price: '1' }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSalesOrderSchema', () => {
|
||||
it('accepts an optional sales request', () => {
|
||||
const schema = createSalesOrderSchema(t);
|
||||
expect(schema.safeParse({ ...validRequest, salesRequest: relation }).success).toBe(true);
|
||||
expect(schema.safeParse(validRequest).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from 'zod';
|
||||
import { compose, maxLength, required } from '@repo/ui/validators';
|
||||
import { configAddressSchema, optionalLatitudeSchema, optionalLongitudeSchema } from '../../../../../core/domain/configuration-field-validators';
|
||||
import { decimalStringSchema } from '../../../../../core/domain/decimal-string.schema';
|
||||
|
||||
const NOTES_MAX = 1024;
|
||||
const IMAGE_URL_MAX = 2048;
|
||||
const IMAGE_DESCRIPTION_MAX = 255;
|
||||
const CODE_MAX = 32;
|
||||
const CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
||||
|
||||
function emptyToUndefined(value: unknown) {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const relationSchema = z.object({
|
||||
id: z.string(),
|
||||
code: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
export function salesLineSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
id: z.string().optional(),
|
||||
product: relationSchema,
|
||||
quantity: decimalStringSchema(t, 'common:fields.quantity'),
|
||||
price: z.preprocess(emptyToUndefined, decimalStringSchema(t, 'common:fields.price').optional()),
|
||||
});
|
||||
}
|
||||
|
||||
export function salesImageSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
id: z.string().optional(),
|
||||
url: compose(z.string(), required(t('common:fields.url')), maxLength(IMAGE_URL_MAX, t('common:fields.url'))),
|
||||
description: z.preprocess(
|
||||
emptyToUndefined,
|
||||
compose(z.string(), maxLength(IMAGE_DESCRIPTION_MAX, t('common:fields.description'))).optional(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function salesDocumentBaseSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
code: z.preprocess(
|
||||
emptyToUndefined,
|
||||
compose(z.string(), maxLength(CODE_MAX, t('common:fields.code')))
|
||||
.regex(CODE_PATTERN, {
|
||||
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.code') } }),
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
date: compose(z.string(), required(t('common:fields.date'))),
|
||||
salesPerson: relationSchema,
|
||||
branch: relationSchema,
|
||||
division: relationSchema,
|
||||
customer: relationSchema,
|
||||
address: configAddressSchema(t),
|
||||
latitude: optionalLatitudeSchema(),
|
||||
longitude: optionalLongitudeSchema(),
|
||||
notes: z.preprocess(
|
||||
emptyToUndefined,
|
||||
compose(z.string(), maxLength(NOTES_MAX, t('common:fields.notes'))).optional(),
|
||||
),
|
||||
products: z.array(salesLineSchema(t)).min(1),
|
||||
images: z.array(salesImageSchema(t)).optional(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createSalesRequestSchema(t: (key: string) => string) {
|
||||
return salesDocumentBaseSchema(t);
|
||||
}
|
||||
|
||||
export function createSalesOrderSchema(t: (key: string) => string) {
|
||||
return salesDocumentBaseSchema(t).extend({
|
||||
salesRequest: relationSchema.nullable().optional(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allowedTransitions, commonTransitions, namedActionsFor } from './sales-status';
|
||||
|
||||
describe('sales status transitions', () => {
|
||||
it('allows request draft to pending or rejected', () => {
|
||||
expect(allowedTransitions('request', 'draft')).toEqual(['pending', 'rejected']);
|
||||
});
|
||||
|
||||
it('blocks transitions from an approved request', () => {
|
||||
expect(allowedTransitions('request', 'approved')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns named approve action only from pending', () => {
|
||||
expect(namedActionsFor('request', 'pending').map((action) => action.key)).toEqual(['approve', 'reject']);
|
||||
expect(namedActionsFor('request', 'draft').map((action) => action.key)).toEqual(['submit']);
|
||||
});
|
||||
|
||||
it('intersects bulk transitions to statuses valid for every row', () => {
|
||||
expect(commonTransitions('order', ['draft', 'processed'])).toEqual(['cancelled']);
|
||||
expect(commonTransitions('order', ['draft'])).toEqual(['processed', 'cancelled']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export const SALES_REQUEST_STATUSES = ['draft', 'pending', 'approved', 'rejected'] as const;
|
||||
export type SalesRequestStatus = (typeof SALES_REQUEST_STATUSES)[number];
|
||||
|
||||
export const SALES_ORDER_STATUSES = ['draft', 'processed', 'completed', 'cancelled'] as const;
|
||||
export type SalesOrderStatus = (typeof SALES_ORDER_STATUSES)[number];
|
||||
|
||||
export type SalesDocumentType = 'request' | 'order';
|
||||
|
||||
const REQUEST_TRANSITIONS: Record<SalesRequestStatus, SalesRequestStatus[]> = {
|
||||
draft: ['pending', 'rejected'],
|
||||
pending: ['approved', 'rejected', 'draft'],
|
||||
approved: [],
|
||||
rejected: ['draft'],
|
||||
};
|
||||
|
||||
const ORDER_TRANSITIONS: Record<SalesOrderStatus, SalesOrderStatus[]> = {
|
||||
draft: ['processed', 'cancelled'],
|
||||
processed: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export const SALES_REQUEST_NAMED_ACTIONS = [
|
||||
{ key: 'submit', target: 'pending' as const, from: ['draft'] as const },
|
||||
{ key: 'approve', target: 'approved' as const, from: ['pending'] as const },
|
||||
{ key: 'reject', target: 'rejected' as const, from: ['pending'] as const },
|
||||
] as const;
|
||||
|
||||
export const SALES_ORDER_NAMED_ACTIONS = [
|
||||
{ key: 'process', target: 'processed' as const, from: ['draft'] as const },
|
||||
{ key: 'complete', target: 'completed' as const, from: ['processed'] as const },
|
||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
|
||||
] as const;
|
||||
|
||||
export function documentStatuses(type: SalesDocumentType): readonly string[] {
|
||||
return type === 'request' ? SALES_REQUEST_STATUSES : SALES_ORDER_STATUSES;
|
||||
}
|
||||
|
||||
export function allowedTransitions(type: SalesDocumentType, current?: string | null): string[] {
|
||||
if (!current) return [];
|
||||
if (type === 'request') {
|
||||
return REQUEST_TRANSITIONS[current as SalesRequestStatus] ?? [];
|
||||
}
|
||||
return ORDER_TRANSITIONS[current as SalesOrderStatus] ?? [];
|
||||
}
|
||||
|
||||
export function commonTransitions(type: SalesDocumentType, statuses: Array<string | undefined>): string[] {
|
||||
if (statuses.length === 0) return [];
|
||||
const [first, ...rest] = statuses;
|
||||
const initial = allowedTransitions(type, first);
|
||||
return initial.filter((status) => rest.every((item) => allowedTransitions(type, item).includes(status)));
|
||||
}
|
||||
|
||||
export function namedActionsFor(type: SalesDocumentType, current?: string | null) {
|
||||
const actions = type === 'request' ? SALES_REQUEST_NAMED_ACTIONS : SALES_ORDER_NAMED_ACTIONS;
|
||||
return actions.filter((action) => current && (action.from as readonly string[]).includes(current));
|
||||
}
|
||||
|
||||
export function documentStatusFilterOptions(t: (key: string) => string, type: SalesDocumentType) {
|
||||
return documentStatuses(type).map((value) => ({
|
||||
value,
|
||||
label: t(`status_${value}`),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban } from 'lucide-react';
|
||||
import { notifications } from '@repo/ui/components';
|
||||
import {
|
||||
useDetailPageContext,
|
||||
useEnterpriseModuleConfigContext,
|
||||
useEnterpriseModuleDataServiceContext,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
} from '@repo/ui/foundations';
|
||||
import { namedActionsFor, type SalesDocumentType } from './sales-status';
|
||||
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
import { ChangeStatusModal } from './change-status-modal';
|
||||
import { ImportCsvModal } from './import-csv-modal';
|
||||
|
||||
const ACTION_ICONS: Record<string, typeof Check> = {
|
||||
submit: Play,
|
||||
approve: Check,
|
||||
reject: XCircle,
|
||||
process: Play,
|
||||
complete: Check,
|
||||
cancel: Ban,
|
||||
};
|
||||
|
||||
export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const { privileges } = useEnterpriseModuleConfigContext();
|
||||
const { dataServices } = useEnterpriseModuleDataServiceContext<
|
||||
SalesDocumentEntity,
|
||||
SalesDocumentRemoteDataServices<SalesDocumentEntity>
|
||||
>();
|
||||
const [statusOpened, setStatusOpened] = useState(false);
|
||||
const [importOpened, setImportOpened] = useState(false);
|
||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||
const canEdit = privileges.ALLOW_EDIT;
|
||||
const canImport = privileges.ALLOW_IMPORT;
|
||||
|
||||
const applyStatus = async (ids: string[], status: string) => {
|
||||
if (ids.length === 1) {
|
||||
await dataServices.changeStatus(ids[0], status);
|
||||
} else {
|
||||
await dataServices.bulkChangeStatus(ids, status);
|
||||
}
|
||||
notifications.show({ color: 'green', message: t('status_updated') });
|
||||
};
|
||||
|
||||
const namedRowActions = (data: SalesDocumentEntity, defaultActions: any[]) => {
|
||||
if (!canEdit) return defaultActions;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
return {
|
||||
key: action.key,
|
||||
label: t(`action_${action.key}`),
|
||||
icon: <Icon size={15} />,
|
||||
onClick: () => {
|
||||
void applyStatus([String(data.id)], action.target);
|
||||
},
|
||||
};
|
||||
});
|
||||
extras.push({
|
||||
key: 'change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={15} />,
|
||||
onClick: () => {
|
||||
setPendingIds([String(data.id)]);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
return [...extras, ...defaultActions];
|
||||
};
|
||||
|
||||
const namedBulkActions = (selectedRows: SalesDocumentEntity[], defaultActions: any[]) => {
|
||||
if (!canEdit || selectedRows.length === 0) return defaultActions;
|
||||
const statuses = selectedRows.map((row) => row.status);
|
||||
const extras: any[] = namedActionsFor(documentType, statuses[0])
|
||||
.filter((action) => statuses.every((status) => (action.from as readonly string[]).includes(status ?? '')))
|
||||
.map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
return {
|
||||
key: `bulk-${action.key}`,
|
||||
label: t(`action_${action.key}`),
|
||||
icon: <Icon size={16} />,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
void applyStatus(selectedRows.map((row) => String(row.id)), action.target);
|
||||
},
|
||||
};
|
||||
});
|
||||
extras.push({
|
||||
key: 'bulk-change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={16} />,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
setPendingIds(selectedRows.map((row) => String(row.id)));
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
return [...extras, ...defaultActions];
|
||||
};
|
||||
|
||||
const importPageAction = canImport
|
||||
? {
|
||||
key: 'import',
|
||||
label: t('import_csv'),
|
||||
icon: <FileUp size={16} />,
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
tooltipLabel: t('import_csv'),
|
||||
onClick: () => setImportOpened(true),
|
||||
}
|
||||
: null;
|
||||
|
||||
const createOrderAction = (_data: SalesDocumentEntity, onClick: () => void) =>
|
||||
canEdit
|
||||
? {
|
||||
key: 'create-order',
|
||||
label: t('create_sales_order'),
|
||||
icon: <ShoppingCart size={16} />,
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
onClick,
|
||||
}
|
||||
: null;
|
||||
|
||||
const detailStatusActions = (data: SalesDocumentEntity, defaultActions: any[]) => {
|
||||
if (!canEdit) return defaultActions;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
return {
|
||||
key: action.key,
|
||||
label: t(`action_${action.key}`),
|
||||
icon: <Icon size={16} />,
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
void applyStatus([String(data.id)], action.target);
|
||||
},
|
||||
};
|
||||
});
|
||||
extras.push({
|
||||
key: 'change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={16} />,
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
setPendingIds([String(data.id)]);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
return [...extras, ...defaultActions];
|
||||
};
|
||||
|
||||
const modals = (
|
||||
<>
|
||||
<ChangeStatusModal
|
||||
opened={statusOpened}
|
||||
onClose={() => setStatusOpened(false)}
|
||||
documentType={documentType}
|
||||
onSubmit={async (status) => {
|
||||
await applyStatus(pendingIds, status);
|
||||
}}
|
||||
/>
|
||||
<ImportCsvModal
|
||||
opened={importOpened}
|
||||
onClose={() => setImportOpened(false)}
|
||||
onSubmit={async (file) => {
|
||||
await dataServices.importCsv(file);
|
||||
notifications.show({ color: 'green', message: t('import_success') });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
canEdit,
|
||||
namedRowActions,
|
||||
namedBulkActions,
|
||||
importPageAction,
|
||||
createOrderAction,
|
||||
detailStatusActions,
|
||||
applyStatus,
|
||||
modals,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSalesDocumentDetailReload() {
|
||||
return useDetailPageContext().reload;
|
||||
}
|
||||
|
||||
export function useMemoPageActions<T>(factory: () => T, deps: unknown[]) {
|
||||
return useMemo(factory, deps);
|
||||
}
|
||||
@@ -8,6 +8,9 @@ export const API_URL = {
|
||||
BRANCHES: '/branches',
|
||||
CUSTOMERS: '/customers',
|
||||
EMPLOYEES: '/employees',
|
||||
PRODUCTS: '/products',
|
||||
SALES_REQUESTS: '/sales-requests',
|
||||
SALES_ORDERS: '/sales-orders',
|
||||
CYCLES: '/cycles',
|
||||
PLANS: '/plans',
|
||||
SALES_INVOICES: '/sales-invoices',
|
||||
|
||||
@@ -3,6 +3,9 @@ export const WEB_URL = {
|
||||
BRANCHES: '/app/configuration/branches',
|
||||
CUSTOMERS: '/app/configuration/customers',
|
||||
EMPLOYEES: '/app/configuration/employees',
|
||||
PRODUCTS: '/app/configuration/products',
|
||||
SALES_REQUESTS: '/app/sales/requests',
|
||||
SALES_ORDERS: '/app/sales/orders',
|
||||
SALES_CYCLES: '/app/sales/cycles',
|
||||
SALES_PLANS: '/app/sales/plans',
|
||||
LOGISTICS_CYCLES: '/app/logistics/cycles',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const DECIMAL_PATTERN = /^\d+(\.\d{1,4})?$/;
|
||||
|
||||
function emptyToUndefined(value: unknown) {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function decimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') {
|
||||
return z.string().regex(DECIMAL_PATTERN, {
|
||||
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
|
||||
});
|
||||
}
|
||||
|
||||
export function optionalDecimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') {
|
||||
return z.preprocess(emptyToUndefined, decimalStringSchema(t, fieldKey).optional());
|
||||
}
|
||||
Reference in New Issue
Block a user