feat: implement sales invoices module with comprehensive functionality

- Introduced a new Sales Invoices module, including routes for creating, editing, and viewing sales invoices.
- Added components for invoice forms, detail views, and index pages, enhancing user experience and data management.
- Integrated language support for English and Indonesian in the sales invoices module.
- Developed remote data services and transformers for handling sales invoice data, ensuring robust data operations.
- Implemented unit tests to validate functionality and reliability across the new module.

These changes enhance the application by providing a structured approach to sales invoice management, improving user experience and data handling.
This commit is contained in:
shancheas
2026-08-27 17:43:34 +07:00
parent 133e73a070
commit 15efa358f9
63 changed files with 2020 additions and 51 deletions
@@ -85,14 +85,14 @@ export const MENU_ITEMS: MenuItemType[] = [
key: 'sales-invoices', key: 'sales-invoices',
label: 'nav:sales-invoices', label: 'nav:sales-invoices',
icon: Receipt, icon: Receipt,
path: '/app/sales/invoices', path: '/app/sales/invoices/index',
moduleKey: 'SALES.INVOICE', moduleKey: 'SALES.INVOICE',
}, },
{ {
key: 'sales-payments', key: 'sales-payments',
label: 'nav:sales-payments', label: 'nav:sales-payments',
icon: CreditCard, icon: CreditCard,
path: '/app/sales/payments', path: '/app/sales/payments/index',
moduleKey: 'SALES.PAYMENT', moduleKey: 'SALES.PAYMENT',
}, },
{ {
@@ -7,6 +7,8 @@ const OrdersModule = lazy(() => import('./orders/presentation/factory'));
const EmployeesModule = lazy(() => import('../configuration/employees/presentation/factory')); const EmployeesModule = lazy(() => import('../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../field/cycles/presentation/factory')); const CyclesModule = lazy(() => import('../field/cycles/presentation/factory'));
const PlansModule = lazy(() => import('../field/plans/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() { export default function SalesModule() {
return ( return (
@@ -16,8 +18,8 @@ export default function SalesModule() {
<Route path="/orders/*" element={<OrdersModule />} /> <Route path="/orders/*" element={<OrdersModule />} />
<Route path="/cycles/*" element={<CyclesModule purpose="sales" />} /> <Route path="/cycles/*" element={<CyclesModule purpose="sales" />} />
<Route path="/plans/*" element={<PlansModule purpose="sales" />} /> <Route path="/plans/*" element={<PlansModule purpose="sales" />} />
<Route path="/invoices" element={<EmbeddedComingSoonPage />} /> <Route path="/invoices/*" element={<InvoicesModule />} />
<Route path="/payments" element={<EmbeddedComingSoonPage />} /> <Route path="/payments/*" element={<PaymentsModule />} />
<Route path="/reports" element={<EmbeddedComingSoonPage />} /> <Route path="/reports" element={<EmbeddedComingSoonPage />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} /> <Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes> </Routes>
@@ -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' },
}),
);
});
});
@@ -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<SalesInvoiceEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<SalesInvoiceEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/sales-invoices',
});
}
}
@@ -0,0 +1 @@
export * from './sales-invoice.constants';
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesInvoiceEntity } from '../entities';
export const salesInvoicesModuleConfig: ModuleConfigEntity<SalesInvoiceEntity> = {
moduleKey: 'SALES.INVOICE',
translationNamespace: 'SALES_INVOICES',
apiUrl: '/sales-invoices',
webUrl: '/app/sales/invoices',
moduleCategory: 'FULL_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1 @@
export * from './sales-invoice.entity';
@@ -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;
}
@@ -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,
});
@@ -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');
});
});
@@ -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<SalesInvoiceEntity> {
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<SalesInvoiceEntity>): Partial<SalesInvoiceEntity> {
return omitEmptyFields(
toSalesWritePayload(entity, {
includeSalesOrderId: true,
includePackingSlipId: true,
omitImages: true,
}),
) as Partial<SalesInvoiceEntity>;
}
transformEditPayload(entity: Partial<SalesInvoiceEntity>): Partial<SalesInvoiceEntity> {
return toSalesWritePayload(entity, { omitImages: true }) as Partial<SalesInvoiceEntity>;
}
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
return toSalesFilterPayload(filter);
}
}
@@ -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<string | null>(null);
const appliedPackingSlipId = useRef<string | null>(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 (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_source')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldAsyncSelect<SalesOrderEntity>
control={formControl.control}
name="salesOrder"
label={t('common:fields.salesOrder')}
valueKey="id"
labelKey="code"
searchable
clearable
loadOptions={loadSalesOrderOptions}
defaultOptions={salesOrder ? [salesOrder] : []}
renderLabel={relationLabel}
/>
<FieldAsyncSelect<LookupEntity>
control={formControl.control}
name="packingSlip"
label={t('common:fields.packingSlip')}
valueKey="id"
labelKey="code"
searchable
clearable
loadOptions={loadPackingSlipOptions}
defaultOptions={packingSlip ? [packingSlip] : []}
renderLabel={relationLabel}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,40 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import { 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 (
<EnterpriseModuleProvider<SalesInvoiceEntity>
config={salesInvoicesModuleConfig}
dataServices={salesInvoicesDataService}
store={salesInvoicesStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${salesInvoicesModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -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 invoices</1> 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"
}
@@ -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 penjualan</1> 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"
}
@@ -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 (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
breadcrumbs: [
{ label: t('nav:sales'), type: 'text' },
{ label: t('nav:sales-invoices'), type: 'link', href: `${salesInvoicesModuleConfig.webUrl}/index` },
],
}}
customPageActions={(data, pageActions) => {
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;
}}
>
<Stack gap="md">
<DetailGeneral
salesOrderHref={(id) => `${salesOrdersModuleConfig.webUrl}/detail/${id}`}
showBalance
/>
<DetailLocation />
<DetailProducts />
</Stack>
{actions.modals}
</EnterpriseDetailPageProvider>
);
}
@@ -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<string, unknown> = { ...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 (
<EnterpriseFormPageProvider
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
ignoreKeyUpdate={[
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
'id',
'salesOrderId',
'salesOrder',
'salesOrderCode',
'packingSlipId',
'packingSlip',
'packingSlipCode',
'balance',
]}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
description: title?.description,
breadcrumbs: [
{ label: t('nav:sales'), type: 'text' },
{ label: t('nav:sales-invoices'), type: 'link', href: `${salesInvoicesModuleConfig.webUrl}/index` },
],
}}
>
<Stack gap="md">
<FormInvoiceSource />
<FormGeneral />
<FormLocation />
<FormProducts />
<FormNotes />
</Stack>
</EnterpriseFormPageProvider>
);
}
@@ -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<SalesInvoiceEntity>[] = 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 ? <SalesFilterFormContent form={form} t={t} documentType="invoice" /> : null),
}),
[t],
);
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
),
icon: Receipt,
breadcrumbs: [
{ label: t('nav:sales'), type: 'text' },
{ label: t('nav:sales-invoices'), type: 'text' },
],
}}
customPageActions={(pageActions) =>
actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions
}
>
<EnterpriseDataTable
columnDefs={columnDefs}
filterConfig={filterConfig}
customRowActions={(data, defaultActions) => actions.namedRowActions(data, defaultActions)}
customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)}
/>
{actions.modals}
</EnterpriseIndexPageProvider>
);
}
@@ -0,0 +1,18 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { SalesInvoiceEntity } from '../../domain/entities';
export interface SalesInvoicesStoreState extends EnterpriseModuleState<SalesInvoiceEntity> {}
export const salesInvoicesStore = create<SalesInvoicesStoreState>((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 }),
}));
@@ -7,4 +7,5 @@ export interface SalesOrderEntity extends SalesDocumentEntity {
export interface SalesOrderDto extends SalesDocumentDto { export interface SalesOrderDto extends SalesDocumentDto {
salesRequestId?: string | null; salesRequestId?: string | null;
salesRequest?: { id: string; code?: string; name?: string } | null;
} }
@@ -4,6 +4,21 @@ import { SalesOrdersRemoteDataTransformer } from './sales-order.remote.transform
const transformer = new SalesOrdersRemoteDataTransformer(); const transformer = new SalesOrdersRemoteDataTransformer();
describe('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', () => { it('includes salesRequestId on create and omits it on edit', () => {
const entity = { const entity = {
date: '2026-08-26', date: '2026-08-26',
@@ -2,7 +2,8 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import { import {
mapSalesDocumentFromDto, mapSalesDocumentFromDto,
toRelationStub, relationId,
toLookup,
toSalesFilterPayload, toSalesFilterPayload,
toSalesWritePayload, toSalesWritePayload,
} from '../../../shared/sales-document.mapper'; } from '../../../shared/sales-document.mapper';
@@ -10,11 +11,13 @@ import type { SalesOrderDto, SalesOrderEntity } from '../entities';
export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer<SalesOrderEntity> { export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer<SalesOrderEntity> {
transformToEntity(dto: SalesOrderDto | SalesOrderEntity): SalesOrderEntity { 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 { return {
...base, ...base,
salesRequestId: (dto as SalesOrderDto).salesRequestId ?? null, salesRequestId: relationId(orderDto.salesRequest) ?? orderDto.salesRequestId ?? null,
salesRequest: toRelationStub((dto as SalesOrderDto).salesRequestId), salesRequest,
}; };
} }
@@ -26,6 +26,7 @@
"csv_file": "CSV file", "csv_file": "CSV file",
"import_success": "CSV imported.", "import_success": "CSV imported.",
"status_updated": "Status updated.", "status_updated": "Status updated.",
"create_sales_invoice": "Create Sales Invoice",
"action_process": "Process", "action_process": "Process",
"action_complete": "Complete", "action_complete": "Complete",
"action_cancel": "Cancel", "action_cancel": "Cancel",
@@ -26,6 +26,7 @@
"csv_file": "File CSV", "csv_file": "File CSV",
"import_success": "CSV berhasil diimpor.", "import_success": "CSV berhasil diimpor.",
"status_updated": "Status diperbarui.", "status_updated": "Status diperbarui.",
"create_sales_invoice": "Buat Faktur Penjualan",
"action_process": "Proses", "action_process": "Proses",
"action_complete": "Selesaikan", "action_complete": "Selesaikan",
"action_cancel": "Batalkan", "action_cancel": "Batalkan",
@@ -1,3 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { Stack } from '@repo/ui/components'; import { Stack } from '@repo/ui/components';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { salesOrdersModuleConfig } from '../../domain/constants'; import { salesOrdersModuleConfig } from '../../domain/constants';
@@ -7,10 +8,12 @@ import { DetailProducts } from '../../../shared/detail-products';
import { DetailImages } from '../../../shared/detail-images'; import { DetailImages } from '../../../shared/detail-images';
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
import { salesRequestsModuleConfig } from '../../../requests/domain/constants'; import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants';
import type { SalesOrderEntity } from '../../domain/entities'; import type { SalesOrderEntity } from '../../domain/entities';
export default function SalesOrderPageDetail() { export default function SalesOrderPageDetail() {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const navigate = useNavigate();
const actions = useSalesDocumentActions('order'); const actions = useSalesDocumentActions('order');
return ( return (
@@ -24,9 +27,13 @@ export default function SalesOrderPageDetail() {
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` }, { label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
], ],
}} }}
customPageActions={(data, pageActions) => customPageActions={(data, pageActions) => {
actions.detailStatusActions(data as SalesOrderEntity, 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;
}}
> >
<Stack gap="md"> <Stack gap="md">
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} /> <DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
@@ -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' },
}),
);
});
});
@@ -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<SalesPaymentEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<SalesPaymentEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/sales-payments',
});
}
}
@@ -0,0 +1 @@
export * from './sales-payment.constants';
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesPaymentEntity } from '../entities';
export const salesPaymentsModuleConfig: ModuleConfigEntity<SalesPaymentEntity> = {
moduleKey: 'SALES.PAYMENT',
translationNamespace: 'SALES_PAYMENTS',
apiUrl: '/sales-payments',
webUrl: '/app/sales/payments',
moduleCategory: 'FULL_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1 @@
export * from './sales-payment.entity';
@@ -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 };
}
@@ -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,
});
@@ -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' }]);
});
});
@@ -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<SalesPaymentEntity> {
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<SalesPaymentEntity>): Partial<SalesPaymentEntity> {
return omitEmptyFields(this.toWritePayload(entity)) as Partial<SalesPaymentEntity>;
}
transformEditPayload(entity: Partial<SalesPaymentEntity>): Partial<SalesPaymentEntity> {
return this.toWritePayload(entity) as Partial<SalesPaymentEntity>;
}
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
return omitEmptyFields({ ...filter });
}
private toWritePayload(entity: Partial<SalesPaymentEntity>): Record<string, unknown> {
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,
};
}
}
@@ -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);
});
});
@@ -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(),
});
}
@@ -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<SalesPaymentEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const rows = detailData?.invoices ?? [];
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_allocations')}
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('common:fields.invoice')}</Table.Th>
<Table.Th ta="right">{t('common:fields.amount')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={2}>
<Text size="sm" c="dimmed">
{t('empty_allocations')}
</Text>
</Table.Td>
</Table.Tr>
) : (
rows.map((row, index) => (
<Table.Tr key={row.id ?? `${row.invoiceId}-${index}`}>
<Table.Td>{relationLabel(row.invoice) || row.invoiceId}</Table.Td>
<Table.Td ta="right">{row.amount}</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Box>
</Paper>
);
}
@@ -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<SalesPaymentEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.code')} value={data?.code} />
<FieldValue
label={t('common:fields.date')}
value={data?.date}
render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue
label={t('common:fields.status')}
value={data?.status}
render={(val) => <StatusBadge status={String(val ?? '')} />}
/>
<FieldValue label={t('common:fields.notes')} value={data?.notes} />
<FieldValue
label={t('common:fields.createdAt')}
value={data?.createdAt}
render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue
label={t('common:fields.updatedAt')}
value={data?.updatedAt}
render={(val) => <RenderDate value={val as any} />}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,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 (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_allocations')}
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('common:fields.invoice')}</Table.Th>
<Table.Th ta="right">{t('common:fields.amount')}</Table.Th>
<Table.Th w={48} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{fields.map((field, index) => {
const line = invoices[index];
return (
<Table.Tr key={field.id}>
<Table.Td miw={240}>
<FieldAsyncSelect<LookupEntity>
control={formControl.control}
name={`invoices.${index}.invoice`}
valueKey="id"
labelKey="code"
searchable
loadOptions={loadSalesInvoiceOptions}
defaultOptions={line?.invoice ? [line.invoice] : []}
renderLabel={relationLabel}
/>
</Table.Td>
<Table.Td miw={140}>
<FieldTextInput control={formControl.control} name={`invoices.${index}.amount`} radius="md" />
</Table.Td>
<Table.Td>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove(index)}
aria-label={t('remove_allocation')}
>
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between" mt="md">
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => append({ amount: '' })}
>
{t('add_allocation')}
</Button>
{fields.length === 0 ? (
<Text size="sm" c="dimmed">
{t('empty_allocations')}
</Text>
) : null}
</Group>
</Paper>
);
}
@@ -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 (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={formControl.control}
name="code"
label={t('common:fields.code')}
radius="md"
/>
<FieldDatePicker
control={formControl.control}
name="date"
label={t('common:fields.date')}
required
radius="md"
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,40 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import { 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 (
<EnterpriseModuleProvider<SalesPaymentEntity>
config={salesPaymentsModuleConfig}
dataServices={salesPaymentsDataService}
store={salesPaymentsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${salesPaymentsModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -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 payments</1> 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"
}
@@ -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 penjualan</1> 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"
}
@@ -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 (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
breadcrumbs: [
{ label: t('nav:sales'), type: 'text' },
{ label: t('nav:sales-payments'), type: 'link', href: `${salesPaymentsModuleConfig.webUrl}/index` },
],
}}
customPageActions={(data, pageActions) =>
actions.detailStatusActions(data as SalesPaymentEntity, pageActions ?? [])
}
>
<Stack gap="md">
<DetailPaymentGeneral />
<DetailAllocations />
<DetailImages />
</Stack>
{actions.modals}
</EnterpriseDetailPageProvider>
);
}
@@ -0,0 +1,86 @@
import { useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { Stack } from '@repo/ui/components';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { 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 (
<EnterpriseFormPageProvider
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
description: title?.description,
breadcrumbs: [
{ label: t('nav:sales'), type: 'text' },
{ label: t('nav:sales-payments'), type: 'link', href: `${salesPaymentsModuleConfig.webUrl}/index` },
],
}}
>
<Stack gap="md">
<FormPaymentGeneral />
<FormAllocations />
<FormImages />
<FormNotes />
</Stack>
</EnterpriseFormPageProvider>
);
}
@@ -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<SalesPaymentEntity>[] = 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 ? <SalesFilterFormContent form={form} t={t} documentType="payment" /> : null),
}),
[t],
);
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
),
icon: CreditCard,
breadcrumbs: [
{ label: t('nav:sales'), type: 'text' },
{ label: t('nav:sales-payments'), type: 'text' },
],
}}
customPageActions={(pageActions) =>
actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions
}
>
<EnterpriseDataTable
columnDefs={columnDefs}
filterConfig={filterConfig}
customRowActions={(data, defaultActions) => actions.namedRowActions(data, defaultActions)}
customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)}
/>
{actions.modals}
</EnterpriseIndexPageProvider>
);
}
@@ -0,0 +1,18 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { SalesPaymentEntity } from '../../domain/entities';
export interface SalesPaymentsStoreState extends EnterpriseModuleState<SalesPaymentEntity> {}
export const salesPaymentsStore = create<SalesPaymentsStoreState>((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 }),
}));
@@ -19,6 +19,35 @@ describe('SalesRequestsRemoteDataTransformer', () => {
expect(entity.salesPerson).toEqual({ id: 'emp-1' }); 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', () => { it('writes nested products and omits status', () => {
const payload = transformer.transformCreatePayload({ const payload = transformer.transformCreatePayload({
date: '2026-08-26', date: '2026-08-26',
@@ -5,15 +5,33 @@ import { relationLabel } from '../../field/shared/relation-label';
export function DetailGeneral({ export function DetailGeneral({
salesRequestHref, salesRequestHref,
salesOrderHref,
packingSlipHref,
showBalance,
}: { }: {
salesRequestHref?: (id: string) => string; salesRequestHref?: (id: string) => string;
salesOrderHref?: (id: string) => string;
packingSlipHref?: (id: string) => string;
showBalance?: boolean;
} = {}) { } = {}) {
const { detailData } = useDetailPageContext< 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 { t } = useEnterpriseModuleTranslationContext();
const data = detailData; const data = detailData;
const salesRequestId = data?.salesRequestId ?? data?.salesRequest?.id; const salesRequestId = data?.salesRequestId ?? data?.salesRequest?.id;
const salesOrderId = data?.salesOrderId ?? data?.salesOrder?.id;
const packingSlipId = data?.packingSlipId ?? data?.packingSlip?.id;
return ( return (
<Paper withBorder shadow="sm" radius="md" p="xl"> <Paper withBorder shadow="sm" radius="md" p="xl">
@@ -35,6 +53,33 @@ export function DetailGeneral({
<FieldValue label={t('common:fields.branch')} value={relationLabel(data?.branch) || data?.branchId} /> <FieldValue label={t('common:fields.branch')} value={relationLabel(data?.branch) || data?.branchId} />
<FieldValue label={t('common:fields.division')} value={relationLabel(data?.division) || data?.divisionId} /> <FieldValue label={t('common:fields.division')} value={relationLabel(data?.division) || data?.divisionId} />
<FieldValue label={t('common:fields.customer')} value={relationLabel(data?.customer) || data?.customerId} /> <FieldValue label={t('common:fields.customer')} value={relationLabel(data?.customer) || data?.customerId} />
{salesOrderHref && (
<FieldValue
label={t('common:fields.salesOrder')}
value={salesOrderId}
render={() =>
salesOrderId ? (
<Anchor href={salesOrderHref(salesOrderId)}>{data?.salesOrder?.code || data?.salesOrderCode || salesOrderId}</Anchor>
) : (
'-'
)
}
/>
)}
{packingSlipHref && (
<FieldValue
label={t('common:fields.packingSlip')}
value={packingSlipId}
render={() =>
packingSlipId ? (
<Anchor href={packingSlipHref(packingSlipId)}>{data?.packingSlip?.code || data?.packingSlipCode || packingSlipId}</Anchor>
) : (
'-'
)
}
/>
)}
{showBalance ? <FieldValue label={t('common:fields.balance')} value={data?.balance} /> : null}
{salesRequestHref && ( {salesRequestHref && (
<FieldValue <FieldValue
label={t('common:fields.salesRequest')} label={t('common:fields.salesRequest')}
@@ -7,6 +7,10 @@ import { loadCustomerOptions } from '../../field/shared/load-customer-options';
import { loadDivisionOptions } from '../../configuration/shared/load-division-options'; import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
import { relationLabel } from '../../field/shared/relation-label'; import { relationLabel } from '../../field/shared/relation-label';
import { documentStatusFilterOptions, type SalesDocumentType } from './sales-status'; import { documentStatusFilterOptions, type SalesDocumentType } from './sales-status';
import { loadSalesOrderOptions } from './load-sales-order-options';
import { loadPackingSlipOptions } from '../../field/shared/lookup.factories';
import type { SalesOrderEntity } from '../orders/domain/entities';
import type { LookupEntity } from '../../field/shared/lookup.entity';
import type { EmployeeEntity } from '../../configuration/employees/domain/entities'; import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
import type { BranchEntity } from '../../configuration/branches/domain/entities'; import type { BranchEntity } from '../../configuration/branches/domain/entities';
import type { DivisionEntity } from '../../configuration/divisions/domain/entities'; import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
@@ -31,6 +35,8 @@ export function SalesFilterFormContent({
clearable clearable
data={documentStatusFilterOptions(t, documentType)} data={documentStatusFilterOptions(t, documentType)}
/> />
{documentType !== 'payment' ? (
<>
<FieldAsyncSelect<CustomerEntity> <FieldAsyncSelect<CustomerEntity>
control={form.control} control={form.control}
name="customer" name="customer"
@@ -75,6 +81,34 @@ export function SalesFilterFormContent({
loadOptions={loadDivisionOptions} loadOptions={loadDivisionOptions}
renderLabel={relationLabel} renderLabel={relationLabel}
/> />
</>
) : null}
{documentType === 'invoice' ? (
<>
<FieldAsyncSelect<SalesOrderEntity>
control={form.control}
name="salesOrder"
label={t('common:fields.salesOrder')}
valueKey="id"
labelKey="code"
searchable
clearable
loadOptions={loadSalesOrderOptions}
renderLabel={relationLabel}
/>
<FieldAsyncSelect<LookupEntity>
control={form.control}
name="packingSlip"
label={t('common:fields.packingSlip')}
valueKey="id"
labelKey="code"
searchable
clearable
loadOptions={loadPackingSlipOptions}
renderLabel={relationLabel}
/>
</>
) : null}
</SimpleGrid> </SimpleGrid>
); );
} }
@@ -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<SalesOrderEntity>((config) =>
salesOrdersDataService.getMany(config),
);
@@ -40,8 +40,16 @@ export interface SalesDocumentEntity extends BaseEntity {
status?: string; status?: string;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
createdBy?: string; createdBy?: string | { id?: string; username?: string };
updatedBy?: 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 { export interface SalesDocumentDto {
@@ -49,19 +57,18 @@ export interface SalesDocumentDto {
code?: string | null; code?: string | null;
date: string | number; date: string | number;
salesPersonId?: string; salesPersonId?: string;
salesPerson?: LookupStub | null;
branchId?: string; branchId?: string;
branch?: LookupStub | null;
divisionId?: string; divisionId?: string;
division?: LookupStub | null;
customerId?: string; customerId?: string;
customer?: LookupStub | null;
address: string; address: string;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
notes?: string | null; notes?: string | null;
products?: Array<{ products?: SalesLineDto[];
id?: string;
productId: string;
quantity: string;
price?: string | null;
}>;
images?: Array<{ images?: Array<{
id?: string; id?: string;
url: string; url: string;
@@ -70,6 +77,6 @@ export interface SalesDocumentDto {
status?: string; status?: string;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
createdBy?: string; createdBy?: string | { id?: string; username?: string };
updatedBy?: string; updatedBy?: string | { id?: string; username?: string };
} }
@@ -1,5 +1,58 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('sales document mapper', () => {
it('maps unix date and relation stubs onto the entity', () => { 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' }); 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', () => { it('writes relation ids and nested lines on create', () => {
const payload = toSalesWritePayload( const payload = toSalesWritePayload(
{ {
@@ -72,3 +177,35 @@ describe('sales document mapper', () => {
).toEqual({ customerId: 'cus-1', status: 'draft' }); ).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' });
});
@@ -1,12 +1,19 @@
import { formatDateValue, parseDateValue } from '@repo/ui/form'; import { formatDateValue, parseDateValue } from '@repo/ui/form';
import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators'; 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 { export function relationId(value: unknown): string | undefined {
if (value && typeof value === 'object' && 'id' in value) { if (value && typeof value === 'object' && 'id' in value) {
const id = (value as { id?: unknown }).id; const id = (value as { id?: unknown }).id;
return id == null ? undefined : String(id); return id == null ? undefined : String(id);
} }
if (typeof value === 'string' && value) return value;
return undefined; return undefined;
} }
@@ -14,14 +21,36 @@ export function toRelationStub(id?: string | null) {
return id ? { id } : 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[] { export function mapSalesLinesFromDto(dto: SalesDocumentDto): SalesLineEntity[] {
return (dto.products ?? []).map((line) => ({ return (dto.products ?? []).map((line) => {
id: line.id, const product = toLookup(line.product, line.productId);
productId: line.productId, return {
product: toRelationStub(line.productId), id: line.id,
quantity: line.quantity, productId: lookupId(line.product, line.productId),
price: line.price ?? null, product,
})); quantity: line.quantity,
price: line.price ?? null,
};
});
} }
export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[] { export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[] {
@@ -33,18 +62,22 @@ export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[]
} }
export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEntity { 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 { return {
id: dto.id, id: dto.id,
code: dto.code ?? null, code: dto.code ?? null,
date: formatDateValue(parseDateValue(dto.date) ?? undefined), date: formatDateValue(parseDateValue(dto.date) ?? undefined),
salesPersonId: dto.salesPersonId, salesPersonId: lookupId(dto.salesPerson, dto.salesPersonId),
salesPerson: toRelationStub(dto.salesPersonId), salesPerson,
branchId: dto.branchId, branchId: lookupId(dto.branch, dto.branchId),
branch: toRelationStub(dto.branchId), branch,
divisionId: dto.divisionId, divisionId: lookupId(dto.division, dto.divisionId),
division: toRelationStub(dto.divisionId), division,
customerId: dto.customerId, customerId: lookupId(dto.customer, dto.customerId),
customer: toRelationStub(dto.customerId), customer,
address: dto.address, address: dto.address,
latitude: dto.latitude ?? null, latitude: dto.latitude ?? null,
longitude: dto.longitude ?? null, longitude: dto.longitude ?? null,
@@ -61,7 +94,12 @@ export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEnt
export function toSalesWritePayload( export function toSalesWritePayload(
entity: Partial<SalesDocumentEntity>, entity: Partial<SalesDocumentEntity>,
options?: { includeSalesRequestId?: boolean }, options?: {
includeSalesRequestId?: boolean;
includeSalesOrderId?: boolean;
includePackingSlipId?: boolean;
omitImages?: boolean;
},
): Record<string, unknown> { ): Record<string, unknown> {
const products = (entity.products ?? []) const products = (entity.products ?? [])
.map((line) => { .map((line) => {
@@ -91,14 +129,27 @@ export function toSalesWritePayload(
longitude: emptyToNull(entity.longitude), longitude: emptyToNull(entity.longitude),
notes: emptyToNull(entity.notes), notes: emptyToNull(entity.notes),
products, products,
images,
}; };
if (!options?.omitImages) {
payload.images = images;
}
if (options?.includeSalesRequestId) { if (options?.includeSalesRequestId) {
payload.salesRequestId = payload.salesRequestId =
relationId((entity as { salesRequest?: unknown }).salesRequest) ?? relationId((entity as { salesRequest?: unknown }).salesRequest) ??
(entity as { salesRequestId?: string }).salesRequestId; (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; return payload;
} }
@@ -125,6 +176,14 @@ export function toSalesFilterPayload(filter: Record<string, any>): Record<string
next.salesRequestId = next.salesRequest.id; next.salesRequestId = next.salesRequest.id;
delete next.salesRequest; delete next.salesRequest;
} }
if (next.salesOrder && typeof next.salesOrder === 'object') {
next.salesOrderId = next.salesOrder.id;
delete next.salesOrder;
}
if (next.packingSlip && typeof next.packingSlip === 'object') {
next.packingSlipId = next.packingSlip.id;
delete next.packingSlip;
}
return omitEmptyFields(next); return omitEmptyFields(next);
} }
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { createSalesOrderSchema, createSalesRequestSchema } from './sales-document.validator'; import { createSalesInvoiceSchema, createSalesOrderSchema, createSalesRequestSchema } from './sales-document.validator';
const t = (key: string) => key; const t = (key: string) => key;
@@ -43,3 +43,16 @@ describe('createSalesOrderSchema', () => {
expect(schema.safeParse(validRequest).success).toBe(true); 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);
});
});
@@ -82,3 +82,10 @@ export function createSalesOrderSchema(t: (key: string) => string) {
salesRequest: relationSchema.nullable().optional(), salesRequest: relationSchema.nullable().optional(),
}); });
} }
export function createSalesInvoiceSchema(t: (key: string) => string) {
return salesDocumentBaseSchema(t).extend({
salesOrder: relationSchema.nullable().optional(),
packingSlip: relationSchema.nullable().optional(),
});
}
@@ -19,4 +19,19 @@ describe('sales status transitions', () => {
expect(commonTransitions('order', ['draft', 'processed'])).toEqual(['cancelled']); expect(commonTransitions('order', ['draft', 'processed'])).toEqual(['cancelled']);
expect(commonTransitions('order', ['draft'])).toEqual(['processed', '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']);
});
}); });
@@ -4,7 +4,13 @@ export type SalesRequestStatus = (typeof SALES_REQUEST_STATUSES)[number];
export const SALES_ORDER_STATUSES = ['draft', 'processed', 'completed', 'cancelled'] as const; export const SALES_ORDER_STATUSES = ['draft', 'processed', 'completed', 'cancelled'] as const;
export type SalesOrderStatus = (typeof SALES_ORDER_STATUSES)[number]; 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<SalesRequestStatus, SalesRequestStatus[]> = { const REQUEST_TRANSITIONS: Record<SalesRequestStatus, SalesRequestStatus[]> = {
draft: ['pending', 'rejected'], draft: ['pending', 'rejected'],
@@ -20,6 +26,14 @@ const ORDER_TRANSITIONS: Record<SalesOrderStatus, SalesOrderStatus[]> = {
cancelled: [], cancelled: [],
}; };
const INVOICE_TRANSITIONS: Record<SalesInvoiceStatus, SalesInvoiceStatus[]> = {
draft: ['processed', 'cancelled'],
processed: ['partial', 'completed', 'cancelled'],
partial: ['completed', 'cancelled'],
completed: [],
cancelled: [],
};
export const SALES_REQUEST_NAMED_ACTIONS = [ export const SALES_REQUEST_NAMED_ACTIONS = [
{ key: 'submit', target: 'pending' as const, from: ['draft'] as const }, { key: 'submit', target: 'pending' as const, from: ['draft'] as const },
{ key: 'approve', target: 'approved' as const, from: ['pending'] 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 }, { key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
] 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[] { 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[] { export function allowedTransitions(type: SalesDocumentType, current?: string | null): string[] {
if (!current) return []; if (!current) return [];
if (type === 'request') { if (type === 'request' || type === 'payment') {
return REQUEST_TRANSITIONS[current as SalesRequestStatus] ?? []; return REQUEST_TRANSITIONS[current as SalesRequestStatus] ?? [];
} }
if (type === 'invoice') {
return INVOICE_TRANSITIONS[current as SalesInvoiceStatus] ?? [];
}
return ORDER_TRANSITIONS[current as SalesOrderStatus] ?? []; return ORDER_TRANSITIONS[current as SalesOrderStatus] ?? [];
} }
@@ -52,7 +79,12 @@ export function commonTransitions(type: SalesDocumentType, statuses: Array<strin
} }
export function namedActionsFor(type: SalesDocumentType, current?: string | null) { export function namedActionsFor(type: SalesDocumentType, current?: string | null) {
const actions = type === 'request' ? SALES_REQUEST_NAMED_ACTIONS : SALES_ORDER_NAMED_ACTIONS; const actions =
type === 'request' || type === 'payment'
? SALES_REQUEST_NAMED_ACTIONS
: type === 'invoice'
? SALES_INVOICE_NAMED_ACTIONS
: SALES_ORDER_NAMED_ACTIONS;
return actions.filter((action) => current && (action.from as readonly string[]).includes(current)); return actions.filter((action) => current && (action.from as readonly string[]).includes(current));
} }
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; 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 { notifications } from '@repo/ui/components';
import { import {
useDetailPageContext, useDetailPageContext,
@@ -10,6 +10,8 @@ import {
import { namedActionsFor, type SalesDocumentType } from './sales-status'; import { namedActionsFor, type SalesDocumentType } from './sales-status';
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service'; import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
import type { SalesDocumentEntity } from './sales-document.entity'; import type { SalesDocumentEntity } from './sales-document.entity';
type SalesActionEntity = { id?: string | number; status?: string };
import { ChangeStatusModal } from './change-status-modal'; import { ChangeStatusModal } from './change-status-modal';
import { ImportCsvModal } from './import-csv-modal'; import { ImportCsvModal } from './import-csv-modal';
@@ -44,7 +46,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
notifications.show({ color: 'green', message: t('status_updated') }); notifications.show({ color: 'green', message: t('status_updated') });
}; };
const namedRowActions = (data: SalesDocumentEntity, defaultActions: any[]) => { const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => {
if (!canEdit) return defaultActions; if (!canEdit) return defaultActions;
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => { const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
const Icon = ACTION_ICONS[action.key] ?? Check; const Icon = ACTION_ICONS[action.key] ?? Check;
@@ -69,7 +71,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
return [...extras, ...defaultActions]; return [...extras, ...defaultActions];
}; };
const namedBulkActions = (selectedRows: SalesDocumentEntity[], defaultActions: any[]) => { const namedBulkActions = (selectedRows: SalesActionEntity[], defaultActions: any[]) => {
if (!canEdit || selectedRows.length === 0) return defaultActions; if (!canEdit || selectedRows.length === 0) return defaultActions;
const statuses = selectedRows.map((row) => row.status); const statuses = selectedRows.map((row) => row.status);
const extras: any[] = namedActionsFor(documentType, statuses[0]) const extras: any[] = namedActionsFor(documentType, statuses[0])
@@ -114,7 +116,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
} }
: null; : null;
const createOrderAction = (_data: SalesDocumentEntity, onClick: () => void) => const createOrderAction = (_data: SalesActionEntity, onClick: () => void) =>
canEdit canEdit
? { ? {
key: 'create-order', key: 'create-order',
@@ -126,7 +128,31 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
} }
: null; : null;
const detailStatusActions = (data: SalesDocumentEntity, defaultActions: any[]) => { const createInvoiceAction = (_data: SalesActionEntity, onClick: () => void) =>
canEdit
? {
key: 'create-invoice',
label: t('create_sales_invoice'),
icon: <Receipt size={16} />,
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: <CreditCard size={16} />,
intent: 'primary' as const,
variant: 'light' as const,
onClick,
}
: null;
const detailStatusActions = (data: SalesActionEntity, defaultActions: any[]) => {
if (!canEdit) return defaultActions; if (!canEdit) return defaultActions;
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => { const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
const Icon = ACTION_ICONS[action.key] ?? Check; const Icon = ACTION_ICONS[action.key] ?? Check;
@@ -182,6 +208,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
namedBulkActions, namedBulkActions,
importPageAction, importPageAction,
createOrderAction, createOrderAction,
createInvoiceAction,
createPaymentAction,
detailStatusActions, detailStatusActions,
applyStatus, applyStatus,
modals, modals,
+1
View File
@@ -16,4 +16,5 @@ export const API_URL = {
PLANS: '/plans', PLANS: '/plans',
SALES_INVOICES: '/sales-invoices', SALES_INVOICES: '/sales-invoices',
PACKING_SLIPS: '/packing-slips', PACKING_SLIPS: '/packing-slips',
SALES_PAYMENTS: '/sales-payments',
} as const; } as const;
@@ -232,6 +232,9 @@
"brand": "Brand", "brand": "Brand",
"url": "URL", "url": "URL",
"images": "Images", "images": "Images",
"salesOrder": "Sales Order",
"packingSlip": "Packing Slip",
"invoice": "Invoice",
"salesRequest": "Sales Request", "salesRequest": "Sales Request",
"lineTotal": "Line Total" "lineTotal": "Line Total"
}, },
@@ -232,6 +232,9 @@
"brand": "Merek", "brand": "Merek",
"url": "URL", "url": "URL",
"images": "Gambar", "images": "Gambar",
"salesOrder": "Pesanan Penjualan",
"packingSlip": "Surat Jalan",
"invoice": "Faktur",
"salesRequest": "Permintaan Penjualan", "salesRequest": "Permintaan Penjualan",
"lineTotal": "Total Baris" "lineTotal": "Total Baris"
}, },
@@ -57,6 +57,7 @@ export enum STATUS_DATA {
TODO = 'todo', TODO = 'todo',
DONE = 'done', DONE = 'done',
PARTIAL = 'partial',
PARTIAL_DONE = 'partial-done', PARTIAL_DONE = 'partial-done',
ON_HOLD = 'on-hold', ON_HOLD = 'on-hold',
@@ -150,6 +151,7 @@ export enum STATUS_COLOR {
WAIT = '#AB6103', WAIT = '#AB6103',
WAITING = '#B87104', WAITING = '#B87104',
WAITING_LIST = '#C68106', WAITING_LIST = '#C68106',
PARTIAL = '#D39308',
PARTIAL_DONE = '#D39308', PARTIAL_DONE = '#D39308',
ON_HOLD = '#E0A50B', ON_HOLD = '#E0A50B',
REFUND = '#EDB70D', REFUND = '#EDB70D',
@@ -208,6 +210,7 @@ export const DEFAULT_STATUS_MAP: Record<string, BadgeProps> = {
[STATUS_DATA.ACTIVATED]: { color: STATUS_COLOR.ACTIVATED, leftSection: getIcon(CheckCircle2) }, [STATUS_DATA.ACTIVATED]: { color: STATUS_COLOR.ACTIVATED, leftSection: getIcon(CheckCircle2) },
[STATUS_DATA.APPROVED]: { color: STATUS_COLOR.APPROVED, 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.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.COMPLETED]: { color: STATUS_COLOR.COMPLETED, leftSection: getIcon(CheckCircle2) },
[STATUS_DATA.COMPLETE]: { color: STATUS_COLOR.COMPLETE, leftSection: getIcon(CheckCircle2) }, [STATUS_DATA.COMPLETE]: { color: STATUS_COLOR.COMPLETE, leftSection: getIcon(CheckCircle2) },
[STATUS_DATA.DONE]: { color: STATUS_COLOR.DONE, leftSection: getIcon(CheckCircle2) }, [STATUS_DATA.DONE]: { color: STATUS_COLOR.DONE, leftSection: getIcon(CheckCircle2) },