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:
shancheas
2026-08-26 15:29:46 +07:00
parent ee6c3273d9
commit 4d9c83ee83
90 changed files with 3331 additions and 22 deletions
+2 -2
View File
@@ -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>
);
@@ -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';
@@ -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';
@@ -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,
});
@@ -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');
});
});
@@ -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,
};
}
}
@@ -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);
});
});
@@ -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(),
),
});
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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"
}
@@ -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"
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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,
});
@@ -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');
});
});
@@ -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);
}
}
@@ -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>
);
}
@@ -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"
}
@@ -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"
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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 }),
}));
@@ -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';
@@ -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,
});
@@ -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');
});
});
@@ -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>
);
}
@@ -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"
}
@@ -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"
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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);
}
+3
View File
@@ -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
View File
@@ -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());
}
@@ -218,7 +218,18 @@
"packingSlips": "Packing Slips",
"destinations": "Destinations",
"from": "From",
"to": "To"
"to": "To",
"branch": "Branch",
"customer": "Customer",
"salesPerson": "Sales Person",
"product": "Product",
"products": "Products",
"unit": "Unit",
"brand": "Brand",
"url": "URL",
"images": "Images",
"salesRequest": "Sales Request",
"lineTotal": "Line Total"
},
"map": {
"pickLocation": "Click the map to set latitude and longitude",
@@ -218,7 +218,18 @@
"packingSlips": "Surat Jalan",
"destinations": "Destinasi",
"from": "Dari",
"to": "Sampai"
"to": "Sampai",
"branch": "Cabang",
"customer": "Pelanggan",
"salesPerson": "Sales",
"product": "Produk",
"products": "Produk",
"unit": "Satuan",
"brand": "Merek",
"url": "URL",
"images": "Gambar",
"salesRequest": "Permintaan Penjualan",
"lineTotal": "Total Baris"
},
"map": {
"pickLocation": "Klik peta untuk mengatur latitude dan longitude",