diff --git a/apps/web/src/apps/main/index.tsx b/apps/web/src/apps/main/index.tsx
index 9e209ac..355df58 100644
--- a/apps/web/src/apps/main/index.tsx
+++ b/apps/web/src/apps/main/index.tsx
@@ -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() {
} />
} />
} />
- } />
+ } />
} />
} />
} />
diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts
index 1546114..6162863 100644
--- a/apps/web/src/apps/main/layouts/data/menu.data.ts
+++ b/apps/web/src/apps/main/layouts/data/menu.data.ts
@@ -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',
+ },
],
},
{
diff --git a/apps/web/src/apps/main/layouts/languages/en/nav.json b/apps/web/src/apps/main/layouts/languages/en/nav.json
index c9b0b10..8d5e7ca 100644
--- a/apps/web/src/apps/main/layouts/languages/en/nav.json
+++ b/apps/web/src/apps/main/layouts/languages/en/nav.json
@@ -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"
}
diff --git a/apps/web/src/apps/main/layouts/languages/id/nav.json b/apps/web/src/apps/main/layouts/languages/id/nav.json
index 568f6a0..04247d9 100644
--- a/apps/web/src/apps/main/layouts/languages/id/nav.json
+++ b/apps/web/src/apps/main/layouts/languages/id/nav.json
@@ -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"
}
diff --git a/apps/web/src/apps/main/modules/configuration/index.tsx b/apps/web/src/apps/main/modules/configuration/index.tsx
index bb13de1..a4e0f32 100644
--- a/apps/web/src/apps/main/modules/configuration/index.tsx
+++ b/apps/web/src/apps/main/modules/configuration/index.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
);
diff --git a/apps/web/src/apps/main/modules/configuration/products/data/product.remote.service.test.ts b/apps/web/src/apps/main/modules/configuration/products/data/product.remote.service.test.ts
new file mode 100644
index 0000000..b1f6961
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/data/product.remote.service.test.ts
@@ -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' }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/products/data/product.remote.service.ts b/apps/web/src/apps/main/modules/configuration/products/data/product.remote.service.ts
new file mode 100644
index 0000000..5330ca6
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/data/product.remote.service.ts
@@ -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 {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ super(httpClient, {
+ ...config,
+ apiUrl: config.apiUrl ?? '/products',
+ });
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/constants/index.ts b/apps/web/src/apps/main/modules/configuration/products/domain/constants/index.ts
new file mode 100644
index 0000000..c60b65b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './product.constants';
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/constants/product.constants.ts b/apps/web/src/apps/main/modules/configuration/products/domain/constants/product.constants.ts
new file mode 100644
index 0000000..b8b82a8
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/constants/product.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { ProductEntity } from '../entities';
+
+export const productsModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'CONFIGURATION.PRODUCT',
+ translationNamespace: 'PRODUCTS',
+ apiUrl: '/products',
+ webUrl: '/app/configuration/products',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'MASTER_DATA',
+} as const;
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/entities/index.ts b/apps/web/src/apps/main/modules/configuration/products/domain/entities/index.ts
new file mode 100644
index 0000000..9120a7a
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './product.entity';
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/entities/product.entity.ts b/apps/web/src/apps/main/modules/configuration/products/domain/entities/product.entity.ts
new file mode 100644
index 0000000..7c0f457
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/entities/product.entity.ts
@@ -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;
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/factories/index.ts b/apps/web/src/apps/main/modules/configuration/products/domain/factories/index.ts
new file mode 100644
index 0000000..79616e0
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/factories/index.ts
@@ -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,
+});
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.test.ts b/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.test.ts
new file mode 100644
index 0000000..4e716ee
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.test.ts
@@ -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');
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.ts b/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.ts
new file mode 100644
index 0000000..a29453e
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.ts
@@ -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 {
+ 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): Partial {
+ return omitEmptyFields({
+ code: entity.code,
+ name: entity.name,
+ unit: entity.unit,
+ price: entity.price,
+ brand: entity.brand,
+ });
+ }
+
+ transformEditPayload(entity: Partial): Partial {
+ 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,
+ };
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/validators/product.validator.test.ts b/apps/web/src/apps/main/modules/configuration/products/domain/validators/product.validator.test.ts
new file mode 100644
index 0000000..0c92156
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/validators/product.validator.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/validators/product.validator.ts b/apps/web/src/apps/main/modules/configuration/products/domain/validators/product.validator.ts
new file mode 100644
index 0000000..ed01c13
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/domain/validators/product.validator.ts
@@ -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(),
+ ),
+ });
+};
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/configuration/products/presentation/components/detail-component/detail-general.tsx
new file mode 100644
index 0000000..1ea12de
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/components/detail-component/detail-general.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/configuration/products/presentation/components/form-component/form-general.tsx
new file mode 100644
index 0000000..721c82c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/components/form-component/form-general.tsx
@@ -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 (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/configuration/products/presentation/components/index-component/filter-content.tsx
new file mode 100644
index 0000000..8c5028e
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/components/index-component/filter-content.tsx
@@ -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; t: (key: string) => string }) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/configuration/products/presentation/factory/index.tsx
new file mode 100644
index 0000000..848723b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/factory/index.tsx
@@ -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 (
+
+ config={productsModuleConfig}
+ dataServices={productsDataService}
+ store={productsStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/languages/en/products.json b/apps/web/src/apps/main/modules/configuration/products/presentation/languages/en/products.json
new file mode 100644
index 0000000..c62227c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/languages/en/products.json
@@ -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>products1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/languages/id/products.json b/apps/web/src/apps/main/modules/configuration/products/presentation/languages/id/products.json
new file mode 100644
index 0000000..e26d6ec
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/languages/id/products.json
@@ -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>produk1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.detail.tsx b/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.detail.tsx
new file mode 100644
index 0000000..712b9f9
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.detail.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.form.tsx b/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.form.tsx
new file mode 100644
index 0000000..bbf8250
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.form.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.index.tsx b/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.index.tsx
new file mode 100644
index 0000000..cf137d0
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/pages/product.page.index.tsx
@@ -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[] = 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 ;
+ },
+ };
+ }, [t]);
+
+ return (
+ }} />
+ ),
+ icon: Package,
+ breadcrumbs: [
+ { label: t('nav:configuration'), type: 'text' },
+ { label: t('nav:configuration-products'), type: 'text' },
+ ],
+ }}
+ >
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/store/index.ts b/apps/web/src/apps/main/modules/configuration/products/presentation/store/index.ts
new file mode 100644
index 0000000..ade3a7c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/products/presentation/store/index.ts
@@ -0,0 +1,22 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { ProductEntity } from '../../domain/entities';
+
+export interface ProductsStoreState extends EnterpriseModuleState {}
+
+export const productsStore = create((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 }),
+}));
diff --git a/apps/web/src/apps/main/modules/field/sales/index.tsx b/apps/web/src/apps/main/modules/field/sales/index.tsx
deleted file mode 100644
index f67bd30..0000000
--- a/apps/web/src/apps/main/modules/field/sales/index.tsx
+++ /dev/null
@@ -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 (
-
- } />
- } />
- } />
-
- );
-}
diff --git a/apps/web/src/apps/main/modules/sales/index.tsx b/apps/web/src/apps/main/modules/sales/index.tsx
new file mode 100644
index 0000000..d1cb2f7
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/index.tsx
@@ -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 (
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/data/sales-order.remote.service.test.ts b/apps/web/src/apps/main/modules/sales/orders/data/sales-order.remote.service.test.ts
new file mode 100644
index 0000000..6855990
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/data/sales-order.remote.service.test.ts
@@ -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' },
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/orders/data/sales-order.remote.service.ts b/apps/web/src/apps/main/modules/sales/orders/data/sales-order.remote.service.ts
new file mode 100644
index 0000000..9fce6c4
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/data/sales-order.remote.service.ts
@@ -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 {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ super(httpClient, {
+ ...config,
+ apiUrl: config.apiUrl ?? '/sales-orders',
+ });
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/constants/index.ts b/apps/web/src/apps/main/modules/sales/orders/domain/constants/index.ts
new file mode 100644
index 0000000..d7c1e44
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './sales-order.constants';
diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/constants/sales-order.constants.ts b/apps/web/src/apps/main/modules/sales/orders/domain/constants/sales-order.constants.ts
new file mode 100644
index 0000000..a48c046
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/domain/constants/sales-order.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { SalesOrderEntity } from '../entities';
+
+export const salesOrdersModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'SALES.ORDER',
+ translationNamespace: 'SALES_ORDERS',
+ apiUrl: '/sales-orders',
+ webUrl: '/app/sales/orders',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'TRANSACTION',
+} as const;
diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/entities/index.ts b/apps/web/src/apps/main/modules/sales/orders/domain/entities/index.ts
new file mode 100644
index 0000000..c86835c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './sales-order.entity';
diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts b/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts
new file mode 100644
index 0000000..e63972e
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts
@@ -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;
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/factories/index.ts b/apps/web/src/apps/main/modules/sales/orders/domain/factories/index.ts
new file mode 100644
index 0000000..48c6b59
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/domain/factories/index.ts
@@ -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,
+});
diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.test.ts b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.test.ts
new file mode 100644
index 0000000..1b82028
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.test.ts
@@ -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');
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts
new file mode 100644
index 0000000..5ab9f64
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts
@@ -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 {
+ 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): Partial {
+ return omitEmptyFields(toSalesWritePayload(entity, { includeSalesRequestId: true })) as Partial;
+ }
+
+ transformEditPayload(entity: Partial): Partial {
+ return toSalesWritePayload(entity) as Partial;
+ }
+
+ transformPayloadFilter(filter: Record): Record {
+ return toSalesFilterPayload(filter);
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/components/form-component/form-sales-request.tsx b/apps/web/src/apps/main/modules/sales/orders/presentation/components/form-component/form-sales-request.tsx
new file mode 100644
index 0000000..7f0d696
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/presentation/components/form-component/form-sales-request.tsx
@@ -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(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 (
+
+
+ {t('section_source')}
+
+
+
+ control={formControl.control}
+ name="salesRequest"
+ label={t('common:fields.salesRequest')}
+ valueKey="id"
+ labelKey="code"
+ searchable
+ clearable
+ loadOptions={loadSalesRequestOptions}
+ defaultOptions={salesRequest ? [salesRequest] : []}
+ renderLabel={relationLabel}
+ />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/sales/orders/presentation/factory/index.tsx
new file mode 100644
index 0000000..03459a2
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/presentation/factory/index.tsx
@@ -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 (
+
+ config={salesOrdersModuleConfig}
+ dataServices={salesOrdersDataService}
+ store={salesOrdersStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json
new file mode 100644
index 0000000..a2ea2e1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json
@@ -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 orders1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json
new file mode 100644
index 0000000..2c86b60
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json
@@ -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 penjualan1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx
new file mode 100644
index 0000000..abac559
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx
@@ -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 (
+ actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])}
+ >
+
+ `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
+
+
+
+
+ {actions.modals}
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.form.tsx b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.form.tsx
new file mode 100644
index 0000000..a0828ad
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.form.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.index.tsx b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.index.tsx
new file mode 100644
index 0000000..decd0bf
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.index.tsx
@@ -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[] = 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 ? : null),
+ }),
+ [t],
+ );
+
+ return (
+ }} />
+ ),
+ icon: ShoppingCart,
+ breadcrumbs: [
+ { label: t('nav:sales'), type: 'text' },
+ { label: t('nav:sales-orders'), type: 'text' },
+ ],
+ }}
+ customPageActions={(pageActions) =>
+ actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions
+ }
+ >
+ actions.namedRowActions(data, defaultActions)}
+ customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)}
+ />
+ {actions.modals}
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/store/index.ts b/apps/web/src/apps/main/modules/sales/orders/presentation/store/index.ts
new file mode 100644
index 0000000..867d717
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/orders/presentation/store/index.ts
@@ -0,0 +1,18 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { SalesOrderEntity } from '../../domain/entities';
+
+export interface SalesOrdersStoreState extends EnterpriseModuleState {}
+
+export const salesOrdersStore = create((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 }),
+}));
diff --git a/apps/web/src/apps/main/modules/sales/requests/data/sales-request.remote.service.test.ts b/apps/web/src/apps/main/modules/sales/requests/data/sales-request.remote.service.test.ts
new file mode 100644
index 0000000..409a6a1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/data/sales-request.remote.service.test.ts
@@ -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' }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/requests/data/sales-request.remote.service.ts b/apps/web/src/apps/main/modules/sales/requests/data/sales-request.remote.service.ts
new file mode 100644
index 0000000..841146c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/data/sales-request.remote.service.ts
@@ -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 {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ super(httpClient, {
+ ...config,
+ apiUrl: config.apiUrl ?? '/sales-requests',
+ });
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/requests/domain/constants/index.ts b/apps/web/src/apps/main/modules/sales/requests/domain/constants/index.ts
new file mode 100644
index 0000000..e2bf942
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './sales-request.constants';
diff --git a/apps/web/src/apps/main/modules/sales/requests/domain/constants/sales-request.constants.ts b/apps/web/src/apps/main/modules/sales/requests/domain/constants/sales-request.constants.ts
new file mode 100644
index 0000000..2cfa58b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/domain/constants/sales-request.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { SalesRequestEntity } from '../entities';
+
+export const salesRequestsModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'SALES.REQUEST',
+ translationNamespace: 'SALES_REQUESTS',
+ apiUrl: '/sales-requests',
+ webUrl: '/app/sales/requests',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'TRANSACTION',
+} as const;
diff --git a/apps/web/src/apps/main/modules/sales/requests/domain/entities/index.ts b/apps/web/src/apps/main/modules/sales/requests/domain/entities/index.ts
new file mode 100644
index 0000000..e43207b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './sales-request.entity';
diff --git a/apps/web/src/apps/main/modules/sales/requests/domain/entities/sales-request.entity.ts b/apps/web/src/apps/main/modules/sales/requests/domain/entities/sales-request.entity.ts
new file mode 100644
index 0000000..39950c3
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/domain/entities/sales-request.entity.ts
@@ -0,0 +1,4 @@
+import type { SalesDocumentDto, SalesDocumentEntity } from '../../../shared/sales-document.entity';
+
+export type SalesRequestEntity = SalesDocumentEntity;
+export type SalesRequestDto = SalesDocumentDto;
diff --git a/apps/web/src/apps/main/modules/sales/requests/domain/factories/index.ts b/apps/web/src/apps/main/modules/sales/requests/domain/factories/index.ts
new file mode 100644
index 0000000..88ce496
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/domain/factories/index.ts
@@ -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,
+});
diff --git a/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.test.ts b/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.test.ts
new file mode 100644
index 0000000..80263e6
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.test.ts
@@ -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');
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.ts
new file mode 100644
index 0000000..fe34a9d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.ts
@@ -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 {
+ transformToEntity(dto: SalesRequestDto | SalesRequestEntity): SalesRequestEntity {
+ return mapSalesDocumentFromDto(dto as SalesRequestDto);
+ }
+
+ transformToDTO(entity: SalesRequestEntity): SalesRequestEntity {
+ return { ...entity };
+ }
+
+ transformCreatePayload(entity: Partial): Partial {
+ return omitEmptyFields(toSalesWritePayload(entity)) as Partial;
+ }
+
+ transformEditPayload(entity: Partial): Partial {
+ return toSalesWritePayload(entity) as Partial;
+ }
+
+ transformPayloadFilter(filter: Record): Record {
+ return toSalesFilterPayload(filter);
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/requests/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/sales/requests/presentation/factory/index.tsx
new file mode 100644
index 0000000..ffbc8ca
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/presentation/factory/index.tsx
@@ -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 (
+
+ config={salesRequestsModuleConfig}
+ dataServices={salesRequestsDataService}
+ store={salesRequestsStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/requests/presentation/languages/en/sales-requests.json b/apps/web/src/apps/main/modules/sales/requests/presentation/languages/en/sales-requests.json
new file mode 100644
index 0000000..aefc98d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/presentation/languages/en/sales-requests.json
@@ -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 requests1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/sales/requests/presentation/languages/id/sales-requests.json b/apps/web/src/apps/main/modules/sales/requests/presentation/languages/id/sales-requests.json
new file mode 100644
index 0000000..8b39685
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/presentation/languages/id/sales-requests.json
@@ -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 penjualan1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.detail.tsx b/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.detail.tsx
new file mode 100644
index 0000000..839c37c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.detail.tsx
@@ -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 (
+ {
+ 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;
+ }}
+ >
+
+
+
+
+
+
+ {actions.modals}
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.form.tsx b/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.form.tsx
new file mode 100644
index 0000000..4a6d330
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.form.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.index.tsx b/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.index.tsx
new file mode 100644
index 0000000..5b76659
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/presentation/pages/sales-request.page.index.tsx
@@ -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[] = 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 ? : null),
+ }),
+ [t],
+ );
+
+ return (
+ }} />
+ ),
+ icon: ClipboardList,
+ breadcrumbs: [
+ { label: t('nav:sales'), type: 'text' },
+ { label: t('nav:sales-requests'), type: 'text' },
+ ],
+ }}
+ customPageActions={(pageActions) =>
+ actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions
+ }
+ >
+ actions.namedRowActions(data, defaultActions)}
+ customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)}
+ />
+ {actions.modals}
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/requests/presentation/store/index.ts b/apps/web/src/apps/main/modules/sales/requests/presentation/store/index.ts
new file mode 100644
index 0000000..94e33be
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/requests/presentation/store/index.ts
@@ -0,0 +1,18 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { SalesRequestEntity } from '../../domain/entities';
+
+export interface SalesRequestsStoreState extends EnterpriseModuleState {}
+
+export const salesRequestsStore = create((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 }),
+}));
diff --git a/apps/web/src/apps/main/modules/sales/shared/change-status-modal.tsx b/apps/web/src/apps/main/modules/sales/shared/change-status-modal.tsx
new file mode 100644
index 0000000..e9c8efc
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/change-status-modal.tsx
@@ -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;
+}) {
+ const { t } = useEnterpriseModuleTranslationContext();
+ const form = useForm<{ status: string }>();
+
+ const handleSubmit = form.handleSubmit(async (values) => {
+ await onSubmit(values.status);
+ form.reset();
+ onClose();
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx b/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx
new file mode 100644
index 0000000..aafcf03
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+ const salesRequestId = data?.salesRequestId ?? data?.salesRequest?.id;
+
+ return (
+
+
+ {t('section_general')}
+
+
+
+
+ }
+ />
+
+
+
+
+ {salesRequestHref && (
+
+ salesRequestId ? (
+
+ {data?.salesRequest?.code || salesRequestId}
+
+ ) : (
+ '-'
+ )
+ }
+ />
+ )}
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/detail-images.tsx b/apps/web/src/apps/main/modules/sales/shared/detail-images.tsx
new file mode 100644
index 0000000..07d0503
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/detail-images.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const images = detailData?.images ?? [];
+
+ return (
+
+
+ {t('section_images')}
+
+
+
+
+
+ {t('common:fields.url')}
+ {t('common:fields.description')}
+
+
+
+ {images.length === 0 ? (
+
+
+
+ {t('empty_images')}
+
+
+
+ ) : (
+ images.map((image, index) => (
+
+
+
+ {image.url}
+
+
+ {image.description || '-'}
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/detail-location.tsx b/apps/web/src/apps/main/modules/sales/shared/detail-location.tsx
new file mode 100644
index 0000000..0e400e3
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/detail-location.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_location')}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/detail-products.tsx b/apps/web/src/apps/main/modules/sales/shared/detail-products.tsx
new file mode 100644
index 0000000..e42e1d0
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/detail-products.tsx
@@ -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();
+ 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 (
+
+
+ {t('section_products')}
+
+
+
+
+
+ {t('common:fields.product')}
+ {t('common:fields.quantity')}
+ {t('common:fields.price')}
+ {t('common:fields.lineTotal')}
+
+
+
+ {lines.length === 0 ? (
+
+
+
+ {t('empty_products')}
+
+
+
+ ) : (
+ 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 (
+
+ {relationLabel(line.product) || line.productId}
+ {line.quantity}
+ {line.price ? currency.format(line.price) : '-'}
+ {currency.format(total)}
+
+ );
+ })
+ )}
+
+
+
+
+ {t('common:fields.total')}: {currency.format(grandTotal)}
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/filter-content.tsx b/apps/web/src/apps/main/modules/sales/shared/filter-content.tsx
new file mode 100644
index 0000000..eddf867
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/filter-content.tsx
@@ -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;
+ t: (key: string) => string;
+ documentType: SalesDocumentType;
+}) {
+ return (
+
+
+
+
+ control={form.control}
+ name="customer"
+ label={t('common:fields.customer')}
+ valueKey="id"
+ labelKey="name"
+ searchable
+ clearable
+ loadOptions={loadCustomerOptions}
+ renderLabel={relationLabel}
+ />
+
+ control={form.control}
+ name="salesPerson"
+ label={t('common:fields.salesPerson')}
+ valueKey="id"
+ labelKey="name"
+ searchable
+ clearable
+ loadOptions={loadEmployeeOptions}
+ renderLabel={relationLabel}
+ />
+
+ control={form.control}
+ name="branch"
+ label={t('common:fields.branch')}
+ valueKey="id"
+ labelKey="name"
+ searchable
+ clearable
+ loadOptions={loadBranchOptions}
+ renderLabel={relationLabel}
+ />
+
+ control={form.control}
+ name="division"
+ label={t('common:fields.division')}
+ valueKey="id"
+ labelKey="name"
+ searchable
+ clearable
+ loadOptions={loadDivisionOptions}
+ renderLabel={relationLabel}
+ />
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/form-general.tsx b/apps/web/src/apps/main/modules/sales/shared/form-general.tsx
new file mode 100644
index 0000000..8473b70
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/form-general.tsx
@@ -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 (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+ control={formControl.control}
+ name="salesPerson"
+ label={t('common:fields.salesPerson')}
+ valueKey="id"
+ labelKey="name"
+ required
+ searchable
+ loadOptions={loadEmployeeOptions}
+ defaultOptions={salesPerson ? [salesPerson] : []}
+ renderLabel={relationLabel}
+ />
+
+ control={formControl.control}
+ name="branch"
+ label={t('common:fields.branch')}
+ valueKey="id"
+ labelKey="name"
+ required
+ searchable
+ loadOptions={loadBranchOptions}
+ defaultOptions={branch ? [branch] : []}
+ renderLabel={relationLabel}
+ />
+
+ control={formControl.control}
+ name="division"
+ label={t('common:fields.division')}
+ valueKey="id"
+ labelKey="name"
+ required
+ searchable
+ loadOptions={loadDivisionOptions}
+ defaultOptions={division ? [division] : []}
+ renderLabel={relationLabel}
+ />
+
+ control={formControl.control}
+ name="customer"
+ label={t('common:fields.customer')}
+ valueKey="id"
+ labelKey="name"
+ required
+ searchable
+ loadOptions={loadCustomerOptions}
+ defaultOptions={customer ? [customer] : []}
+ renderLabel={relationLabel}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/form-images.tsx b/apps/web/src/apps/main/modules/sales/shared/form-images.tsx
new file mode 100644
index 0000000..54beb21
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/form-images.tsx
@@ -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 (
+
+
+ {t('section_images')}
+ } onClick={() => append({ url: '', description: '' })}>
+ {t('add_image')}
+
+
+
+ {fields.length === 0 ? (
+
+ {t('empty_images')}
+
+ ) : (
+ fields.map((field, index) => (
+
+
+ remove(index)} aria-label={t('remove_image')}>
+
+
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/form-location.tsx b/apps/web/src/apps/main/modules/sales/shared/form-location.tsx
new file mode 100644
index 0000000..92590ba
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/form-location.tsx
@@ -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 (
+
+
+ {t('section_location')}
+
+
+
+ {
+ formControl.setValue('latitude', point.latitude, { shouldDirty: true, shouldValidate: true });
+ formControl.setValue('longitude', point.longitude, { shouldDirty: true, shouldValidate: true });
+ }}
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/form-notes.tsx b/apps/web/src/apps/main/modules/sales/shared/form-notes.tsx
new file mode 100644
index 0000000..791e2d7
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/form-notes.tsx
@@ -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 (
+
+
+ {t('section_notes')}
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/form-products.tsx b/apps/web/src/apps/main/modules/sales/shared/form-products.tsx
new file mode 100644
index 0000000..08cfc6c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/form-products.tsx
@@ -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 (
+
+
+ {t('section_products')}
+
+
+
+
+
+ {t('common:fields.product')}
+ {t('common:fields.quantity')}
+ {t('common:fields.price')}
+ {t('common:fields.lineTotal')}
+
+
+
+
+ {fields.map((field, index) => {
+ const line = products[index];
+ return (
+
+
+
+ control={formControl.control}
+ name={`products.${index}.product`}
+ valueKey="id"
+ labelKey="name"
+ searchable
+ loadOptions={loadProductOptions}
+ defaultOptions={line?.product ? [line.product] : []}
+ renderLabel={relationLabel}
+ />
+
+
+
+
+
+
+
+ {currency.format(lineTotal(line?.quantity, line?.price))}
+
+ remove(index)} aria-label={t('remove_line')}>
+
+
+
+
+ );
+ })}
+
+
+
+
+ }
+ onClick={() => append({ product: undefined, quantity: '1', price: '' })}
+ >
+ {t('add_line')}
+
+
+ {t('common:fields.total')}: {currency.format(grandTotal)}
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/import-csv-modal.tsx b/apps/web/src/apps/main/modules/sales/shared/import-csv-modal.tsx
new file mode 100644
index 0000000..53584d5
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/import-csv-modal.tsx
@@ -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;
+}) {
+ 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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/load-product-options.ts b/apps/web/src/apps/main/modules/sales/shared/load-product-options.ts
new file mode 100644
index 0000000..62d9272
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/load-product-options.ts
@@ -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((config) => productsDataService.getMany(config));
diff --git a/apps/web/src/apps/main/modules/sales/shared/load-sales-request-options.ts b/apps/web/src/apps/main/modules/sales/shared/load-sales-request-options.ts
new file mode 100644
index 0000000..60474be
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/load-sales-request-options.ts
@@ -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((config) =>
+ salesRequestsDataService.getMany(config),
+);
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.entity.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.entity.ts
new file mode 100644
index 0000000..917f6c4
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.entity.ts
@@ -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;
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts
new file mode 100644
index 0000000..b56b154
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts
@@ -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' });
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts
new file mode 100644
index 0000000..29abc52
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts
@@ -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,
+ options?: { includeSalesRequestId?: boolean },
+): Record {
+ 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 = {
+ 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): Record {
+ 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 ?? '',
+ })),
+ };
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.test.ts
new file mode 100644
index 0000000..cd019f5
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.test.ts
@@ -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;
+
+ 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',
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.ts
new file mode 100644
index 0000000..44c0b15
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.ts
@@ -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 extends TrackGoRemoteDataServices {
+ protected readonly resourceUrl: string;
+
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ 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, 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 },
+ });
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.test.ts
new file mode 100644
index 0000000..e018620
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts
new file mode 100644
index 0000000..0820ee4
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts
@@ -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(),
+ });
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts
new file mode 100644
index 0000000..48ff615
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts
@@ -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']);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-status.ts b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts
new file mode 100644
index 0000000..85ff3a9
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts
@@ -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 = {
+ draft: ['pending', 'rejected'],
+ pending: ['approved', 'rejected', 'draft'],
+ approved: [],
+ rejected: ['draft'],
+};
+
+const ORDER_TRANSITIONS: Record = {
+ 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[] {
+ 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}`),
+ }));
+}
diff --git a/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx b/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx
new file mode 100644
index 0000000..eb20dce
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx
@@ -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 = {
+ 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
+ >();
+ const [statusOpened, setStatusOpened] = useState(false);
+ const [importOpened, setImportOpened] = useState(false);
+ const [pendingIds, setPendingIds] = useState([]);
+ 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: ,
+ onClick: () => {
+ void applyStatus([String(data.id)], action.target);
+ },
+ };
+ });
+ extras.push({
+ key: 'change-status',
+ label: t('change_status'),
+ icon: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ intent: 'primary' as const,
+ variant: 'light' as const,
+ onClick: () => {
+ setPendingIds([String(data.id)]);
+ setStatusOpened(true);
+ },
+ });
+ return [...extras, ...defaultActions];
+ };
+
+ const modals = (
+ <>
+ setStatusOpened(false)}
+ documentType={documentType}
+ onSubmit={async (status) => {
+ await applyStatus(pendingIds, status);
+ }}
+ />
+ 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(factory: () => T, deps: unknown[]) {
+ return useMemo(factory, deps);
+}
diff --git a/apps/web/src/core/constants/api-url.ts b/apps/web/src/core/constants/api-url.ts
index 9eeadc5..06f3f23 100644
--- a/apps/web/src/core/constants/api-url.ts
+++ b/apps/web/src/core/constants/api-url.ts
@@ -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',
diff --git a/apps/web/src/core/constants/web-url.ts b/apps/web/src/core/constants/web-url.ts
index 8f62224..099e6ae 100644
--- a/apps/web/src/core/constants/web-url.ts
+++ b/apps/web/src/core/constants/web-url.ts
@@ -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',
diff --git a/apps/web/src/core/domain/decimal-string.schema.ts b/apps/web/src/core/domain/decimal-string.schema.ts
new file mode 100644
index 0000000..835f01d
--- /dev/null
+++ b/apps/web/src/core/domain/decimal-string.schema.ts
@@ -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());
+}
diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json
index e7f5037..815fd20 100644
--- a/packages/core-i18n/src/languages/en/common.json
+++ b/packages/core-i18n/src/languages/en/common.json
@@ -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",
diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json
index e3f44f6..88fc443 100644
--- a/packages/core-i18n/src/languages/id/common.json
+++ b/packages/core-i18n/src/languages/id/common.json
@@ -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",