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>products 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>produk 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 orders 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 penjualan 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 requests 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 penjualan 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 ( + +
+ + ({ value, label: t(`status_${value}`) }))} + /> + + + + + +
+
+ ); +} 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')} + + + + {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')}> + + + + + ); + })} + +
+
+ + + + {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",