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 f3686a3..c0e7dd0 100644
--- a/apps/web/src/apps/main/layouts/data/menu.data.ts
+++ b/apps/web/src/apps/main/layouts/data/menu.data.ts
@@ -85,14 +85,14 @@ export const MENU_ITEMS: MenuItemType[] = [
key: 'sales-invoices',
label: 'nav:sales-invoices',
icon: Receipt,
- path: '/app/sales/invoices',
+ path: '/app/sales/invoices/index',
moduleKey: 'SALES.INVOICE',
},
{
key: 'sales-payments',
label: 'nav:sales-payments',
icon: CreditCard,
- path: '/app/sales/payments',
+ path: '/app/sales/payments/index',
moduleKey: 'SALES.PAYMENT',
},
{
diff --git a/apps/web/src/apps/main/modules/sales/index.tsx b/apps/web/src/apps/main/modules/sales/index.tsx
index dc75f77..7da2c0a 100644
--- a/apps/web/src/apps/main/modules/sales/index.tsx
+++ b/apps/web/src/apps/main/modules/sales/index.tsx
@@ -7,6 +7,8 @@ const OrdersModule = lazy(() => import('./orders/presentation/factory'));
const EmployeesModule = lazy(() => import('../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../field/cycles/presentation/factory'));
const PlansModule = lazy(() => import('../field/plans/presentation/factory'));
+const InvoicesModule = lazy(() => import('./invoices/presentation/factory'));
+const PaymentsModule = lazy(() => import('./payments/presentation/factory'));
export default function SalesModule() {
return (
@@ -16,8 +18,8 @@ export default function SalesModule() {
} />
} />
} />
- } />
- } />
+ } />
+ } />
} />
} />
diff --git a/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.test.ts b/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.test.ts
new file mode 100644
index 0000000..03cb9b1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.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 { SalesInvoicesRemoteDataServices } from './sales-invoice.remote.service';
+import { SalesInvoicesRemoteDataTransformer } from '../domain/transformers/sales-invoice.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('SalesInvoicesRemoteDataServices', () => {
+ let httpClient: AxiosInstance;
+ let service: SalesInvoicesRemoteDataServices;
+
+ beforeEach(() => {
+ httpClient = createMockHttpClient();
+ service = new SalesInvoicesRemoteDataServices(httpClient, {
+ apiUrl: '/sales-invoices',
+ moduleKey: 'SALES.INVOICE',
+ transformer: new SalesInvoicesRemoteDataTransformer(),
+ });
+ });
+
+ 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-invoices/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-invoices/bulk-status',
+ method: 'POST',
+ data: { ids: ['so-1'], status: 'processed' },
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.ts b/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.ts
new file mode 100644
index 0000000..82b8da8
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.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 { SalesInvoiceEntity } from '../domain/entities';
+
+export class SalesInvoicesRemoteDataServices extends SalesDocumentRemoteDataServices {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ super(httpClient, {
+ ...config,
+ apiUrl: config.apiUrl ?? '/sales-invoices',
+ });
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/constants/index.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/constants/index.ts
new file mode 100644
index 0000000..1b9fbcd
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './sales-invoice.constants';
diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/constants/sales-invoice.constants.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/constants/sales-invoice.constants.ts
new file mode 100644
index 0000000..2810467
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/domain/constants/sales-invoice.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { SalesInvoiceEntity } from '../entities';
+
+export const salesInvoicesModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'SALES.INVOICE',
+ translationNamespace: 'SALES_INVOICES',
+ apiUrl: '/sales-invoices',
+ webUrl: '/app/sales/invoices',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'TRANSACTION',
+} as const;
diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/entities/index.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/entities/index.ts
new file mode 100644
index 0000000..70fdce9
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './sales-invoice.entity';
diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/entities/sales-invoice.entity.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/entities/sales-invoice.entity.ts
new file mode 100644
index 0000000..95054c1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/domain/entities/sales-invoice.entity.ts
@@ -0,0 +1,21 @@
+import type { SalesDocumentDto, SalesDocumentEntity } from '../../../shared/sales-document.entity';
+
+export interface SalesInvoiceEntity extends SalesDocumentEntity {
+ salesOrderId?: string | null;
+ salesOrder?: { id: string; code?: string; name?: string } | null;
+ salesOrderCode?: string | null;
+ packingSlipId?: string | null;
+ packingSlip?: { id: string; code?: string; name?: string } | null;
+ packingSlipCode?: string | null;
+ balance?: string | null;
+}
+
+export interface SalesInvoiceDto extends SalesDocumentDto {
+ salesOrderId?: string | null;
+ salesOrder?: { id: string; code?: string; name?: string } | null;
+ salesOrderCode?: string | null;
+ packingSlipId?: string | null;
+ packingSlip?: { id: string; code?: string; name?: string } | null;
+ packingSlipCode?: string | null;
+ balance?: string | null;
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/factories/index.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/factories/index.ts
new file mode 100644
index 0000000..7c76c68
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/domain/factories/index.ts
@@ -0,0 +1,12 @@
+import { apiClient } from '../../../../../../../core/lib/api-client';
+import { SalesInvoicesRemoteDataServices } from '../../data/sales-invoice.remote.service';
+import { salesInvoicesModuleConfig } from '../constants/sales-invoice.constants';
+import { SalesInvoicesRemoteDataTransformer } from '../transformers/sales-invoice.remote.transformer';
+
+export const salesInvoicesDataTransformer = new SalesInvoicesRemoteDataTransformer();
+
+export const salesInvoicesDataService = new SalesInvoicesRemoteDataServices(apiClient, {
+ apiUrl: salesInvoicesModuleConfig.apiUrl,
+ moduleKey: salesInvoicesModuleConfig.moduleKey,
+ transformer: salesInvoicesDataTransformer,
+});
diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.test.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.test.ts
new file mode 100644
index 0000000..75e8e38
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { SalesInvoicesRemoteDataTransformer } from './sales-invoice.remote.transformer';
+
+const transformer = new SalesInvoicesRemoteDataTransformer();
+
+describe('SalesInvoicesRemoteDataTransformer', () => {
+ it('keeps nested sales order, packing slip, and balance from the API response', () => {
+ const entity = transformer.transformToEntity({
+ id: 'inv-1',
+ date: Date.UTC(2026, 7, 26),
+ address: 'depok',
+ salesOrder: { id: 'so-1', code: 'SO-20260801-0001' },
+ packingSlip: { id: 'ps-1', code: 'PS-1' },
+ salesOrderCode: 'SO-20260801-0001',
+ packingSlipCode: 'PS-1',
+ balance: '10000.0000',
+ products: [],
+ } as any);
+
+ expect(entity.salesOrder).toEqual({ id: 'so-1', code: 'SO-20260801-0001' });
+ expect(entity.salesOrderId).toBe('so-1');
+ expect(entity.packingSlipId).toBe('ps-1');
+ expect(entity.balance).toBe('10000.0000');
+ });
+
+ it('includes parent ids on create, omits them on edit, and never writes images', () => {
+ 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' }],
+ images: [{ url: 'https://cdn.example/a.png' }],
+ salesOrder: { id: 'so-1' } as any,
+ packingSlip: { id: 'ps-1' } as any,
+ };
+
+ const createPayload = transformer.transformCreatePayload(entity);
+ const editPayload = transformer.transformEditPayload(entity);
+
+ expect(createPayload.salesOrderId).toBe('so-1');
+ expect(createPayload.packingSlipId).toBe('ps-1');
+ expect(createPayload).not.toHaveProperty('images');
+ expect(editPayload).not.toHaveProperty('salesOrderId');
+ expect(editPayload).not.toHaveProperty('images');
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.ts
new file mode 100644
index 0000000..6f1ec8e
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.ts
@@ -0,0 +1,51 @@
+import { BaseDataTransformer } from '@repo/core-api/data-services';
+import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
+import {
+ mapSalesDocumentFromDto,
+ relationId,
+ toLookup,
+ toSalesFilterPayload,
+ toSalesWritePayload,
+} from '../../../shared/sales-document.mapper';
+import type { SalesInvoiceDto, SalesInvoiceEntity } from '../entities';
+
+export class SalesInvoicesRemoteDataTransformer extends BaseDataTransformer {
+ transformToEntity(dto: SalesInvoiceDto | SalesInvoiceEntity): SalesInvoiceEntity {
+ const invoiceDto = dto as SalesInvoiceDto;
+ const base = mapSalesDocumentFromDto(invoiceDto);
+ const salesOrder = toLookup(invoiceDto.salesOrder, invoiceDto.salesOrderId);
+ const packingSlip = toLookup(invoiceDto.packingSlip, invoiceDto.packingSlipId);
+ return {
+ ...base,
+ salesOrderId: relationId(invoiceDto.salesOrder) ?? invoiceDto.salesOrderId ?? null,
+ salesOrder,
+ salesOrderCode: invoiceDto.salesOrderCode ?? salesOrder?.code ?? null,
+ packingSlipId: relationId(invoiceDto.packingSlip) ?? invoiceDto.packingSlipId ?? null,
+ packingSlip,
+ packingSlipCode: invoiceDto.packingSlipCode ?? packingSlip?.code ?? null,
+ balance: invoiceDto.balance ?? null,
+ };
+ }
+
+ transformToDTO(entity: SalesInvoiceEntity): SalesInvoiceEntity {
+ return { ...entity };
+ }
+
+ transformCreatePayload(entity: Partial): Partial {
+ return omitEmptyFields(
+ toSalesWritePayload(entity, {
+ includeSalesOrderId: true,
+ includePackingSlipId: true,
+ omitImages: true,
+ }),
+ ) as Partial;
+ }
+
+ transformEditPayload(entity: Partial): Partial {
+ return toSalesWritePayload(entity, { omitImages: true }) as Partial;
+ }
+
+ transformPayloadFilter(filter: Record): Record {
+ return toSalesFilterPayload(filter);
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/components/form-component/form-invoice-source.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/components/form-component/form-invoice-source.tsx
new file mode 100644
index 0000000..2244d00
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/components/form-component/form-invoice-source.tsx
@@ -0,0 +1,88 @@
+import { useEffect, useRef } from 'react';
+import { Box, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
+import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
+import { loadSalesOrderOptions } from '../../../../shared/load-sales-order-options';
+import { loadPackingSlipOptions, packingSlipsDataService } from '../../../../../field/shared/lookup.factories';
+import { relationLabel } from '../../../../../field/shared/relation-label';
+import { salesRequestToFormValues } from '../../../../shared/sales-document.mapper';
+import { salesOrdersDataService } from '../../../../orders/domain/factories';
+import type { SalesOrderEntity } from '../../../../orders/domain/entities';
+import type { LookupEntity } from '../../../../../field/shared/lookup.entity';
+
+export function FormInvoiceSource() {
+ const { formControl, isCreate } = useFormPageContext();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const salesOrder = formControl.watch('salesOrder');
+ const packingSlip = formControl.watch('packingSlip');
+ const appliedOrderId = useRef(null);
+ const appliedPackingSlipId = useRef(null);
+
+ useEffect(() => {
+ const id = salesOrder?.id;
+ if (!isCreate || !id || appliedOrderId.current === id) return;
+ appliedOrderId.current = id;
+ void salesOrdersDataService.getOne(id).then((result: { data?: { data?: SalesOrderEntity } }) => {
+ const entity = (result.data as { data?: SalesOrderEntity })?.data;
+ if (!entity) return;
+ const values = salesRequestToFormValues(entity);
+ formControl.reset({
+ ...formControl.getValues(),
+ salesOrder,
+ ...values,
+ images: [],
+ });
+ });
+ }, [formControl, isCreate, salesOrder]);
+
+ useEffect(() => {
+ const id = packingSlip?.id;
+ if (!isCreate || !id || appliedPackingSlipId.current === id) return;
+ appliedPackingSlipId.current = id;
+ void packingSlipsDataService.getOne(id).then((result: { data?: { data?: LookupEntity } }) => {
+ const entity = (result.data as { data?: LookupEntity })?.data;
+ if (!entity) return;
+ formControl.reset({
+ ...formControl.getValues(),
+ packingSlip: entity,
+ });
+ });
+ }, [formControl, isCreate, packingSlip]);
+
+ if (!isCreate) return null;
+
+ return (
+
+
+ {t('section_source')}
+
+
+
+
+ control={formControl.control}
+ name="salesOrder"
+ label={t('common:fields.salesOrder')}
+ valueKey="id"
+ labelKey="code"
+ searchable
+ clearable
+ loadOptions={loadSalesOrderOptions}
+ defaultOptions={salesOrder ? [salesOrder] : []}
+ renderLabel={relationLabel}
+ />
+
+ control={formControl.control}
+ name="packingSlip"
+ label={t('common:fields.packingSlip')}
+ valueKey="id"
+ labelKey="code"
+ searchable
+ clearable
+ loadOptions={loadPackingSlipOptions}
+ defaultOptions={packingSlip ? [packingSlip] : []}
+ renderLabel={relationLabel}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/factory/index.tsx
new file mode 100644
index 0000000..e08872b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/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 { salesInvoicesModuleConfig } from '../../domain/constants';
+import { salesInvoicesDataService } from '../../domain/factories';
+import { SalesInvoiceEntity } from '../../domain/entities';
+import { salesInvoicesStore } from '../store';
+
+import salesInvoicesId from '../languages/id/sales-invoices.json';
+import salesInvoicesEn from '../languages/en/sales-invoices.json';
+
+const IndexPage = lazy(() => import('../pages/sales-invoice.page.index'));
+const FormPage = lazy(() => import('../pages/sales-invoice.page.form'));
+const DetailPage = lazy(() => import('../pages/sales-invoice.page.detail'));
+
+registerModuleNamespace(salesInvoicesModuleConfig.translationNamespace, {
+ id: salesInvoicesId,
+ en: salesInvoicesEn,
+});
+
+export default function SalesInvoicesModule() {
+ return (
+
+ config={salesInvoicesModuleConfig}
+ dataServices={salesInvoicesDataService}
+ store={salesInvoicesStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json
new file mode 100644
index 0000000..a1ffafd
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json
@@ -0,0 +1,34 @@
+{
+ "title": "Sales Invoices",
+ "detail_page_title": "Sales Invoice Detail",
+ "create_page_title": "New Sales Invoice",
+ "edit_page_title": "Edit Sales Invoice",
+ "duplicate_page_title": "Duplicate Sales Invoice",
+ "description": "Create <1>sales invoices1> from a sales order or packing slip, or as a standalone document.",
+ "detail_page_description": "Review invoice header, location, products, and remaining balance.",
+ "create_page_description": "Create a sales invoice, optionally sourced from a sales order or packing slip.",
+ "edit_page_description": "Update invoice header, location, products, and notes.",
+ "duplicate_page_description": "Copy an existing sales invoice to create a new one.",
+ "section_general": "General",
+ "section_source": "Source",
+ "section_location": "Location",
+ "section_products": "Products",
+ "section_notes": "Notes",
+ "add_line": "Add line",
+ "remove_line": "Remove line",
+ "empty_products": "No product lines.",
+ "change_status": "Change status",
+ "import_csv": "Import CSV",
+ "csv_file": "CSV file",
+ "import_success": "CSV imported.",
+ "status_updated": "Status updated.",
+ "create_sales_payment": "Create Sales Payment",
+ "action_process": "Process",
+ "action_complete": "Complete",
+ "action_cancel": "Cancel",
+ "status_draft": "Draft",
+ "status_processed": "Processed",
+ "status_partial": "Partial",
+ "status_completed": "Completed",
+ "status_cancelled": "Cancelled"
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json
new file mode 100644
index 0000000..7a835bc
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json
@@ -0,0 +1,34 @@
+{
+ "title": "Faktur Penjualan",
+ "detail_page_title": "Detail Faktur Penjualan",
+ "create_page_title": "Faktur Penjualan Baru",
+ "edit_page_title": "Ubah Faktur Penjualan",
+ "duplicate_page_title": "Duplikat Faktur Penjualan",
+ "description": "Buat <1>faktur penjualan1> dari pesanan atau surat jalan, atau sebagai dokumen mandiri.",
+ "detail_page_description": "Tinjau header, lokasi, produk, dan sisa saldo faktur.",
+ "create_page_description": "Buat faktur penjualan, opsional dari pesanan atau surat jalan.",
+ "edit_page_description": "Perbarui header, lokasi, produk, dan catatan faktur.",
+ "duplicate_page_description": "Salin faktur penjualan yang ada untuk membuat data baru.",
+ "section_general": "Umum",
+ "section_source": "Sumber",
+ "section_location": "Lokasi",
+ "section_products": "Produk",
+ "section_notes": "Catatan",
+ "add_line": "Tambah baris",
+ "remove_line": "Hapus baris",
+ "empty_products": "Tidak ada baris produk.",
+ "change_status": "Ubah status",
+ "import_csv": "Impor CSV",
+ "csv_file": "File CSV",
+ "import_success": "CSV berhasil diimpor.",
+ "status_updated": "Status diperbarui.",
+ "create_sales_payment": "Buat Pembayaran Penjualan",
+ "action_process": "Proses",
+ "action_complete": "Selesaikan",
+ "action_cancel": "Batalkan",
+ "status_draft": "Draft",
+ "status_processed": "Diproses",
+ "status_partial": "Sebagian",
+ "status_completed": "Selesai",
+ "status_cancelled": "Dibatalkan"
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx
new file mode 100644
index 0000000..e3258ed
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx
@@ -0,0 +1,48 @@
+import { useNavigate } from 'react-router-dom';
+import { Stack } from '@repo/ui/components';
+import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
+import { salesInvoicesModuleConfig } from '../../domain/constants';
+import { DetailGeneral } from '../../../shared/detail-general';
+import { DetailLocation } from '../../../shared/detail-location';
+import { DetailProducts } from '../../../shared/detail-products';
+import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
+import { salesOrdersModuleConfig } from '../../../orders/domain/constants';
+import { salesPaymentsModuleConfig } from '../../../payments/domain/constants';
+import type { SalesInvoiceEntity } from '../../domain/entities';
+
+export default function SalesInvoicePageDetail() {
+ const { t } = useEnterpriseModuleTranslationContext();
+ const navigate = useNavigate();
+ const actions = useSalesDocumentActions('invoice');
+
+ return (
+ {
+ const createPayment = actions.createPaymentAction(data as SalesInvoiceEntity, () => {
+ navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
+ });
+ const withStatus = actions.detailStatusActions(data as SalesInvoiceEntity, pageActions ?? []);
+ return createPayment ? [createPayment, ...withStatus] : withStatus;
+ }}
+ >
+
+ `${salesOrdersModuleConfig.webUrl}/detail/${id}`}
+ showBalance
+ />
+
+
+
+ {actions.modals}
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.form.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.form.tsx
new file mode 100644
index 0000000..5374523
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.form.tsx
@@ -0,0 +1,118 @@
+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 { salesInvoicesModuleConfig } from '../../domain/constants';
+import { createSalesInvoiceSchema } from '../../../shared/sales-document.validator';
+import { FormInvoiceSource } from '../components/form-component/form-invoice-source';
+import { FormGeneral } from '../../../shared/form-general';
+import { FormLocation } from '../../../shared/form-location';
+import { FormProducts } from '../../../shared/form-products';
+import { FormNotes } from '../../../shared/form-notes';
+import { salesOrdersDataService } from '../../../orders/domain/factories';
+import { packingSlipsDataService } from '../../../../field/shared/lookup.factories';
+import { salesRequestToFormValues } from '../../../shared/sales-document.mapper';
+import type { SalesOrderEntity } from '../../../orders/domain/entities';
+import type { LookupEntity } from '../../../../field/shared/lookup.entity';
+
+export default function SalesInvoicePageForm({ 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(() => createSalesInvoiceSchema(t), [t]);
+ const formControl = useForm({
+ resolver: zodResolver(validator),
+ defaultValues: {
+ products: [{ quantity: '1', price: '' }],
+ },
+ });
+
+ useEffect(() => {
+ const salesOrderId = searchParams.get('salesOrderId');
+ const packingSlipId = searchParams.get('packingSlipId');
+ if (formPageType !== 'CREATE' || prefilled.current || (!salesOrderId && !packingSlipId)) return;
+ prefilled.current = true;
+
+ const load = async () => {
+ let next: Record = { ...formControl.getValues() };
+ if (salesOrderId) {
+ const result = await salesOrdersDataService.getOne(salesOrderId);
+ const entity = (result.data as { data?: SalesOrderEntity })?.data;
+ if (entity) {
+ next = {
+ ...next,
+ salesOrder: { id: entity.id as string, code: entity.code ?? undefined },
+ ...salesRequestToFormValues(entity),
+ images: [],
+ };
+ }
+ }
+ if (packingSlipId) {
+ const result = await packingSlipsDataService.getOne(packingSlipId);
+ const entity = (result.data as { data?: LookupEntity })?.data;
+ if (entity) {
+ next = { ...next, packingSlip: entity };
+ }
+ }
+ formControl.reset(next as any);
+ };
+
+ void load();
+ }, [formControl, formPageType, searchParams]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx
new file mode 100644
index 0000000..9f04fab
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx
@@ -0,0 +1,79 @@
+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 { Receipt } 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 { SalesInvoiceEntity } from '../../domain/entities';
+
+export default function SalesInvoicePageIndex() {
+ const { t } = useEnterpriseModuleTranslationContext();
+ const actions = useSalesDocumentActions('invoice');
+
+ 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,
+ },
+ { field: 'balance', headerName: t('common:fields.balance'), minWidth: 140 },
+ ],
+ [t],
+ );
+
+ const filterConfig = useMemo(
+ () => ({
+ renderBody: (form: any) => (form ? : null),
+ }),
+ [t],
+ );
+
+ return (
+ }} />
+ ),
+ icon: Receipt,
+ breadcrumbs: [
+ { label: t('nav:sales'), type: 'text' },
+ { label: t('nav:sales-invoices'), 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/invoices/presentation/store/index.ts b/apps/web/src/apps/main/modules/sales/invoices/presentation/store/index.ts
new file mode 100644
index 0000000..32bc78e
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/store/index.ts
@@ -0,0 +1,18 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { SalesInvoiceEntity } from '../../domain/entities';
+
+export interface SalesInvoicesStoreState extends EnterpriseModuleState {}
+
+export const salesInvoicesStore = 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/orders/domain/entities/sales-order.entity.ts b/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts
index e63972e..b39ab3e 100644
--- 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
@@ -7,4 +7,5 @@ export interface SalesOrderEntity extends SalesDocumentEntity {
export interface SalesOrderDto extends SalesDocumentDto {
salesRequestId?: string | null;
+ salesRequest?: { id: string; code?: string; name?: string } | null;
}
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
index 1b82028..3520dbe 100644
--- 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
@@ -4,6 +4,21 @@ import { SalesOrdersRemoteDataTransformer } from './sales-order.remote.transform
const transformer = new SalesOrdersRemoteDataTransformer();
describe('SalesOrdersRemoteDataTransformer', () => {
+ it('keeps nested salesRequest code from the API response', () => {
+ const entity = transformer.transformToEntity({
+ id: 'so-1',
+ date: Date.UTC(2026, 7, 26),
+ address: 'depok',
+ salesRequest: { id: 'sr-1', code: 'SR-20260801-0001' },
+ branch: { id: 'br-1', code: 'B_411587', name: 'Jakarta Pusat' },
+ products: [],
+ } as any);
+
+ expect(entity.salesRequest).toEqual({ id: 'sr-1', code: 'SR-20260801-0001' });
+ expect(entity.salesRequestId).toBe('sr-1');
+ expect(entity.branch).toEqual({ id: 'br-1', code: 'B_411587', name: 'Jakarta Pusat' });
+ });
+
it('includes salesRequestId on create and omits it on edit', () => {
const entity = {
date: '2026-08-26',
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
index 5ab9f64..a1e3f50 100644
--- 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
@@ -2,7 +2,8 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import {
mapSalesDocumentFromDto,
- toRelationStub,
+ relationId,
+ toLookup,
toSalesFilterPayload,
toSalesWritePayload,
} from '../../../shared/sales-document.mapper';
@@ -10,11 +11,13 @@ import type { SalesOrderDto, SalesOrderEntity } from '../entities';
export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer {
transformToEntity(dto: SalesOrderDto | SalesOrderEntity): SalesOrderEntity {
- const base = mapSalesDocumentFromDto(dto as SalesOrderDto);
+ const orderDto = dto as SalesOrderDto;
+ const base = mapSalesDocumentFromDto(orderDto);
+ const salesRequest = toLookup(orderDto.salesRequest, orderDto.salesRequestId);
return {
...base,
- salesRequestId: (dto as SalesOrderDto).salesRequestId ?? null,
- salesRequest: toRelationStub((dto as SalesOrderDto).salesRequestId),
+ salesRequestId: relationId(orderDto.salesRequest) ?? orderDto.salesRequestId ?? null,
+ salesRequest,
};
}
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
index a2ea2e1..e4e1816 100644
--- 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
@@ -26,6 +26,7 @@
"csv_file": "CSV file",
"import_success": "CSV imported.",
"status_updated": "Status updated.",
+ "create_sales_invoice": "Create Sales Invoice",
"action_process": "Process",
"action_complete": "Complete",
"action_cancel": "Cancel",
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
index 2c86b60..b1555e4 100644
--- 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
@@ -26,6 +26,7 @@
"csv_file": "File CSV",
"import_success": "CSV berhasil diimpor.",
"status_updated": "Status diperbarui.",
+ "create_sales_invoice": "Buat Faktur Penjualan",
"action_process": "Proses",
"action_complete": "Selesaikan",
"action_cancel": "Batalkan",
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
index f9e8cf1..e7b628d 100644
--- 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
@@ -1,3 +1,4 @@
+import { useNavigate } from 'react-router-dom';
import { Stack } from '@repo/ui/components';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { salesOrdersModuleConfig } from '../../domain/constants';
@@ -7,10 +8,12 @@ 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 { salesInvoicesModuleConfig } from '../../../invoices/domain/constants';
import type { SalesOrderEntity } from '../../domain/entities';
export default function SalesOrderPageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
+ const navigate = useNavigate();
const actions = useSalesDocumentActions('order');
return (
@@ -24,9 +27,13 @@ export default function SalesOrderPageDetail() {
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
],
}}
- customPageActions={(data, pageActions) =>
- actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])
- }
+ customPageActions={(data, pageActions) => {
+ const createInvoice = actions.createInvoiceAction(data as SalesOrderEntity, () => {
+ navigate(`${salesInvoicesModuleConfig.webUrl}/create?salesOrderId=${data.id}`);
+ });
+ const withStatus = actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? []);
+ return createInvoice ? [createInvoice, ...withStatus] : withStatus;
+ }}
>
`${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
diff --git a/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.test.ts b/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.test.ts
new file mode 100644
index 0000000..79a69e2
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.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 { SalesPaymentsRemoteDataServices } from './sales-payment.remote.service';
+import { SalesPaymentsRemoteDataTransformer } from '../domain/transformers/sales-payment.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('SalesPaymentsRemoteDataServices', () => {
+ let httpClient: AxiosInstance;
+ let service: SalesPaymentsRemoteDataServices;
+
+ beforeEach(() => {
+ httpClient = createMockHttpClient();
+ service = new SalesPaymentsRemoteDataServices(httpClient, {
+ apiUrl: '/sales-payments',
+ moduleKey: 'SALES.PAYMENT',
+ transformer: new SalesPaymentsRemoteDataTransformer(),
+ });
+ });
+
+ 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-payments/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-payments/bulk-status',
+ method: 'POST',
+ data: { ids: ['so-1'], status: 'processed' },
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.ts b/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.ts
new file mode 100644
index 0000000..7ea4398
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.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 { SalesPaymentEntity } from '../domain/entities';
+
+export class SalesPaymentsRemoteDataServices extends SalesDocumentRemoteDataServices {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ super(httpClient, {
+ ...config,
+ apiUrl: config.apiUrl ?? '/sales-payments',
+ });
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/constants/index.ts b/apps/web/src/apps/main/modules/sales/payments/domain/constants/index.ts
new file mode 100644
index 0000000..12a0640
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './sales-payment.constants';
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/constants/sales-payment.constants.ts b/apps/web/src/apps/main/modules/sales/payments/domain/constants/sales-payment.constants.ts
new file mode 100644
index 0000000..6856178
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/constants/sales-payment.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { SalesPaymentEntity } from '../entities';
+
+export const salesPaymentsModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'SALES.PAYMENT',
+ translationNamespace: 'SALES_PAYMENTS',
+ apiUrl: '/sales-payments',
+ webUrl: '/app/sales/payments',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'TRANSACTION',
+} as const;
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/entities/index.ts b/apps/web/src/apps/main/modules/sales/payments/domain/entities/index.ts
new file mode 100644
index 0000000..76e1d6a
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './sales-payment.entity';
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/entities/sales-payment.entity.ts b/apps/web/src/apps/main/modules/sales/payments/domain/entities/sales-payment.entity.ts
new file mode 100644
index 0000000..b46ffed
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/entities/sales-payment.entity.ts
@@ -0,0 +1,41 @@
+import type { BaseEntity } from '@repo/core-api/data-services';
+import type { LookupStub, SalesImageEntity } from '../../../shared/sales-document.entity';
+
+export interface SalesPaymentAllocationEntity {
+ id?: string;
+ invoiceId?: string;
+ invoice?: LookupStub | null;
+ amount: string;
+}
+
+export interface SalesPaymentEntity extends BaseEntity {
+ code?: string | null;
+ date: string;
+ notes?: string | null;
+ invoices?: SalesPaymentAllocationEntity[];
+ images?: SalesImageEntity[];
+ status?: string;
+ createdAt?: number;
+ updatedAt?: number;
+ createdBy?: string | { id?: string; username?: string };
+ updatedBy?: string | { id?: string; username?: string };
+}
+
+export interface SalesPaymentDto {
+ id?: string;
+ code?: string | null;
+ date: string | number;
+ notes?: string | null;
+ invoices?: Array<{
+ id?: string;
+ invoiceId?: string;
+ invoice?: LookupStub | null;
+ amount: string;
+ }>;
+ images?: Array<{ id?: string; url: string; description?: string | null }>;
+ status?: string;
+ createdAt?: number;
+ updatedAt?: number;
+ createdBy?: string | { id?: string; username?: string };
+ updatedBy?: string | { id?: string; username?: string };
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/factories/index.ts b/apps/web/src/apps/main/modules/sales/payments/domain/factories/index.ts
new file mode 100644
index 0000000..4c41893
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/factories/index.ts
@@ -0,0 +1,12 @@
+import { apiClient } from '../../../../../../../core/lib/api-client';
+import { SalesPaymentsRemoteDataServices } from '../../data/sales-payment.remote.service';
+import { salesPaymentsModuleConfig } from '../constants/sales-payment.constants';
+import { SalesPaymentsRemoteDataTransformer } from '../transformers/sales-payment.remote.transformer';
+
+export const salesPaymentsDataTransformer = new SalesPaymentsRemoteDataTransformer();
+
+export const salesPaymentsDataService = new SalesPaymentsRemoteDataServices(apiClient, {
+ apiUrl: salesPaymentsModuleConfig.apiUrl,
+ moduleKey: salesPaymentsModuleConfig.moduleKey,
+ transformer: salesPaymentsDataTransformer,
+});
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.test.ts b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.test.ts
new file mode 100644
index 0000000..f78facb
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { SalesPaymentsRemoteDataTransformer } from './sales-payment.remote.transformer';
+
+const transformer = new SalesPaymentsRemoteDataTransformer();
+
+describe('SalesPaymentsRemoteDataTransformer', () => {
+ it('maps list rows without allocations and detail rows with invoices and images', () => {
+ const listEntity = transformer.transformToEntity({
+ id: 'pay-1',
+ code: 'SP-1',
+ date: Date.UTC(2026, 7, 26),
+ notes: null,
+ status: 'draft',
+ } as any);
+ expect(listEntity.invoices).toEqual([]);
+ expect(listEntity.images).toEqual([]);
+
+ const detailEntity = transformer.transformToEntity({
+ id: 'pay-1',
+ date: Date.UTC(2026, 7, 26),
+ invoices: [{ id: 'a-1', invoiceId: 'inv-1', invoice: { id: 'inv-1', code: 'INV-1' }, amount: '10000.0000' }],
+ images: [{ id: 'img-1', url: 'https://cdn.example/a.png', description: null }],
+ } as any);
+ expect(detailEntity.invoices).toEqual([
+ { id: 'a-1', invoiceId: 'inv-1', invoice: { id: 'inv-1', code: 'INV-1' }, amount: '10000.0000' },
+ ]);
+ expect(detailEntity.images?.[0].url).toBe('https://cdn.example/a.png');
+ });
+
+ it('writes invoiceId and amount on create', () => {
+ const payload = transformer.transformCreatePayload({
+ date: '2026-08-26',
+ invoices: [{ invoice: { id: 'inv-1' }, amount: '10000.0000' }],
+ images: [{ url: 'https://cdn.example/a.png', description: '' }],
+ });
+ expect(payload.invoices).toEqual([{ invoiceId: 'inv-1', amount: '10000.0000' }]);
+ expect(payload.images).toEqual([{ url: 'https://cdn.example/a.png' }]);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts
new file mode 100644
index 0000000..465987d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts
@@ -0,0 +1,70 @@
+import { BaseDataTransformer } from '@repo/core-api/data-services';
+import { formatDateValue, parseDateValue } from '@repo/ui/form';
+import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
+import { mapSalesImagesFromDto, relationId, toLookup } from '../../../shared/sales-document.mapper';
+import type { SalesPaymentDto, SalesPaymentEntity } from '../entities';
+
+export class SalesPaymentsRemoteDataTransformer extends BaseDataTransformer {
+ transformToEntity(dto: SalesPaymentDto | SalesPaymentEntity): SalesPaymentEntity {
+ const paymentDto = dto as SalesPaymentDto;
+ return {
+ id: paymentDto.id,
+ code: paymentDto.code ?? null,
+ date: formatDateValue(parseDateValue(paymentDto.date) ?? undefined),
+ notes: paymentDto.notes ?? null,
+ invoices: (paymentDto.invoices ?? []).map((row) => {
+ const invoice = toLookup(row.invoice, row.invoiceId);
+ return {
+ id: row.id,
+ invoiceId: relationId(row.invoice) ?? row.invoiceId,
+ invoice,
+ amount: row.amount,
+ };
+ }),
+ images: mapSalesImagesFromDto({ images: paymentDto.images } as any),
+ status: paymentDto.status,
+ createdAt: paymentDto.createdAt,
+ updatedAt: paymentDto.updatedAt,
+ createdBy: paymentDto.createdBy,
+ updatedBy: paymentDto.updatedBy,
+ };
+ }
+
+ transformToDTO(entity: SalesPaymentEntity): SalesPaymentEntity {
+ return { ...entity };
+ }
+
+ transformCreatePayload(entity: Partial): Partial {
+ return omitEmptyFields(this.toWritePayload(entity)) as Partial;
+ }
+
+ transformEditPayload(entity: Partial): Partial {
+ return this.toWritePayload(entity) as Partial;
+ }
+
+ transformPayloadFilter(filter: Record): Record {
+ return omitEmptyFields({ ...filter });
+ }
+
+ private toWritePayload(entity: Partial): Record {
+ const invoices = (entity.invoices ?? [])
+ .map((row) => {
+ const invoiceId = relationId(row.invoice) ?? row.invoiceId;
+ if (!invoiceId) return null;
+ return omitEmptyFields({ invoiceId, amount: row.amount });
+ })
+ .filter(Boolean);
+
+ const images = (entity.images ?? [])
+ .filter((image) => Boolean(image?.url))
+ .map((image) => omitEmptyFields({ url: image.url, description: image.description }));
+
+ return {
+ code: entity.code,
+ date: formatDateValue(entity.date),
+ notes: emptyToNull(entity.notes),
+ invoices,
+ images,
+ };
+ }
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.test.ts b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.test.ts
new file mode 100644
index 0000000..a39effa
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { createSalesPaymentSchema } from './sales-payment.validator';
+
+const t = (key: string) => key;
+const invoice = { id: 'inv-1', code: 'INV-1' };
+
+describe('createSalesPaymentSchema', () => {
+ const schema = createSalesPaymentSchema(t);
+
+ it('accepts a payment with at least one allocation', () => {
+ expect(
+ schema.safeParse({
+ date: '2026-08-26',
+ invoices: [{ invoice, amount: '10000.0000' }],
+ }).success,
+ ).toBe(true);
+ });
+
+ it('rejects empty allocations', () => {
+ expect(schema.safeParse({ date: '2026-08-26', invoices: [] }).success).toBe(false);
+ });
+
+ it('rejects a non-decimal amount', () => {
+ expect(
+ schema.safeParse({
+ date: '2026-08-26',
+ invoices: [{ invoice, amount: 'abc' }],
+ }).success,
+ ).toBe(false);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts
new file mode 100644
index 0000000..6f7a493
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts
@@ -0,0 +1,49 @@
+import { z } from 'zod';
+import { compose, maxLength, required } from '@repo/ui/validators';
+import { decimalStringSchema } from '../../../../../../../core/domain/decimal-string.schema';
+import { salesImageSchema } from '../../../shared/sales-document.validator';
+
+const NOTES_MAX = 1024;
+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 createSalesPaymentSchema(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'))),
+ notes: z.preprocess(
+ emptyToUndefined,
+ compose(z.string(), maxLength(NOTES_MAX, t('common:fields.notes'))).optional(),
+ ),
+ invoices: z
+ .array(
+ z.object({
+ id: z.string().optional(),
+ invoice: relationSchema,
+ amount: decimalStringSchema(t, 'common:fields.amount'),
+ }),
+ )
+ .min(1),
+ images: z.array(salesImageSchema(t)).optional(),
+ });
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx
new file mode 100644
index 0000000..95fb3c8
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx
@@ -0,0 +1,46 @@
+import { Box, Paper, Table, Text } from '@repo/ui/components';
+import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
+import type { SalesPaymentEntity } from '../../../domain/entities';
+import { relationLabel } from '../../../../../field/shared/relation-label';
+
+export function DetailAllocations() {
+ const { detailData } = useDetailPageContext();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const rows = detailData?.invoices ?? [];
+
+ return (
+
+
+ {t('section_allocations')}
+
+
+
+
+
+ {t('common:fields.invoice')}
+ {t('common:fields.amount')}
+
+
+
+ {rows.length === 0 ? (
+
+
+
+ {t('empty_allocations')}
+
+
+
+ ) : (
+ rows.map((row, index) => (
+
+ {relationLabel(row.invoice) || row.invoiceId}
+ {row.amount}
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-payment-general.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-payment-general.tsx
new file mode 100644
index 0000000..6055a22
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-payment-general.tsx
@@ -0,0 +1,43 @@
+import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
+import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
+import type { SalesPaymentEntity } from '../../../domain/entities';
+
+export function DetailPaymentGeneral() {
+ const { detailData } = useDetailPageContext();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_general')}
+
+
+
+
+ }
+ />
+ }
+ />
+
+ }
+ />
+ }
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx
new file mode 100644
index 0000000..7066825
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx
@@ -0,0 +1,85 @@
+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 { loadSalesInvoiceOptions } from '../../../../../field/shared/lookup.factories';
+import { relationLabel } from '../../../../../field/shared/relation-label';
+import type { LookupEntity } from '../../../../../field/shared/lookup.entity';
+
+export function FormAllocations() {
+ const { formControl } = useFormPageContext();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const { fields, append, remove } = useFieldArray({
+ control: formControl.control,
+ name: 'invoices',
+ });
+ const invoices = useWatch({ control: formControl.control, name: 'invoices' }) ?? [];
+
+ return (
+
+
+ {t('section_allocations')}
+
+
+
+
+
+ {t('common:fields.invoice')}
+ {t('common:fields.amount')}
+
+
+
+
+ {fields.map((field, index) => {
+ const line = invoices[index];
+ return (
+
+
+
+ control={formControl.control}
+ name={`invoices.${index}.invoice`}
+ valueKey="id"
+ labelKey="code"
+ searchable
+ loadOptions={loadSalesInvoiceOptions}
+ defaultOptions={line?.invoice ? [line.invoice] : []}
+ renderLabel={relationLabel}
+ />
+
+
+
+
+
+ remove(index)}
+ aria-label={t('remove_allocation')}
+ >
+
+
+
+
+ );
+ })}
+
+
+
+
+ }
+ onClick={() => append({ amount: '' })}
+ >
+ {t('add_allocation')}
+
+ {fields.length === 0 ? (
+
+ {t('empty_allocations')}
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx
new file mode 100644
index 0000000..734e746
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx
@@ -0,0 +1,32 @@
+import { Box, FieldDatePicker, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
+import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
+
+export function FormPaymentGeneral() {
+ const { formControl } = useFormPageContext();
+ const { t } = useEnterpriseModuleTranslationContext();
+
+ return (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/factory/index.tsx
new file mode 100644
index 0000000..1bd5743
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/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 { salesPaymentsModuleConfig } from '../../domain/constants';
+import { salesPaymentsDataService } from '../../domain/factories';
+import { SalesPaymentEntity } from '../../domain/entities';
+import { salesPaymentsStore } from '../store';
+
+import salesPaymentsId from '../languages/id/sales-payments.json';
+import salesPaymentsEn from '../languages/en/sales-payments.json';
+
+const IndexPage = lazy(() => import('../pages/sales-payment.page.index'));
+const FormPage = lazy(() => import('../pages/sales-payment.page.form'));
+const DetailPage = lazy(() => import('../pages/sales-payment.page.detail'));
+
+registerModuleNamespace(salesPaymentsModuleConfig.translationNamespace, {
+ id: salesPaymentsId,
+ en: salesPaymentsEn,
+});
+
+export default function SalesPaymentsModule() {
+ return (
+
+ config={salesPaymentsModuleConfig}
+ dataServices={salesPaymentsDataService}
+ store={salesPaymentsStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/languages/en/sales-payments.json b/apps/web/src/apps/main/modules/sales/payments/presentation/languages/en/sales-payments.json
new file mode 100644
index 0000000..c9dcaaa
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/languages/en/sales-payments.json
@@ -0,0 +1,34 @@
+{
+ "title": "Sales Payments",
+ "detail_page_title": "Sales Payment Detail",
+ "create_page_title": "New Sales Payment",
+ "edit_page_title": "Edit Sales Payment",
+ "duplicate_page_title": "Duplicate Sales Payment",
+ "description": "Record <1>sales payments1> as allocations against one or more invoices.",
+ "detail_page_description": "Review payment header, invoice allocations, and images.",
+ "create_page_description": "Create a sales payment with invoice allocations and optional images.",
+ "edit_page_description": "Update payment date, allocations, notes, and images.",
+ "duplicate_page_description": "Copy an existing sales payment to create a new one.",
+ "section_general": "General",
+ "section_allocations": "Allocations",
+ "section_images": "Images",
+ "section_notes": "Notes",
+ "add_allocation": "Add allocation",
+ "remove_allocation": "Remove allocation",
+ "empty_allocations": "No invoice allocations.",
+ "add_image": "Add image",
+ "remove_image": "Remove image",
+ "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_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/payments/presentation/languages/id/sales-payments.json b/apps/web/src/apps/main/modules/sales/payments/presentation/languages/id/sales-payments.json
new file mode 100644
index 0000000..ed1ec3d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/languages/id/sales-payments.json
@@ -0,0 +1,34 @@
+{
+ "title": "Pembayaran Penjualan",
+ "detail_page_title": "Detail Pembayaran Penjualan",
+ "create_page_title": "Pembayaran Penjualan Baru",
+ "edit_page_title": "Ubah Pembayaran Penjualan",
+ "duplicate_page_title": "Duplikat Pembayaran Penjualan",
+ "description": "Catat <1>pembayaran penjualan1> sebagai alokasi ke satu atau lebih faktur.",
+ "detail_page_description": "Tinjau header pembayaran, alokasi faktur, dan gambar.",
+ "create_page_description": "Buat pembayaran penjualan dengan alokasi faktur dan gambar opsional.",
+ "edit_page_description": "Perbarui tanggal, alokasi, catatan, dan gambar pembayaran.",
+ "duplicate_page_description": "Salin pembayaran penjualan yang ada untuk membuat data baru.",
+ "section_general": "Umum",
+ "section_allocations": "Alokasi",
+ "section_images": "Gambar",
+ "section_notes": "Catatan",
+ "add_allocation": "Tambah alokasi",
+ "remove_allocation": "Hapus alokasi",
+ "empty_allocations": "Tidak ada alokasi faktur.",
+ "add_image": "Tambah gambar",
+ "remove_image": "Hapus gambar",
+ "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_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/payments/presentation/pages/sales-payment.page.detail.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.detail.tsx
new file mode 100644
index 0000000..d787888
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.detail.tsx
@@ -0,0 +1,37 @@
+import { Stack } from '@repo/ui/components';
+import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
+import { salesPaymentsModuleConfig } from '../../domain/constants';
+import { DetailPaymentGeneral } from '../components/detail-component/detail-payment-general';
+import { DetailAllocations } from '../components/detail-component/detail-allocations';
+import { DetailImages } from '../../../shared/detail-images';
+import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
+import type { SalesPaymentEntity } from '../../domain/entities';
+
+export default function SalesPaymentPageDetail() {
+ const { t } = useEnterpriseModuleTranslationContext();
+ const actions = useSalesDocumentActions('payment');
+
+ return (
+
+ actions.detailStatusActions(data as SalesPaymentEntity, pageActions ?? [])
+ }
+ >
+
+
+
+
+
+ {actions.modals}
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx
new file mode 100644
index 0000000..34f867d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.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 { salesPaymentsModuleConfig } from '../../domain/constants';
+import { createSalesPaymentSchema } from '../../domain/validators/sales-payment.validator';
+import { FormPaymentGeneral } from '../components/form-component/form-payment-general';
+import { FormAllocations } from '../components/form-component/form-allocations';
+import { FormImages } from '../../../shared/form-images';
+import { FormNotes } from '../../../shared/form-notes';
+import { salesInvoicesDataService } from '../../../invoices/domain/factories';
+import type { SalesInvoiceEntity } from '../../../invoices/domain/entities';
+
+export default function SalesPaymentPageForm({ 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(() => createSalesPaymentSchema(t), [t]);
+ const formControl = useForm({
+ resolver: zodResolver(validator),
+ defaultValues: {
+ invoices: [{ amount: '' }],
+ images: [],
+ },
+ });
+
+ useEffect(() => {
+ const invoiceId = searchParams.get('invoiceId');
+ if (formPageType !== 'CREATE' || !invoiceId || prefilled.current) return;
+ prefilled.current = true;
+ void salesInvoicesDataService.getOne(invoiceId).then((result) => {
+ const entity = (result.data as { data?: SalesInvoiceEntity })?.data;
+ if (!entity) return;
+ formControl.reset({
+ invoices: [
+ {
+ invoice: { id: entity.id as string, code: entity.code ?? undefined },
+ amount: entity.balance ?? '',
+ },
+ ],
+ images: [],
+ } as any);
+ });
+ }, [formControl, formPageType, searchParams]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.index.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.index.tsx
new file mode 100644
index 0000000..0201599
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.index.tsx
@@ -0,0 +1,59 @@
+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 { CreditCard } from 'lucide-react';
+import { SalesFilterFormContent } from '../../../shared/filter-content';
+import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
+import type { SalesPaymentEntity } from '../../domain/entities';
+
+export default function SalesPaymentPageIndex() {
+ const { t } = useEnterpriseModuleTranslationContext();
+ const actions = useSalesDocumentActions('payment');
+
+ const columnDefs: ColDef[] = useMemo(
+ () => [
+ { field: 'code', headerName: t('common:fields.code'), minWidth: 160 },
+ { field: 'date', headerName: t('common:fields.date'), minWidth: 140 },
+ ],
+ [t],
+ );
+
+ const filterConfig = useMemo(
+ () => ({
+ renderBody: (form: any) => (form ? : null),
+ }),
+ [t],
+ );
+
+ return (
+ }} />
+ ),
+ icon: CreditCard,
+ breadcrumbs: [
+ { label: t('nav:sales'), type: 'text' },
+ { label: t('nav:sales-payments'), 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/payments/presentation/store/index.ts b/apps/web/src/apps/main/modules/sales/payments/presentation/store/index.ts
new file mode 100644
index 0000000..6135cd7
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/payments/presentation/store/index.ts
@@ -0,0 +1,18 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { SalesPaymentEntity } from '../../domain/entities';
+
+export interface SalesPaymentsStoreState extends EnterpriseModuleState {}
+
+export const salesPaymentsStore = 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/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
index 80263e6..75a9191 100644
--- 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
@@ -19,6 +19,35 @@ describe('SalesRequestsRemoteDataTransformer', () => {
expect(entity.salesPerson).toEqual({ id: 'emp-1' });
});
+ it('keeps nested branch and division labels from the API response', () => {
+ const entity = transformer.transformToEntity({
+ id: 'fccaf567-17aa-4534-8ab3-2cee0b1905b9',
+ code: 'SR-20260801-0001',
+ date: 1785517200000,
+ salesPerson: { id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a', code: 'CYC_E_108858', name: 'Ada Lovelace' },
+ branch: { id: '28179106-7f23-424a-ae70-05cfda01261a', code: 'B_411587', name: 'Jakarta Pusat' },
+ division: { id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1', code: 'D_411587', name: 'Sales Division' },
+ customer: { id: '74e9f045-e176-429c-8e24-8dd77a4e89f2', code: 'C_411587', name: 'Acme Corp' },
+ address: 'depok',
+ products: [
+ {
+ id: 'd0211f93-66d9-4aab-b1c6-eafb187875e6',
+ product: { id: '023db166-be0a-46ed-9e70-cebeea672f08', code: 'FUEL_103044', name: 'Fuel 95' },
+ quantity: '1.0000',
+ price: '100000.0000',
+ },
+ ],
+ });
+
+ expect(entity.branch).toEqual({
+ id: '28179106-7f23-424a-ae70-05cfda01261a',
+ code: 'B_411587',
+ name: 'Jakarta Pusat',
+ });
+ expect(entity.division?.name).toBe('Sales Division');
+ expect(entity.products?.[0].product?.name).toBe('Fuel 95');
+ });
+
it('writes nested products and omits status', () => {
const payload = transformer.transformCreatePayload({
date: '2026-08-26',
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
index cf76777..d6f57ab 100644
--- a/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx
+++ b/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx
@@ -5,15 +5,33 @@ import { relationLabel } from '../../field/shared/relation-label';
export function DetailGeneral({
salesRequestHref,
+ salesOrderHref,
+ packingSlipHref,
+ showBalance,
}: {
salesRequestHref?: (id: string) => string;
+ salesOrderHref?: (id: string) => string;
+ packingSlipHref?: (id: string) => string;
+ showBalance?: boolean;
} = {}) {
const { detailData } = useDetailPageContext<
- SalesDocumentEntity & { salesRequestId?: string | null; salesRequest?: { id?: string; code?: string } | null }
+ SalesDocumentEntity & {
+ salesRequestId?: string | null;
+ salesRequest?: { id?: string; code?: string } | null;
+ salesOrderId?: string | null;
+ salesOrder?: { id?: string; code?: string } | null;
+ packingSlipId?: string | null;
+ packingSlip?: { id?: string; code?: string } | null;
+ salesOrderCode?: string | null;
+ packingSlipCode?: string | null;
+ balance?: string | null;
+ }
>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
const salesRequestId = data?.salesRequestId ?? data?.salesRequest?.id;
+ const salesOrderId = data?.salesOrderId ?? data?.salesOrder?.id;
+ const packingSlipId = data?.packingSlipId ?? data?.packingSlip?.id;
return (
@@ -35,6 +53,33 @@ export function DetailGeneral({
+ {salesOrderHref && (
+
+ salesOrderId ? (
+ {data?.salesOrder?.code || data?.salesOrderCode || salesOrderId}
+ ) : (
+ '-'
+ )
+ }
+ />
+ )}
+ {packingSlipHref && (
+
+ packingSlipId ? (
+ {data?.packingSlip?.code || data?.packingSlipCode || packingSlipId}
+ ) : (
+ '-'
+ )
+ }
+ />
+ )}
+ {showBalance ? : null}
{salesRequestHref && (
+ {documentType !== 'payment' ? (
+ <>
control={form.control}
name="customer"
@@ -75,6 +81,34 @@ export function SalesFilterFormContent({
loadOptions={loadDivisionOptions}
renderLabel={relationLabel}
/>
+ >
+ ) : null}
+ {documentType === 'invoice' ? (
+ <>
+
+ control={form.control}
+ name="salesOrder"
+ label={t('common:fields.salesOrder')}
+ valueKey="id"
+ labelKey="code"
+ searchable
+ clearable
+ loadOptions={loadSalesOrderOptions}
+ renderLabel={relationLabel}
+ />
+
+ control={form.control}
+ name="packingSlip"
+ label={t('common:fields.packingSlip')}
+ valueKey="id"
+ labelKey="code"
+ searchable
+ clearable
+ loadOptions={loadPackingSlipOptions}
+ renderLabel={relationLabel}
+ />
+ >
+ ) : null}
);
}
diff --git a/apps/web/src/apps/main/modules/sales/shared/load-sales-order-options.ts b/apps/web/src/apps/main/modules/sales/shared/load-sales-order-options.ts
new file mode 100644
index 0000000..6107ccc
--- /dev/null
+++ b/apps/web/src/apps/main/modules/sales/shared/load-sales-order-options.ts
@@ -0,0 +1,7 @@
+import { createOptionLoader } from '../../field/shared/create-option-loader';
+import { salesOrdersDataService } from '../orders/domain/factories';
+import type { SalesOrderEntity } from '../orders/domain/entities';
+
+export const loadSalesOrderOptions = createOptionLoader((config) =>
+ salesOrdersDataService.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
index 917f6c4..0f30214 100644
--- 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
@@ -40,8 +40,16 @@ export interface SalesDocumentEntity extends BaseEntity {
status?: string;
createdAt?: number;
updatedAt?: number;
- createdBy?: string;
- updatedBy?: string;
+ createdBy?: string | { id?: string; username?: string };
+ updatedBy?: string | { id?: string; username?: string };
+}
+
+export interface SalesLineDto {
+ id?: string;
+ productId?: string;
+ product?: LookupStub | null;
+ quantity: string;
+ price?: string | null;
}
export interface SalesDocumentDto {
@@ -49,19 +57,18 @@ export interface SalesDocumentDto {
code?: string | null;
date: string | number;
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?: Array<{
- id?: string;
- productId: string;
- quantity: string;
- price?: string | null;
- }>;
+ products?: SalesLineDto[];
images?: Array<{
id?: string;
url: string;
@@ -70,6 +77,6 @@ export interface SalesDocumentDto {
status?: string;
createdAt?: number;
updatedAt?: number;
- createdBy?: string;
- updatedBy?: string;
+ createdBy?: string | { id?: string; username?: string };
+ updatedBy?: string | { id?: string; username?: 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
index b56b154..42c13a6 100644
--- 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
@@ -1,5 +1,58 @@
import { describe, expect, it } from 'vitest';
-import { mapSalesDocumentFromDto, toSalesFilterPayload, toSalesWritePayload } from './sales-document.mapper';
+import {
+ mapSalesDocumentFromDto,
+ salesRequestToFormValues,
+ toSalesFilterPayload,
+ toSalesWritePayload,
+} from './sales-document.mapper';
+
+const nestedResponse = {
+ id: 'fccaf567-17aa-4534-8ab3-2cee0b1905b9',
+ code: 'SR-20260801-0001',
+ date: 1785517200000,
+ salesPerson: {
+ id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a',
+ code: 'CYC_E_108858',
+ name: 'Ada Lovelace',
+ },
+ branch: {
+ id: '28179106-7f23-424a-ae70-05cfda01261a',
+ code: 'B_411587',
+ name: 'Jakarta Pusat',
+ },
+ division: {
+ id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1',
+ code: 'D_411587',
+ name: 'Sales Division',
+ },
+ customer: {
+ id: '74e9f045-e176-429c-8e24-8dd77a4e89f2',
+ code: 'C_411587',
+ name: 'Acme Corp',
+ },
+ address: 'depok',
+ latitude: -6.474014,
+ longitude: 106.814575,
+ notes: 'notes',
+ status: 'draft',
+ createdAt: 1787814580147,
+ updatedAt: 1787814580147,
+ createdBy: { id: '1b946785-f6be-4f0a-8527-4bf97b737df8', username: 'alice' },
+ updatedBy: { id: '1b946785-f6be-4f0a-8527-4bf97b737df8', username: 'alice' },
+ products: [
+ {
+ id: 'd0211f93-66d9-4aab-b1c6-eafb187875e6',
+ product: {
+ id: '023db166-be0a-46ed-9e70-cebeea672f08',
+ code: 'FUEL_103044',
+ name: 'Fuel 95',
+ },
+ quantity: '1.0000',
+ price: '100000.0000',
+ },
+ ],
+ images: [],
+};
describe('sales document mapper', () => {
it('maps unix date and relation stubs onto the entity', () => {
@@ -26,6 +79,58 @@ describe('sales document mapper', () => {
expect(entity.products?.[0]).toMatchObject({ productId: 'prd-1', product: { id: 'prd-1' }, quantity: '2.0000' });
});
+ it('keeps nested lookup code and name from the API response', () => {
+ const entity = mapSalesDocumentFromDto(nestedResponse);
+
+ expect(entity.branch).toEqual({
+ id: '28179106-7f23-424a-ae70-05cfda01261a',
+ code: 'B_411587',
+ name: 'Jakarta Pusat',
+ });
+ expect(entity.branchId).toBe('28179106-7f23-424a-ae70-05cfda01261a');
+ expect(entity.division).toEqual({
+ id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1',
+ code: 'D_411587',
+ name: 'Sales Division',
+ });
+ expect(entity.salesPerson).toEqual({
+ id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a',
+ code: 'CYC_E_108858',
+ name: 'Ada Lovelace',
+ });
+ expect(entity.customer).toEqual({
+ id: '74e9f045-e176-429c-8e24-8dd77a4e89f2',
+ code: 'C_411587',
+ name: 'Acme Corp',
+ });
+ expect(entity.products?.[0]).toMatchObject({
+ productId: '023db166-be0a-46ed-9e70-cebeea672f08',
+ product: { id: '023db166-be0a-46ed-9e70-cebeea672f08', code: 'FUEL_103044', name: 'Fuel 95' },
+ quantity: '1.0000',
+ price: '100000.0000',
+ });
+ });
+
+ it('copies nested lookups onto form values for edit', () => {
+ const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse));
+
+ expect(formValues.branch).toEqual({
+ id: '28179106-7f23-424a-ae70-05cfda01261a',
+ code: 'B_411587',
+ name: 'Jakarta Pusat',
+ });
+ expect(formValues.division).toEqual({
+ id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1',
+ code: 'D_411587',
+ name: 'Sales Division',
+ });
+ expect(formValues.products[0].product).toEqual({
+ id: '023db166-be0a-46ed-9e70-cebeea672f08',
+ code: 'FUEL_103044',
+ name: 'Fuel 95',
+ });
+ });
+
it('writes relation ids and nested lines on create', () => {
const payload = toSalesWritePayload(
{
@@ -72,3 +177,35 @@ describe('sales document mapper', () => {
).toEqual({ customerId: 'cus-1', status: 'draft' });
});
});
+
+ it('includes parent ids on create and omits images when 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' }],
+ images: [{ url: 'https://cdn.example/a.png' }],
+ salesOrder: { id: 'so-1' } as any,
+ packingSlip: { id: 'ps-1' } as any,
+ } as any,
+ { includeSalesOrderId: true, includePackingSlipId: true, omitImages: true },
+ );
+
+ expect(payload.salesOrderId).toBe('so-1');
+ expect(payload.packingSlipId).toBe('ps-1');
+ expect(payload).not.toHaveProperty('images');
+ });
+
+ it('flattens sales order and packing slip in filters', () => {
+ expect(
+ toSalesFilterPayload({
+ salesOrder: { id: 'so-1' },
+ packingSlip: { id: 'ps-1' },
+ status: 'draft',
+ }),
+ ).toEqual({ salesOrderId: 'so-1', packingSlipId: 'ps-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
index 9658158..e7fdb56 100644
--- 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
@@ -1,12 +1,19 @@
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';
+import type {
+ LookupStub,
+ 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);
}
+ if (typeof value === 'string' && value) return value;
return undefined;
}
@@ -14,14 +21,36 @@ export function toRelationStub(id?: string | null) {
return id ? { id } : null;
}
+export function toLookup(value: unknown, fallbackId?: string | null): LookupStub | null {
+ if (value && typeof value === 'object' && 'id' in value) {
+ const item = value as { id?: unknown; code?: unknown; name?: unknown };
+ if (item.id == null || item.id === '') {
+ return toRelationStub(fallbackId);
+ }
+ return {
+ id: String(item.id),
+ ...(item.code != null && item.code !== '' ? { code: String(item.code) } : {}),
+ ...(item.name != null && item.name !== '' ? { name: String(item.name) } : {}),
+ };
+ }
+ return toRelationStub(fallbackId ?? (typeof value === 'string' ? value : null));
+}
+
+function lookupId(value: unknown, fallbackId?: string | null): string | undefined {
+ return relationId(value) ?? (fallbackId == null || fallbackId === '' ? undefined : String(fallbackId));
+}
+
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,
- }));
+ return (dto.products ?? []).map((line) => {
+ const product = toLookup(line.product, line.productId);
+ return {
+ id: line.id,
+ productId: lookupId(line.product, line.productId),
+ product,
+ quantity: line.quantity,
+ price: line.price ?? null,
+ };
+ });
}
export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[] {
@@ -33,18 +62,22 @@ export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[]
}
export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEntity {
+ const salesPerson = toLookup(dto.salesPerson, dto.salesPersonId);
+ const branch = toLookup(dto.branch, dto.branchId);
+ const division = toLookup(dto.division, dto.divisionId);
+ const customer = toLookup(dto.customer, dto.customerId);
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),
+ salesPersonId: lookupId(dto.salesPerson, dto.salesPersonId),
+ salesPerson,
+ branchId: lookupId(dto.branch, dto.branchId),
+ branch,
+ divisionId: lookupId(dto.division, dto.divisionId),
+ division,
+ customerId: lookupId(dto.customer, dto.customerId),
+ customer,
address: dto.address,
latitude: dto.latitude ?? null,
longitude: dto.longitude ?? null,
@@ -61,7 +94,12 @@ export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEnt
export function toSalesWritePayload(
entity: Partial,
- options?: { includeSalesRequestId?: boolean },
+ options?: {
+ includeSalesRequestId?: boolean;
+ includeSalesOrderId?: boolean;
+ includePackingSlipId?: boolean;
+ omitImages?: boolean;
+ },
): Record {
const products = (entity.products ?? [])
.map((line) => {
@@ -91,14 +129,27 @@ export function toSalesWritePayload(
longitude: emptyToNull(entity.longitude),
notes: emptyToNull(entity.notes),
products,
- images,
};
+ if (!options?.omitImages) {
+ payload.images = images;
+ }
+
if (options?.includeSalesRequestId) {
payload.salesRequestId =
relationId((entity as { salesRequest?: unknown }).salesRequest) ??
(entity as { salesRequestId?: string }).salesRequestId;
}
+ if (options?.includeSalesOrderId) {
+ payload.salesOrderId =
+ relationId((entity as { salesOrder?: unknown }).salesOrder) ??
+ (entity as { salesOrderId?: string }).salesOrderId;
+ }
+ if (options?.includePackingSlipId) {
+ payload.packingSlipId =
+ relationId((entity as { packingSlip?: unknown }).packingSlip) ??
+ (entity as { packingSlipId?: string }).packingSlipId;
+ }
return payload;
}
@@ -125,6 +176,14 @@ export function toSalesFilterPayload(filter: Record): Record key;
@@ -43,3 +43,16 @@ describe('createSalesOrderSchema', () => {
expect(schema.safeParse(validRequest).success).toBe(true);
});
});
+
+describe('createSalesInvoiceSchema', () => {
+ it('accepts optional sales order and packing slip', () => {
+ const schema = createSalesInvoiceSchema(t);
+ expect(schema.safeParse({ ...validRequest, salesOrder: relation, packingSlip: relation }).success).toBe(true);
+ expect(schema.safeParse(validRequest).success).toBe(true);
+ });
+
+ it('rejects a document with no product lines', () => {
+ const schema = createSalesInvoiceSchema(t);
+ expect(schema.safeParse({ ...validRequest, products: [] }).success).toBe(false);
+ });
+});
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
index 9473df3..075407d 100644
--- 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
@@ -82,3 +82,10 @@ export function createSalesOrderSchema(t: (key: string) => string) {
salesRequest: relationSchema.nullable().optional(),
});
}
+
+export function createSalesInvoiceSchema(t: (key: string) => string) {
+ return salesDocumentBaseSchema(t).extend({
+ salesOrder: relationSchema.nullable().optional(),
+ packingSlip: 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
index 48ff615..3c0524e 100644
--- 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
@@ -19,4 +19,19 @@ describe('sales status transitions', () => {
expect(commonTransitions('order', ['draft', 'processed'])).toEqual(['cancelled']);
expect(commonTransitions('order', ['draft'])).toEqual(['processed', 'cancelled']);
});
+
+ it('allows invoice processed to partial, completed, or cancelled', () => {
+ expect(allowedTransitions('invoice', 'processed')).toEqual(['partial', 'completed', 'cancelled']);
+ expect(allowedTransitions('invoice', 'partial')).toEqual(['completed', 'cancelled']);
+ });
+
+ it('returns named invoice complete only from processed', () => {
+ expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']);
+ expect(namedActionsFor('invoice', 'partial')).toEqual([]);
+ });
+
+ it('reuses request transitions and named actions for payments', () => {
+ expect(allowedTransitions('payment', 'draft')).toEqual(['pending', 'rejected']);
+ expect(namedActionsFor('payment', 'pending').map((action) => action.key)).toEqual(['approve', 'reject']);
+ });
});
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
index 85ff3a9..7ffd637 100644
--- a/apps/web/src/apps/main/modules/sales/shared/sales-status.ts
+++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts
@@ -4,7 +4,13 @@ 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';
+export const SALES_INVOICE_STATUSES = ['draft', 'processed', 'partial', 'completed', 'cancelled'] as const;
+export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number];
+
+export const SALES_PAYMENT_STATUSES = SALES_REQUEST_STATUSES;
+export type SalesPaymentStatus = SalesRequestStatus;
+
+export type SalesDocumentType = 'request' | 'order' | 'invoice' | 'payment';
const REQUEST_TRANSITIONS: Record = {
draft: ['pending', 'rejected'],
@@ -20,6 +26,14 @@ const ORDER_TRANSITIONS: Record = {
cancelled: [],
};
+const INVOICE_TRANSITIONS: Record = {
+ draft: ['processed', 'cancelled'],
+ processed: ['partial', 'completed', 'cancelled'],
+ partial: ['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 },
@@ -32,15 +46,28 @@ export const SALES_ORDER_NAMED_ACTIONS = [
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
] as const;
+export const SALES_INVOICE_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 const SALES_PAYMENT_NAMED_ACTIONS = SALES_REQUEST_NAMED_ACTIONS;
+
export function documentStatuses(type: SalesDocumentType): readonly string[] {
- return type === 'request' ? SALES_REQUEST_STATUSES : SALES_ORDER_STATUSES;
+ if (type === 'request' || type === 'payment') return SALES_REQUEST_STATUSES;
+ if (type === 'invoice') return SALES_INVOICE_STATUSES;
+ return SALES_ORDER_STATUSES;
}
export function allowedTransitions(type: SalesDocumentType, current?: string | null): string[] {
if (!current) return [];
- if (type === 'request') {
+ if (type === 'request' || type === 'payment') {
return REQUEST_TRANSITIONS[current as SalesRequestStatus] ?? [];
}
+ if (type === 'invoice') {
+ return INVOICE_TRANSITIONS[current as SalesInvoiceStatus] ?? [];
+ }
return ORDER_TRANSITIONS[current as SalesOrderStatus] ?? [];
}
@@ -52,7 +79,12 @@ export function commonTransitions(type: SalesDocumentType, statuses: Array current && (action.from as readonly string[]).includes(current));
}
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
index 55c8677..9913d8f 100644
--- 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
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
-import { Check, FileUp, ShoppingCart, XCircle, Play, Ban } from 'lucide-react';
+import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, Receipt, CreditCard } from 'lucide-react';
import { notifications } from '@repo/ui/components';
import {
useDetailPageContext,
@@ -10,6 +10,8 @@ import {
import { namedActionsFor, type SalesDocumentType } from './sales-status';
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
import type { SalesDocumentEntity } from './sales-document.entity';
+
+type SalesActionEntity = { id?: string | number; status?: string };
import { ChangeStatusModal } from './change-status-modal';
import { ImportCsvModal } from './import-csv-modal';
@@ -44,7 +46,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
notifications.show({ color: 'green', message: t('status_updated') });
};
- const namedRowActions = (data: SalesDocumentEntity, defaultActions: any[]) => {
+ const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => {
if (!canEdit) return defaultActions;
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
const Icon = ACTION_ICONS[action.key] ?? Check;
@@ -69,7 +71,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
return [...extras, ...defaultActions];
};
- const namedBulkActions = (selectedRows: SalesDocumentEntity[], defaultActions: any[]) => {
+ const namedBulkActions = (selectedRows: SalesActionEntity[], defaultActions: any[]) => {
if (!canEdit || selectedRows.length === 0) return defaultActions;
const statuses = selectedRows.map((row) => row.status);
const extras: any[] = namedActionsFor(documentType, statuses[0])
@@ -114,7 +116,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
}
: null;
- const createOrderAction = (_data: SalesDocumentEntity, onClick: () => void) =>
+ const createOrderAction = (_data: SalesActionEntity, onClick: () => void) =>
canEdit
? {
key: 'create-order',
@@ -126,7 +128,31 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
}
: null;
- const detailStatusActions = (data: SalesDocumentEntity, defaultActions: any[]) => {
+ const createInvoiceAction = (_data: SalesActionEntity, onClick: () => void) =>
+ canEdit
+ ? {
+ key: 'create-invoice',
+ label: t('create_sales_invoice'),
+ icon: ,
+ intent: 'primary' as const,
+ variant: 'light' as const,
+ onClick,
+ }
+ : null;
+
+ const createPaymentAction = (_data: SalesActionEntity, onClick: () => void) =>
+ canEdit
+ ? {
+ key: 'create-payment',
+ label: t('create_sales_payment'),
+ icon: ,
+ intent: 'primary' as const,
+ variant: 'light' as const,
+ onClick,
+ }
+ : null;
+
+ const detailStatusActions = (data: SalesActionEntity, defaultActions: any[]) => {
if (!canEdit) return defaultActions;
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
const Icon = ACTION_ICONS[action.key] ?? Check;
@@ -182,6 +208,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
namedBulkActions,
importPageAction,
createOrderAction,
+ createInvoiceAction,
+ createPaymentAction,
detailStatusActions,
applyStatus,
modals,
diff --git a/apps/web/src/core/constants/api-url.ts b/apps/web/src/core/constants/api-url.ts
index b1f5307..fcc2d25 100644
--- a/apps/web/src/core/constants/api-url.ts
+++ b/apps/web/src/core/constants/api-url.ts
@@ -16,4 +16,5 @@ export const API_URL = {
PLANS: '/plans',
SALES_INVOICES: '/sales-invoices',
PACKING_SLIPS: '/packing-slips',
+ SALES_PAYMENTS: '/sales-payments',
} as const;
diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json
index f8fa573..54f6264 100644
--- a/packages/core-i18n/src/languages/en/common.json
+++ b/packages/core-i18n/src/languages/en/common.json
@@ -232,6 +232,9 @@
"brand": "Brand",
"url": "URL",
"images": "Images",
+ "salesOrder": "Sales Order",
+ "packingSlip": "Packing Slip",
+ "invoice": "Invoice",
"salesRequest": "Sales Request",
"lineTotal": "Line Total"
},
diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json
index 172c555..1d83c4a 100644
--- a/packages/core-i18n/src/languages/id/common.json
+++ b/packages/core-i18n/src/languages/id/common.json
@@ -232,6 +232,9 @@
"brand": "Merek",
"url": "URL",
"images": "Gambar",
+ "salesOrder": "Pesanan Penjualan",
+ "packingSlip": "Surat Jalan",
+ "invoice": "Faktur",
"salesRequest": "Permintaan Penjualan",
"lineTotal": "Total Baris"
},
diff --git a/packages/ui/src/components/status-badge/status-badge.tsx b/packages/ui/src/components/status-badge/status-badge.tsx
index 8f158ed..aa4773a 100644
--- a/packages/ui/src/components/status-badge/status-badge.tsx
+++ b/packages/ui/src/components/status-badge/status-badge.tsx
@@ -57,6 +57,7 @@ export enum STATUS_DATA {
TODO = 'todo',
DONE = 'done',
+ PARTIAL = 'partial',
PARTIAL_DONE = 'partial-done',
ON_HOLD = 'on-hold',
@@ -150,6 +151,7 @@ export enum STATUS_COLOR {
WAIT = '#AB6103',
WAITING = '#B87104',
WAITING_LIST = '#C68106',
+ PARTIAL = '#D39308',
PARTIAL_DONE = '#D39308',
ON_HOLD = '#E0A50B',
REFUND = '#EDB70D',
@@ -208,6 +210,7 @@ export const DEFAULT_STATUS_MAP: Record = {
[STATUS_DATA.ACTIVATED]: { color: STATUS_COLOR.ACTIVATED, leftSection: getIcon(CheckCircle2) },
[STATUS_DATA.APPROVED]: { color: STATUS_COLOR.APPROVED, leftSection: getIcon(CheckCircle2) },
[STATUS_DATA.PROCESSED]: { color: STATUS_COLOR.PROCESSED, leftSection: getIcon(Check) },
+ [STATUS_DATA.PARTIAL]: { color: STATUS_COLOR.PARTIAL, leftSection: getIcon(Clock) },
[STATUS_DATA.COMPLETED]: { color: STATUS_COLOR.COMPLETED, leftSection: getIcon(CheckCircle2) },
[STATUS_DATA.COMPLETE]: { color: STATUS_COLOR.COMPLETE, leftSection: getIcon(CheckCircle2) },
[STATUS_DATA.DONE]: { color: STATUS_COLOR.DONE, leftSection: getIcon(CheckCircle2) },