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:
@@ -85,14 +85,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
key: 'sales-invoices',
|
||||
label: 'nav:sales-invoices',
|
||||
icon: Receipt,
|
||||
path: '/app/sales/invoices',
|
||||
path: '/app/sales/invoices/index',
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
},
|
||||
{
|
||||
key: 'sales-payments',
|
||||
label: 'nav:sales-payments',
|
||||
icon: CreditCard,
|
||||
path: '/app/sales/payments',
|
||||
path: '/app/sales/payments/index',
|
||||
moduleKey: 'SALES.PAYMENT',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,8 @@ const OrdersModule = lazy(() => import('./orders/presentation/factory'));
|
||||
const EmployeesModule = lazy(() => import('../configuration/employees/presentation/factory'));
|
||||
const CyclesModule = lazy(() => import('../field/cycles/presentation/factory'));
|
||||
const PlansModule = lazy(() => import('../field/plans/presentation/factory'));
|
||||
const InvoicesModule = lazy(() => import('./invoices/presentation/factory'));
|
||||
const PaymentsModule = lazy(() => import('./payments/presentation/factory'));
|
||||
|
||||
export default function SalesModule() {
|
||||
return (
|
||||
@@ -16,8 +18,8 @@ export default function SalesModule() {
|
||||
<Route path="/orders/*" element={<OrdersModule />} />
|
||||
<Route path="/cycles/*" element={<CyclesModule purpose="sales" />} />
|
||||
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
|
||||
<Route path="/invoices" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="/payments" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="/invoices/*" element={<InvoicesModule />} />
|
||||
<Route path="/payments/*" element={<PaymentsModule />} />
|
||||
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
|
||||
+58
@@ -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';
|
||||
+11
@@ -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,
|
||||
});
|
||||
+49
@@ -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');
|
||||
});
|
||||
});
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+88
@@ -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>
|
||||
);
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+48
@@ -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>
|
||||
);
|
||||
}
|
||||
+118
@@ -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>
|
||||
);
|
||||
}
|
||||
+79
@@ -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 {
|
||||
salesRequestId?: string | null;
|
||||
salesRequest?: { id: string; code?: string; name?: string } | null;
|
||||
}
|
||||
|
||||
+15
@@ -4,6 +4,21 @@ import { SalesOrdersRemoteDataTransformer } from './sales-order.remote.transform
|
||||
const transformer = new SalesOrdersRemoteDataTransformer();
|
||||
|
||||
describe('SalesOrdersRemoteDataTransformer', () => {
|
||||
it('keeps nested salesRequest code from the API response', () => {
|
||||
const entity = transformer.transformToEntity({
|
||||
id: 'so-1',
|
||||
date: Date.UTC(2026, 7, 26),
|
||||
address: 'depok',
|
||||
salesRequest: { id: 'sr-1', code: 'SR-20260801-0001' },
|
||||
branch: { id: 'br-1', code: 'B_411587', name: 'Jakarta Pusat' },
|
||||
products: [],
|
||||
} as any);
|
||||
|
||||
expect(entity.salesRequest).toEqual({ id: 'sr-1', code: 'SR-20260801-0001' });
|
||||
expect(entity.salesRequestId).toBe('sr-1');
|
||||
expect(entity.branch).toEqual({ id: 'br-1', code: 'B_411587', name: 'Jakarta Pusat' });
|
||||
});
|
||||
|
||||
it('includes salesRequestId on create and omits it on edit', () => {
|
||||
const entity = {
|
||||
date: '2026-08-26',
|
||||
|
||||
+7
-4
@@ -2,7 +2,8 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import {
|
||||
mapSalesDocumentFromDto,
|
||||
toRelationStub,
|
||||
relationId,
|
||||
toLookup,
|
||||
toSalesFilterPayload,
|
||||
toSalesWritePayload,
|
||||
} from '../../../shared/sales-document.mapper';
|
||||
@@ -10,11 +11,13 @@ import type { SalesOrderDto, SalesOrderEntity } from '../entities';
|
||||
|
||||
export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer<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 {
|
||||
...base,
|
||||
salesRequestId: (dto as SalesOrderDto).salesRequestId ?? null,
|
||||
salesRequest: toRelationStub((dto as SalesOrderDto).salesRequestId),
|
||||
salesRequestId: relationId(orderDto.salesRequest) ?? orderDto.salesRequestId ?? null,
|
||||
salesRequest,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -26,6 +26,7 @@
|
||||
"csv_file": "CSV file",
|
||||
"import_success": "CSV imported.",
|
||||
"status_updated": "Status updated.",
|
||||
"create_sales_invoice": "Create Sales Invoice",
|
||||
"action_process": "Process",
|
||||
"action_complete": "Complete",
|
||||
"action_cancel": "Cancel",
|
||||
|
||||
+1
@@ -26,6 +26,7 @@
|
||||
"csv_file": "File CSV",
|
||||
"import_success": "CSV berhasil diimpor.",
|
||||
"status_updated": "Status diperbarui.",
|
||||
"create_sales_invoice": "Buat Faktur Penjualan",
|
||||
"action_process": "Proses",
|
||||
"action_complete": "Selesaikan",
|
||||
"action_cancel": "Batalkan",
|
||||
|
||||
+10
-3
@@ -1,3 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { salesOrdersModuleConfig } from '../../domain/constants';
|
||||
@@ -7,10 +8,12 @@ import { DetailProducts } from '../../../shared/detail-products';
|
||||
import { DetailImages } from '../../../shared/detail-images';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
|
||||
import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants';
|
||||
import type { SalesOrderEntity } from '../../domain/entities';
|
||||
|
||||
export default function SalesOrderPageDetail() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const navigate = useNavigate();
|
||||
const actions = useSalesDocumentActions('order');
|
||||
|
||||
return (
|
||||
@@ -24,9 +27,13 @@ export default function SalesOrderPageDetail() {
|
||||
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
customPageActions={(data, pageActions) =>
|
||||
actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])
|
||||
}
|
||||
customPageActions={(data, pageActions) => {
|
||||
const createInvoice = actions.createInvoiceAction(data as SalesOrderEntity, () => {
|
||||
navigate(`${salesInvoicesModuleConfig.webUrl}/create?salesOrderId=${data.id}`);
|
||||
});
|
||||
const withStatus = actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? []);
|
||||
return createInvoice ? [createInvoice, ...withStatus] : withStatus;
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
|
||||
|
||||
+58
@@ -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';
|
||||
+11
@@ -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,
|
||||
});
|
||||
+39
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
+70
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
});
|
||||
});
|
||||
+49
@@ -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(),
|
||||
});
|
||||
}
|
||||
+46
@@ -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>
|
||||
);
|
||||
}
|
||||
+43
@@ -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>
|
||||
);
|
||||
}
|
||||
+85
@@ -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>
|
||||
);
|
||||
}
|
||||
+32
@@ -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>
|
||||
);
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+37
@@ -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>
|
||||
);
|
||||
}
|
||||
+86
@@ -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>
|
||||
);
|
||||
}
|
||||
+59
@@ -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 }),
|
||||
}));
|
||||
+29
@@ -19,6 +19,35 @@ describe('SalesRequestsRemoteDataTransformer', () => {
|
||||
expect(entity.salesPerson).toEqual({ id: 'emp-1' });
|
||||
});
|
||||
|
||||
it('keeps nested branch and division labels from the API response', () => {
|
||||
const entity = transformer.transformToEntity({
|
||||
id: 'fccaf567-17aa-4534-8ab3-2cee0b1905b9',
|
||||
code: 'SR-20260801-0001',
|
||||
date: 1785517200000,
|
||||
salesPerson: { id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a', code: 'CYC_E_108858', name: 'Ada Lovelace' },
|
||||
branch: { id: '28179106-7f23-424a-ae70-05cfda01261a', code: 'B_411587', name: 'Jakarta Pusat' },
|
||||
division: { id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1', code: 'D_411587', name: 'Sales Division' },
|
||||
customer: { id: '74e9f045-e176-429c-8e24-8dd77a4e89f2', code: 'C_411587', name: 'Acme Corp' },
|
||||
address: 'depok',
|
||||
products: [
|
||||
{
|
||||
id: 'd0211f93-66d9-4aab-b1c6-eafb187875e6',
|
||||
product: { id: '023db166-be0a-46ed-9e70-cebeea672f08', code: 'FUEL_103044', name: 'Fuel 95' },
|
||||
quantity: '1.0000',
|
||||
price: '100000.0000',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(entity.branch).toEqual({
|
||||
id: '28179106-7f23-424a-ae70-05cfda01261a',
|
||||
code: 'B_411587',
|
||||
name: 'Jakarta Pusat',
|
||||
});
|
||||
expect(entity.division?.name).toBe('Sales Division');
|
||||
expect(entity.products?.[0].product?.name).toBe('Fuel 95');
|
||||
});
|
||||
|
||||
it('writes nested products and omits status', () => {
|
||||
const payload = transformer.transformCreatePayload({
|
||||
date: '2026-08-26',
|
||||
|
||||
@@ -5,15 +5,33 @@ import { relationLabel } from '../../field/shared/relation-label';
|
||||
|
||||
export function DetailGeneral({
|
||||
salesRequestHref,
|
||||
salesOrderHref,
|
||||
packingSlipHref,
|
||||
showBalance,
|
||||
}: {
|
||||
salesRequestHref?: (id: string) => string;
|
||||
salesOrderHref?: (id: string) => string;
|
||||
packingSlipHref?: (id: string) => string;
|
||||
showBalance?: boolean;
|
||||
} = {}) {
|
||||
const { detailData } = useDetailPageContext<
|
||||
SalesDocumentEntity & { salesRequestId?: string | null; salesRequest?: { id?: string; code?: string } | null }
|
||||
SalesDocumentEntity & {
|
||||
salesRequestId?: string | null;
|
||||
salesRequest?: { id?: string; code?: string } | null;
|
||||
salesOrderId?: string | null;
|
||||
salesOrder?: { id?: string; code?: string } | null;
|
||||
packingSlipId?: string | null;
|
||||
packingSlip?: { id?: string; code?: string } | null;
|
||||
salesOrderCode?: string | null;
|
||||
packingSlipCode?: string | null;
|
||||
balance?: string | null;
|
||||
}
|
||||
>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const data = detailData;
|
||||
const salesRequestId = data?.salesRequestId ?? data?.salesRequest?.id;
|
||||
const salesOrderId = data?.salesOrderId ?? data?.salesOrder?.id;
|
||||
const packingSlipId = data?.packingSlipId ?? data?.packingSlip?.id;
|
||||
|
||||
return (
|
||||
<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.division')} value={relationLabel(data?.division) || data?.divisionId} />
|
||||
<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 && (
|
||||
<FieldValue
|
||||
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 { relationLabel } from '../../field/shared/relation-label';
|
||||
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 { BranchEntity } from '../../configuration/branches/domain/entities';
|
||||
import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
|
||||
@@ -31,6 +35,8 @@ export function SalesFilterFormContent({
|
||||
clearable
|
||||
data={documentStatusFilterOptions(t, documentType)}
|
||||
/>
|
||||
{documentType !== 'payment' ? (
|
||||
<>
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={form.control}
|
||||
name="customer"
|
||||
@@ -75,6 +81,34 @@ export function SalesFilterFormContent({
|
||||
loadOptions={loadDivisionOptions}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
createdBy?: string | { id?: string; username?: string };
|
||||
updatedBy?: string | { id?: string; username?: string };
|
||||
}
|
||||
|
||||
export interface SalesLineDto {
|
||||
id?: string;
|
||||
productId?: string;
|
||||
product?: LookupStub | null;
|
||||
quantity: string;
|
||||
price?: string | null;
|
||||
}
|
||||
|
||||
export interface SalesDocumentDto {
|
||||
@@ -49,19 +57,18 @@ export interface SalesDocumentDto {
|
||||
code?: string | null;
|
||||
date: string | number;
|
||||
salesPersonId?: string;
|
||||
salesPerson?: LookupStub | null;
|
||||
branchId?: string;
|
||||
branch?: LookupStub | null;
|
||||
divisionId?: string;
|
||||
division?: LookupStub | null;
|
||||
customerId?: string;
|
||||
customer?: LookupStub | null;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: Array<{
|
||||
id?: string;
|
||||
productId: string;
|
||||
quantity: string;
|
||||
price?: string | null;
|
||||
}>;
|
||||
products?: SalesLineDto[];
|
||||
images?: Array<{
|
||||
id?: string;
|
||||
url: string;
|
||||
@@ -70,6 +77,6 @@ export interface SalesDocumentDto {
|
||||
status?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
createdBy?: string | { id?: string; username?: string };
|
||||
updatedBy?: string | { id?: string; username?: string };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mapSalesDocumentFromDto, toSalesFilterPayload, toSalesWritePayload } from './sales-document.mapper';
|
||||
import {
|
||||
mapSalesDocumentFromDto,
|
||||
salesRequestToFormValues,
|
||||
toSalesFilterPayload,
|
||||
toSalesWritePayload,
|
||||
} from './sales-document.mapper';
|
||||
|
||||
const nestedResponse = {
|
||||
id: 'fccaf567-17aa-4534-8ab3-2cee0b1905b9',
|
||||
code: 'SR-20260801-0001',
|
||||
date: 1785517200000,
|
||||
salesPerson: {
|
||||
id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a',
|
||||
code: 'CYC_E_108858',
|
||||
name: 'Ada Lovelace',
|
||||
},
|
||||
branch: {
|
||||
id: '28179106-7f23-424a-ae70-05cfda01261a',
|
||||
code: 'B_411587',
|
||||
name: 'Jakarta Pusat',
|
||||
},
|
||||
division: {
|
||||
id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1',
|
||||
code: 'D_411587',
|
||||
name: 'Sales Division',
|
||||
},
|
||||
customer: {
|
||||
id: '74e9f045-e176-429c-8e24-8dd77a4e89f2',
|
||||
code: 'C_411587',
|
||||
name: 'Acme Corp',
|
||||
},
|
||||
address: 'depok',
|
||||
latitude: -6.474014,
|
||||
longitude: 106.814575,
|
||||
notes: 'notes',
|
||||
status: 'draft',
|
||||
createdAt: 1787814580147,
|
||||
updatedAt: 1787814580147,
|
||||
createdBy: { id: '1b946785-f6be-4f0a-8527-4bf97b737df8', username: 'alice' },
|
||||
updatedBy: { id: '1b946785-f6be-4f0a-8527-4bf97b737df8', username: 'alice' },
|
||||
products: [
|
||||
{
|
||||
id: 'd0211f93-66d9-4aab-b1c6-eafb187875e6',
|
||||
product: {
|
||||
id: '023db166-be0a-46ed-9e70-cebeea672f08',
|
||||
code: 'FUEL_103044',
|
||||
name: 'Fuel 95',
|
||||
},
|
||||
quantity: '1.0000',
|
||||
price: '100000.0000',
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
};
|
||||
|
||||
describe('sales document mapper', () => {
|
||||
it('maps unix date and relation stubs onto the entity', () => {
|
||||
@@ -26,6 +79,58 @@ describe('sales document mapper', () => {
|
||||
expect(entity.products?.[0]).toMatchObject({ productId: 'prd-1', product: { id: 'prd-1' }, quantity: '2.0000' });
|
||||
});
|
||||
|
||||
it('keeps nested lookup code and name from the API response', () => {
|
||||
const entity = mapSalesDocumentFromDto(nestedResponse);
|
||||
|
||||
expect(entity.branch).toEqual({
|
||||
id: '28179106-7f23-424a-ae70-05cfda01261a',
|
||||
code: 'B_411587',
|
||||
name: 'Jakarta Pusat',
|
||||
});
|
||||
expect(entity.branchId).toBe('28179106-7f23-424a-ae70-05cfda01261a');
|
||||
expect(entity.division).toEqual({
|
||||
id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1',
|
||||
code: 'D_411587',
|
||||
name: 'Sales Division',
|
||||
});
|
||||
expect(entity.salesPerson).toEqual({
|
||||
id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a',
|
||||
code: 'CYC_E_108858',
|
||||
name: 'Ada Lovelace',
|
||||
});
|
||||
expect(entity.customer).toEqual({
|
||||
id: '74e9f045-e176-429c-8e24-8dd77a4e89f2',
|
||||
code: 'C_411587',
|
||||
name: 'Acme Corp',
|
||||
});
|
||||
expect(entity.products?.[0]).toMatchObject({
|
||||
productId: '023db166-be0a-46ed-9e70-cebeea672f08',
|
||||
product: { id: '023db166-be0a-46ed-9e70-cebeea672f08', code: 'FUEL_103044', name: 'Fuel 95' },
|
||||
quantity: '1.0000',
|
||||
price: '100000.0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('copies nested lookups onto form values for edit', () => {
|
||||
const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse));
|
||||
|
||||
expect(formValues.branch).toEqual({
|
||||
id: '28179106-7f23-424a-ae70-05cfda01261a',
|
||||
code: 'B_411587',
|
||||
name: 'Jakarta Pusat',
|
||||
});
|
||||
expect(formValues.division).toEqual({
|
||||
id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1',
|
||||
code: 'D_411587',
|
||||
name: 'Sales Division',
|
||||
});
|
||||
expect(formValues.products[0].product).toEqual({
|
||||
id: '023db166-be0a-46ed-9e70-cebeea672f08',
|
||||
code: 'FUEL_103044',
|
||||
name: 'Fuel 95',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes relation ids and nested lines on create', () => {
|
||||
const payload = toSalesWritePayload(
|
||||
{
|
||||
@@ -72,3 +177,35 @@ describe('sales document mapper', () => {
|
||||
).toEqual({ customerId: 'cus-1', status: 'draft' });
|
||||
});
|
||||
});
|
||||
|
||||
it('includes parent ids on create and omits images when requested', () => {
|
||||
const payload = toSalesWritePayload(
|
||||
{
|
||||
date: '2026-08-26',
|
||||
salesPerson: { id: 'emp-1' } as any,
|
||||
branch: { id: 'br-1' } as any,
|
||||
division: { id: 'div-1' } as any,
|
||||
customer: { id: 'cus-1' } as any,
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ product: { id: 'prd-1' } as any, quantity: '1' }],
|
||||
images: [{ url: 'https://cdn.example/a.png' }],
|
||||
salesOrder: { id: 'so-1' } as any,
|
||||
packingSlip: { id: 'ps-1' } as any,
|
||||
} as any,
|
||||
{ includeSalesOrderId: true, includePackingSlipId: true, omitImages: true },
|
||||
);
|
||||
|
||||
expect(payload.salesOrderId).toBe('so-1');
|
||||
expect(payload.packingSlipId).toBe('ps-1');
|
||||
expect(payload).not.toHaveProperty('images');
|
||||
});
|
||||
|
||||
it('flattens sales order and packing slip in filters', () => {
|
||||
expect(
|
||||
toSalesFilterPayload({
|
||||
salesOrder: { id: 'so-1' },
|
||||
packingSlip: { id: 'ps-1' },
|
||||
status: 'draft',
|
||||
}),
|
||||
).toEqual({ salesOrderId: 'so-1', packingSlipId: 'ps-1', status: 'draft' });
|
||||
});
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
||||
import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators';
|
||||
import type { SalesDocumentDto, SalesDocumentEntity, SalesImageEntity, SalesLineEntity } from './sales-document.entity';
|
||||
import type {
|
||||
LookupStub,
|
||||
SalesDocumentDto,
|
||||
SalesDocumentEntity,
|
||||
SalesImageEntity,
|
||||
SalesLineEntity,
|
||||
} from './sales-document.entity';
|
||||
|
||||
export function relationId(value: unknown): string | undefined {
|
||||
if (value && typeof value === 'object' && 'id' in value) {
|
||||
const id = (value as { id?: unknown }).id;
|
||||
return id == null ? undefined : String(id);
|
||||
}
|
||||
if (typeof value === 'string' && value) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -14,14 +21,36 @@ export function toRelationStub(id?: string | null) {
|
||||
return id ? { id } : null;
|
||||
}
|
||||
|
||||
export function toLookup(value: unknown, fallbackId?: string | null): LookupStub | null {
|
||||
if (value && typeof value === 'object' && 'id' in value) {
|
||||
const item = value as { id?: unknown; code?: unknown; name?: unknown };
|
||||
if (item.id == null || item.id === '') {
|
||||
return toRelationStub(fallbackId);
|
||||
}
|
||||
return {
|
||||
id: String(item.id),
|
||||
...(item.code != null && item.code !== '' ? { code: String(item.code) } : {}),
|
||||
...(item.name != null && item.name !== '' ? { name: String(item.name) } : {}),
|
||||
};
|
||||
}
|
||||
return toRelationStub(fallbackId ?? (typeof value === 'string' ? value : null));
|
||||
}
|
||||
|
||||
function lookupId(value: unknown, fallbackId?: string | null): string | undefined {
|
||||
return relationId(value) ?? (fallbackId == null || fallbackId === '' ? undefined : String(fallbackId));
|
||||
}
|
||||
|
||||
export function mapSalesLinesFromDto(dto: SalesDocumentDto): SalesLineEntity[] {
|
||||
return (dto.products ?? []).map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: toRelationStub(line.productId),
|
||||
quantity: line.quantity,
|
||||
price: line.price ?? null,
|
||||
}));
|
||||
return (dto.products ?? []).map((line) => {
|
||||
const product = toLookup(line.product, line.productId);
|
||||
return {
|
||||
id: line.id,
|
||||
productId: lookupId(line.product, line.productId),
|
||||
product,
|
||||
quantity: line.quantity,
|
||||
price: line.price ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[] {
|
||||
@@ -33,18 +62,22 @@ export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[]
|
||||
}
|
||||
|
||||
export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEntity {
|
||||
const salesPerson = toLookup(dto.salesPerson, dto.salesPersonId);
|
||||
const branch = toLookup(dto.branch, dto.branchId);
|
||||
const division = toLookup(dto.division, dto.divisionId);
|
||||
const customer = toLookup(dto.customer, dto.customerId);
|
||||
return {
|
||||
id: dto.id,
|
||||
code: dto.code ?? null,
|
||||
date: formatDateValue(parseDateValue(dto.date) ?? undefined),
|
||||
salesPersonId: dto.salesPersonId,
|
||||
salesPerson: toRelationStub(dto.salesPersonId),
|
||||
branchId: dto.branchId,
|
||||
branch: toRelationStub(dto.branchId),
|
||||
divisionId: dto.divisionId,
|
||||
division: toRelationStub(dto.divisionId),
|
||||
customerId: dto.customerId,
|
||||
customer: toRelationStub(dto.customerId),
|
||||
salesPersonId: lookupId(dto.salesPerson, dto.salesPersonId),
|
||||
salesPerson,
|
||||
branchId: lookupId(dto.branch, dto.branchId),
|
||||
branch,
|
||||
divisionId: lookupId(dto.division, dto.divisionId),
|
||||
division,
|
||||
customerId: lookupId(dto.customer, dto.customerId),
|
||||
customer,
|
||||
address: dto.address,
|
||||
latitude: dto.latitude ?? null,
|
||||
longitude: dto.longitude ?? null,
|
||||
@@ -61,7 +94,12 @@ export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEnt
|
||||
|
||||
export function toSalesWritePayload(
|
||||
entity: Partial<SalesDocumentEntity>,
|
||||
options?: { includeSalesRequestId?: boolean },
|
||||
options?: {
|
||||
includeSalesRequestId?: boolean;
|
||||
includeSalesOrderId?: boolean;
|
||||
includePackingSlipId?: boolean;
|
||||
omitImages?: boolean;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
const products = (entity.products ?? [])
|
||||
.map((line) => {
|
||||
@@ -91,14 +129,27 @@ export function toSalesWritePayload(
|
||||
longitude: emptyToNull(entity.longitude),
|
||||
notes: emptyToNull(entity.notes),
|
||||
products,
|
||||
images,
|
||||
};
|
||||
|
||||
if (!options?.omitImages) {
|
||||
payload.images = images;
|
||||
}
|
||||
|
||||
if (options?.includeSalesRequestId) {
|
||||
payload.salesRequestId =
|
||||
relationId((entity as { salesRequest?: unknown }).salesRequest) ??
|
||||
(entity as { salesRequestId?: string }).salesRequestId;
|
||||
}
|
||||
if (options?.includeSalesOrderId) {
|
||||
payload.salesOrderId =
|
||||
relationId((entity as { salesOrder?: unknown }).salesOrder) ??
|
||||
(entity as { salesOrderId?: string }).salesOrderId;
|
||||
}
|
||||
if (options?.includePackingSlipId) {
|
||||
payload.packingSlipId =
|
||||
relationId((entity as { packingSlip?: unknown }).packingSlip) ??
|
||||
(entity as { packingSlipId?: string }).packingSlipId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -125,6 +176,14 @@ export function toSalesFilterPayload(filter: Record<string, any>): Record<string
|
||||
next.salesRequestId = next.salesRequest.id;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
|
||||
@@ -43,3 +43,16 @@ describe('createSalesOrderSchema', () => {
|
||||
expect(schema.safeParse(validRequest).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSalesInvoiceSchema', () => {
|
||||
it('accepts optional sales order and packing slip', () => {
|
||||
const schema = createSalesInvoiceSchema(t);
|
||||
expect(schema.safeParse({ ...validRequest, salesOrder: relation, packingSlip: relation }).success).toBe(true);
|
||||
expect(schema.safeParse(validRequest).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a document with no product lines', () => {
|
||||
const schema = createSalesInvoiceSchema(t);
|
||||
expect(schema.safeParse({ ...validRequest, products: [] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,3 +82,10 @@ export function createSalesOrderSchema(t: (key: string) => string) {
|
||||
salesRequest: relationSchema.nullable().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createSalesInvoiceSchema(t: (key: string) => string) {
|
||||
return salesDocumentBaseSchema(t).extend({
|
||||
salesOrder: relationSchema.nullable().optional(),
|
||||
packingSlip: relationSchema.nullable().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,4 +19,19 @@ describe('sales status transitions', () => {
|
||||
expect(commonTransitions('order', ['draft', 'processed'])).toEqual(['cancelled']);
|
||||
expect(commonTransitions('order', ['draft'])).toEqual(['processed', 'cancelled']);
|
||||
});
|
||||
|
||||
it('allows invoice processed to partial, completed, or cancelled', () => {
|
||||
expect(allowedTransitions('invoice', 'processed')).toEqual(['partial', 'completed', 'cancelled']);
|
||||
expect(allowedTransitions('invoice', 'partial')).toEqual(['completed', 'cancelled']);
|
||||
});
|
||||
|
||||
it('returns named invoice complete only from processed', () => {
|
||||
expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']);
|
||||
expect(namedActionsFor('invoice', 'partial')).toEqual([]);
|
||||
});
|
||||
|
||||
it('reuses request transitions and named actions for payments', () => {
|
||||
expect(allowedTransitions('payment', 'draft')).toEqual(['pending', 'rejected']);
|
||||
expect(namedActionsFor('payment', 'pending').map((action) => action.key)).toEqual(['approve', 'reject']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,13 @@ export type SalesRequestStatus = (typeof SALES_REQUEST_STATUSES)[number];
|
||||
export const SALES_ORDER_STATUSES = ['draft', 'processed', 'completed', 'cancelled'] as const;
|
||||
export type SalesOrderStatus = (typeof SALES_ORDER_STATUSES)[number];
|
||||
|
||||
export type SalesDocumentType = 'request' | 'order';
|
||||
export const SALES_INVOICE_STATUSES = ['draft', 'processed', 'partial', 'completed', 'cancelled'] as const;
|
||||
export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number];
|
||||
|
||||
export const SALES_PAYMENT_STATUSES = SALES_REQUEST_STATUSES;
|
||||
export type SalesPaymentStatus = SalesRequestStatus;
|
||||
|
||||
export type SalesDocumentType = 'request' | 'order' | 'invoice' | 'payment';
|
||||
|
||||
const REQUEST_TRANSITIONS: Record<SalesRequestStatus, SalesRequestStatus[]> = {
|
||||
draft: ['pending', 'rejected'],
|
||||
@@ -20,6 +26,14 @@ const ORDER_TRANSITIONS: Record<SalesOrderStatus, SalesOrderStatus[]> = {
|
||||
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 = [
|
||||
{ key: 'submit', target: 'pending' as const, from: ['draft'] as const },
|
||||
{ key: 'approve', target: 'approved' as const, from: ['pending'] as const },
|
||||
@@ -32,15 +46,28 @@ export const SALES_ORDER_NAMED_ACTIONS = [
|
||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
|
||||
] as const;
|
||||
|
||||
export const SALES_INVOICE_NAMED_ACTIONS = [
|
||||
{ key: 'process', target: 'processed' as const, from: ['draft'] as const },
|
||||
{ key: 'complete', target: 'completed' as const, from: ['processed'] as const },
|
||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
|
||||
] as const;
|
||||
|
||||
export const SALES_PAYMENT_NAMED_ACTIONS = SALES_REQUEST_NAMED_ACTIONS;
|
||||
|
||||
export function documentStatuses(type: SalesDocumentType): readonly string[] {
|
||||
return type === 'request' ? SALES_REQUEST_STATUSES : SALES_ORDER_STATUSES;
|
||||
if (type === 'request' || type === 'payment') return SALES_REQUEST_STATUSES;
|
||||
if (type === 'invoice') return SALES_INVOICE_STATUSES;
|
||||
return SALES_ORDER_STATUSES;
|
||||
}
|
||||
|
||||
export function allowedTransitions(type: SalesDocumentType, current?: string | null): string[] {
|
||||
if (!current) return [];
|
||||
if (type === 'request') {
|
||||
if (type === 'request' || type === 'payment') {
|
||||
return REQUEST_TRANSITIONS[current as SalesRequestStatus] ?? [];
|
||||
}
|
||||
if (type === 'invoice') {
|
||||
return INVOICE_TRANSITIONS[current as SalesInvoiceStatus] ?? [];
|
||||
}
|
||||
return ORDER_TRANSITIONS[current as SalesOrderStatus] ?? [];
|
||||
}
|
||||
|
||||
@@ -52,7 +79,12 @@ export function commonTransitions(type: SalesDocumentType, statuses: Array<strin
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban } from 'lucide-react';
|
||||
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, Receipt, CreditCard } from 'lucide-react';
|
||||
import { notifications } from '@repo/ui/components';
|
||||
import {
|
||||
useDetailPageContext,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { namedActionsFor, type SalesDocumentType } from './sales-status';
|
||||
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
|
||||
type SalesActionEntity = { id?: string | number; status?: string };
|
||||
import { ChangeStatusModal } from './change-status-modal';
|
||||
import { ImportCsvModal } from './import-csv-modal';
|
||||
|
||||
@@ -44,7 +46,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
notifications.show({ color: 'green', message: t('status_updated') });
|
||||
};
|
||||
|
||||
const namedRowActions = (data: SalesDocumentEntity, defaultActions: any[]) => {
|
||||
const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => {
|
||||
if (!canEdit) return defaultActions;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
@@ -69,7 +71,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
return [...extras, ...defaultActions];
|
||||
};
|
||||
|
||||
const namedBulkActions = (selectedRows: SalesDocumentEntity[], defaultActions: any[]) => {
|
||||
const namedBulkActions = (selectedRows: SalesActionEntity[], defaultActions: any[]) => {
|
||||
if (!canEdit || selectedRows.length === 0) return defaultActions;
|
||||
const statuses = selectedRows.map((row) => row.status);
|
||||
const extras: any[] = namedActionsFor(documentType, statuses[0])
|
||||
@@ -114,7 +116,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const createOrderAction = (_data: SalesDocumentEntity, onClick: () => void) =>
|
||||
const createOrderAction = (_data: SalesActionEntity, onClick: () => void) =>
|
||||
canEdit
|
||||
? {
|
||||
key: 'create-order',
|
||||
@@ -126,7 +128,31 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const detailStatusActions = (data: SalesDocumentEntity, defaultActions: any[]) => {
|
||||
const createInvoiceAction = (_data: SalesActionEntity, onClick: () => void) =>
|
||||
canEdit
|
||||
? {
|
||||
key: 'create-invoice',
|
||||
label: t('create_sales_invoice'),
|
||||
icon: <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;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
@@ -182,6 +208,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
namedBulkActions,
|
||||
importPageAction,
|
||||
createOrderAction,
|
||||
createInvoiceAction,
|
||||
createPaymentAction,
|
||||
detailStatusActions,
|
||||
applyStatus,
|
||||
modals,
|
||||
|
||||
@@ -16,4 +16,5 @@ export const API_URL = {
|
||||
PLANS: '/plans',
|
||||
SALES_INVOICES: '/sales-invoices',
|
||||
PACKING_SLIPS: '/packing-slips',
|
||||
SALES_PAYMENTS: '/sales-payments',
|
||||
} as const;
|
||||
|
||||
@@ -232,6 +232,9 @@
|
||||
"brand": "Brand",
|
||||
"url": "URL",
|
||||
"images": "Images",
|
||||
"salesOrder": "Sales Order",
|
||||
"packingSlip": "Packing Slip",
|
||||
"invoice": "Invoice",
|
||||
"salesRequest": "Sales Request",
|
||||
"lineTotal": "Line Total"
|
||||
},
|
||||
|
||||
@@ -232,6 +232,9 @@
|
||||
"brand": "Merek",
|
||||
"url": "URL",
|
||||
"images": "Gambar",
|
||||
"salesOrder": "Pesanan Penjualan",
|
||||
"packingSlip": "Surat Jalan",
|
||||
"invoice": "Faktur",
|
||||
"salesRequest": "Permintaan Penjualan",
|
||||
"lineTotal": "Total Baris"
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@ export enum STATUS_DATA {
|
||||
|
||||
TODO = 'todo',
|
||||
DONE = 'done',
|
||||
PARTIAL = 'partial',
|
||||
PARTIAL_DONE = 'partial-done',
|
||||
|
||||
ON_HOLD = 'on-hold',
|
||||
@@ -150,6 +151,7 @@ export enum STATUS_COLOR {
|
||||
WAIT = '#AB6103',
|
||||
WAITING = '#B87104',
|
||||
WAITING_LIST = '#C68106',
|
||||
PARTIAL = '#D39308',
|
||||
PARTIAL_DONE = '#D39308',
|
||||
ON_HOLD = '#E0A50B',
|
||||
REFUND = '#EDB70D',
|
||||
@@ -208,6 +210,7 @@ export const DEFAULT_STATUS_MAP: Record<string, BadgeProps> = {
|
||||
[STATUS_DATA.ACTIVATED]: { color: STATUS_COLOR.ACTIVATED, leftSection: getIcon(CheckCircle2) },
|
||||
[STATUS_DATA.APPROVED]: { color: STATUS_COLOR.APPROVED, leftSection: getIcon(CheckCircle2) },
|
||||
[STATUS_DATA.PROCESSED]: { color: STATUS_COLOR.PROCESSED, leftSection: getIcon(Check) },
|
||||
[STATUS_DATA.PARTIAL]: { color: STATUS_COLOR.PARTIAL, leftSection: getIcon(Clock) },
|
||||
[STATUS_DATA.COMPLETED]: { color: STATUS_COLOR.COMPLETED, leftSection: getIcon(CheckCircle2) },
|
||||
[STATUS_DATA.COMPLETE]: { color: STATUS_COLOR.COMPLETE, leftSection: getIcon(CheckCircle2) },
|
||||
[STATUS_DATA.DONE]: { color: STATUS_COLOR.DONE, leftSection: getIcon(CheckCircle2) },
|
||||
|
||||
Reference in New Issue
Block a user