feat: implement packing slips module with comprehensive functionality

- Introduced a new Packing Slips module, including routes for creating, editing, and viewing packing slips.
- Added components for packing slip forms, detail views, and index pages, enhancing user experience and data management.
- Integrated language support for English and Indonesian in the packing slips module.
- Developed remote data services and transformers for handling packing slip 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 packing slip management, improving user experience and data handling.
This commit is contained in:
shancheas
2026-08-31 09:35:31 +07:00
parent 15efa358f9
commit 050fafd731
49 changed files with 942 additions and 178 deletions
@@ -6,7 +6,8 @@ import type { MenuItemType } from '../types/menu.types';
const childKeys = (item: MenuItemType | undefined): string[] => (item?.children ?? []).map((child) => child.key); const childKeys = (item: MenuItemType | undefined): string[] => (item?.children ?? []).map((child) => child.key);
const findItem = (items: MenuItemType[], key: string): MenuItemType | undefined => items.find((item) => item.key === key); const findItem = (items: MenuItemType[], key: string): MenuItemType | undefined =>
items.find((item) => item.key === key);
const flatten = (items: MenuItemType[]): MenuItemType[] => const flatten = (items: MenuItemType[]): MenuItemType[] =>
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]); items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
@@ -58,7 +59,10 @@ describe('MENU_ITEMS', () => {
'configuration-customers', 'configuration-customers',
'configuration-products', 'configuration-products',
]); ]);
expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual(['system-users', 'system-privileges']); expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual([
'system-users',
'system-privileges',
]);
}); });
it('uses unique keys across the whole tree', () => { it('uses unique keys across the whole tree', () => {
@@ -151,7 +151,7 @@ export const MENU_ITEMS: MenuItemType[] = [
key: 'logistics-packing-slips', key: 'logistics-packing-slips',
label: 'nav:logistics-packing-slips', label: 'nav:logistics-packing-slips',
icon: Package, icon: Package,
path: '/app/logistics/packing-slips', path: '/app/logistics/packing-slips/index',
moduleKey: 'SALES.PACKING_SLIP', moduleKey: 'SALES.PACKING_SLIP',
}, },
{ {
@@ -5,6 +5,7 @@ import { EmbeddedComingSoonPage } from '../../../../../core/components/coming-so
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory')); const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../cycles/presentation/factory')); const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
const PlansModule = lazy(() => import('../plans/presentation/factory')); const PlansModule = lazy(() => import('../plans/presentation/factory'));
const PackingSlipsModule = lazy(() => import('../packing-slips/presentation/factory'));
export default function LogisticsFieldModule() { export default function LogisticsFieldModule() {
return ( return (
@@ -12,7 +13,7 @@ export default function LogisticsFieldModule() {
<Route path="/employees/*" element={<EmployeesModule purpose="logistics" />} /> <Route path="/employees/*" element={<EmployeesModule purpose="logistics" />} />
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} /> <Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} /> <Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
<Route path="/packing-slips" element={<EmbeddedComingSoonPage />} /> <Route path="/packing-slips/*" element={<PackingSlipsModule />} />
<Route path="/reports" element={<EmbeddedComingSoonPage />} /> <Route path="/reports" element={<EmbeddedComingSoonPage />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} /> <Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes> </Routes>
@@ -0,0 +1,13 @@
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { DataServicesConfig } from '@repo/core-api/data-services';
import { SalesDocumentRemoteDataServices } from '../../../sales/shared/sales-document.remote.service';
import type { PackingSlipEntity } from '../domain/entities';
export class PackingSlipsRemoteDataServices extends SalesDocumentRemoteDataServices<PackingSlipEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<PackingSlipEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/packing-slips',
});
}
}
@@ -0,0 +1 @@
export * from './packing-slip.constants';
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { PackingSlipEntity } from '../entities';
export const packingSlipsModuleConfig: ModuleConfigEntity<PackingSlipEntity> = {
moduleKey: 'SALES.PACKING_SLIP',
translationNamespace: 'PACKING_SLIPS',
apiUrl: '/packing-slips',
webUrl: '/app/logistics/packing-slips',
moduleCategory: 'FULL_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1 @@
export * from './packing-slip.entity';
@@ -0,0 +1,13 @@
import type { SalesDocumentDto, SalesDocumentEntity } from '../../../sales/shared/sales-document.entity';
export interface PackingSlipEntity extends SalesDocumentEntity {
salesOrderId?: string | null;
salesOrder?: { id: string; code?: string; name?: string } | null;
salesOrderCode?: string | null;
}
export interface PackingSlipDto extends SalesDocumentDto {
salesOrderId?: string | null;
salesOrder?: { id: string; code?: string; name?: string } | null;
salesOrderCode?: string | null;
}
@@ -0,0 +1,12 @@
import { apiClient } from '../../../../../../../core/lib/api-client';
import { PackingSlipsRemoteDataServices } from '../../data/packing-slip.remote.service';
import { packingSlipsModuleConfig } from '../constants';
import { PackingSlipsRemoteDataTransformer } from '../transformers/packing-slip.remote.transformer';
export const packingSlipsDataTransformer = new PackingSlipsRemoteDataTransformer();
export const packingSlipsModuleDataService = new PackingSlipsRemoteDataServices(apiClient, {
apiUrl: packingSlipsModuleConfig.apiUrl,
moduleKey: packingSlipsModuleConfig.moduleKey,
transformer: packingSlipsDataTransformer,
});
@@ -0,0 +1,49 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import {
mapSalesDocumentFromDto,
relationId,
toLookup,
toSalesFilterPayload,
toSalesWritePayload,
} from '../../../sales/shared/sales-document.mapper';
import type { PackingSlipDto, PackingSlipEntity } from '../entities';
export class PackingSlipsRemoteDataTransformer extends BaseDataTransformer<PackingSlipEntity> {
transformToEntity(dto: PackingSlipDto | PackingSlipEntity): PackingSlipEntity {
const packingDto = dto as PackingSlipDto;
const base = mapSalesDocumentFromDto(packingDto);
const salesOrder = toLookup(packingDto.salesOrder, packingDto.salesOrderId);
return {
...base,
salesOrderId: relationId(packingDto.salesOrder) ?? packingDto.salesOrderId ?? null,
salesOrder,
salesOrderCode: packingDto.salesOrderCode ?? salesOrder?.code ?? null,
};
}
transformToDTO(entity: PackingSlipEntity): PackingSlipEntity {
return { ...entity };
}
transformCreatePayload(entity: Partial<PackingSlipEntity>): Partial<PackingSlipEntity> {
return omitEmptyFields(
toSalesWritePayload(entity, {
includeSalesOrderId: true,
omitImages: true,
omitStaffFields: true,
}),
) as Partial<PackingSlipEntity>;
}
transformEditPayload(entity: Partial<PackingSlipEntity>): Partial<PackingSlipEntity> {
return toSalesWritePayload(entity, {
omitImages: true,
omitStaffFields: true,
}) as Partial<PackingSlipEntity>;
}
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
return toSalesFilterPayload(filter);
}
}
@@ -0,0 +1,64 @@
import { Box, FieldAsyncSelect, FieldDatePicker, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
import { loadSalesOrderOptions } from '../../../../sales/shared/load-sales-order-options';
import { relationLabel } from '../../../../shared/relation-label';
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
import type { SalesOrderEntity } from '../../../../sales/orders/domain/entities';
export function FormPackingGeneral() {
const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
const customer = formControl.watch('customer');
const salesOrder = formControl.watch('salesOrder');
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')}
placeholder="e.g. PS-20260826-0001"
radius="md"
/>
<FieldDatePicker
control={formControl.control}
name="date"
label={t('common:fields.date')}
required
radius="md"
/>
<FieldAsyncSelect<CustomerEntity>
control={formControl.control}
name="customer"
label={t('common:fields.customer')}
valueKey="id"
labelKey="name"
required
searchable
loadOptions={loadCustomerOptions}
defaultOptions={customer ? [customer] : []}
renderLabel={relationLabel}
/>
<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}
/>
</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 { packingSlipsModuleConfig } from '../../domain/constants';
import { packingSlipsModuleDataService } from '../../domain/factories';
import { PackingSlipEntity } from '../../domain/entities';
import { packingSlipsStore } from '../store';
import packingSlipsId from '../languages/id/packing-slips.json';
import packingSlipsEn from '../languages/en/packing-slips.json';
const IndexPage = lazy(() => import('../pages/packing-slip.page.index'));
const FormPage = lazy(() => import('../pages/packing-slip.page.form'));
const DetailPage = lazy(() => import('../pages/packing-slip.page.detail'));
registerModuleNamespace(packingSlipsModuleConfig.translationNamespace, {
id: packingSlipsId,
en: packingSlipsEn,
});
export default function PackingSlipsModule() {
return (
<EnterpriseModuleProvider<PackingSlipEntity>
config={packingSlipsModuleConfig}
dataServices={packingSlipsModuleDataService}
store={packingSlipsStore}
>
<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={`${packingSlipsModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,33 @@
{
"title": "Packing Slips",
"detail_page_title": "Packing Slip Detail",
"create_page_title": "New Packing Slip",
"edit_page_title": "Edit Packing Slip",
"duplicate_page_title": "Duplicate Packing Slip",
"description": "Create <1>packing slips</1> from a sales order or as a standalone document.",
"detail_page_description": "Review packing header, location, and product quantities.",
"create_page_description": "Create a packing slip, optionally sourced from a sales order.",
"edit_page_description": "Update packing header, location, products, and notes.",
"duplicate_page_description": "Copy an existing packing slip to create a new one.",
"section_general": "General",
"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.",
"action_complete": "Complete",
"action_cancel": "Cancel",
"complete_packing": "Complete packing",
"complete_packing_help": "Enter delivered quantity for each line. Remaining quantity opens a new packing slip.",
"delivered_quantity": "Delivered quantity",
"status_draft": "Draft",
"status_processed": "Processed",
"status_completed": "Completed",
"status_cancelled": "Cancelled"
}
@@ -0,0 +1,33 @@
{
"title": "Surat Jalan",
"detail_page_title": "Detail Surat Jalan",
"create_page_title": "Surat Jalan Baru",
"edit_page_title": "Ubah Surat Jalan",
"duplicate_page_title": "Duplikat Surat Jalan",
"description": "Buat <1>surat jalan</1> dari pesanan penjualan atau sebagai dokumen mandiri.",
"detail_page_description": "Tinjau header, lokasi, dan kuantitas produk surat jalan.",
"create_page_description": "Buat surat jalan, opsional dari pesanan penjualan.",
"edit_page_description": "Perbarui header, lokasi, produk, dan catatan surat jalan.",
"duplicate_page_description": "Salin surat jalan yang ada untuk membuat data baru.",
"section_general": "Umum",
"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.",
"action_complete": "Selesaikan",
"action_cancel": "Batalkan",
"complete_packing": "Selesaikan packing",
"complete_packing_help": "Masukkan kuantitas terkirim untuk setiap baris. Sisa kuantitas akan membuka surat jalan baru.",
"delivered_quantity": "Kuantitas terkirim",
"status_draft": "Draft",
"status_processed": "Diproses",
"status_completed": "Selesai",
"status_cancelled": "Dibatalkan"
}
@@ -0,0 +1,38 @@
import { Stack } from '@repo/ui/components';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { packingSlipsModuleConfig } from '../../domain/constants';
import { DetailGeneral } from '../../../sales/shared/detail-general';
import { DetailLocation } from '../../../sales/shared/detail-location';
import { DetailProducts } from '../../../sales/shared/detail-products';
import { useSalesDocumentActions } from '../../../sales/shared/use-sales-document-actions';
import { salesOrdersModuleConfig } from '../../../sales/orders/domain/constants';
import type { PackingSlipEntity } from '../../domain/entities';
export default function PackingSlipPageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
const actions = useSalesDocumentActions('packing');
return (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
breadcrumbs: [
{ label: t('nav:logistics'), type: 'text' },
{ label: t('nav:logistics-packing-slips'), type: 'link', href: `${packingSlipsModuleConfig.webUrl}/index` },
],
}}
customPageActions={(data, pageActions) =>
actions.detailStatusActions(data as PackingSlipEntity, pageActions ?? [])
}
>
<Stack gap="md">
<DetailGeneral salesOrderHref={(id) => `${salesOrdersModuleConfig.webUrl}/detail/${id}`} />
<DetailLocation />
<DetailProducts />
</Stack>
{actions.modals}
</EnterpriseDetailPageProvider>
);
}
@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { Stack } from '@repo/ui/components';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { packingSlipsModuleConfig } from '../../domain/constants';
import { createSalesPackingSlipSchema } from '../../../sales/shared/sales-document.validator';
import { FormPackingGeneral } from '../components/form-component/form-packing-general';
import { FormLocation } from '../../../sales/shared/form-location';
import { FormProducts } from '../../../sales/shared/form-products';
import { FormNotes } from '../../../sales/shared/form-notes';
export default function PackingSlipPageForm({ formPageType }: { formPageType: FormPageType }) {
const { t } = useEnterpriseModuleTranslationContext();
const title = useMemo(() => {
if (formPageType === 'CREATE') {
return { title: t('create_page_title'), description: t('create_page_description') };
}
if (formPageType === 'EDIT') {
return { title: t('edit_page_title'), description: t('edit_page_description') };
}
if (formPageType === 'DUPLICATE') {
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
}
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => createSalesPackingSlipSchema(t), [t]);
const formControl = useForm({
resolver: zodResolver(validator),
defaultValues: {
products: [{ quantity: '1', price: '' }],
},
});
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:logistics'), type: 'text' },
{ label: t('nav:logistics-packing-slips'), type: 'link', href: `${packingSlipsModuleConfig.webUrl}/index` },
],
}}
>
<Stack gap="md">
<FormPackingGeneral />
<FormLocation />
<FormProducts />
<FormNotes />
</Stack>
</EnterpriseFormPageProvider>
);
}
@@ -0,0 +1,72 @@
import { useMemo } from 'react';
import {
EnterpriseIndexPageProvider,
useEnterpriseModuleTranslationContext,
EnterpriseDataTable,
} from '@repo/ui/foundations';
import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { Package } from 'lucide-react';
import { SalesFilterFormContent } from '../../../sales/shared/filter-content';
import { useSalesDocumentActions } from '../../../sales/shared/use-sales-document-actions';
import { relationLabel } from '../../shared/relation-label';
import type { PackingSlipEntity } from '../../domain/entities';
export default function PackingSlipPageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
const actions = useSalesDocumentActions('packing');
const columnDefs: ColDef<PackingSlipEntity>[] = 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: 'salesOrderId',
headerName: t('common:fields.salesOrder'),
minWidth: 160,
valueGetter: ({ data }) => relationLabel(data?.salesOrder) || data?.salesOrderCode || data?.salesOrderId,
},
],
[t],
);
const filterConfig = useMemo(
() => ({
renderBody: (form: any) => (form ? <SalesFilterFormContent form={form} t={t} documentType="packing" /> : 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: Package,
breadcrumbs: [
{ label: t('nav:logistics'), type: 'text' },
{ label: t('nav:logistics-packing-slips'), 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 { PackingSlipEntity } from '../../domain/entities';
export interface PackingSlipsStoreState extends EnterpriseModuleState<PackingSlipEntity> {}
export const packingSlipsStore = create<PackingSlipsStoreState>((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 }),
}));
@@ -125,6 +125,21 @@ export function DetailGeneral() {
</Button> </Button>
</Group> </Group>
</Paper> </Paper>
{(data?.purpose === 'sales' ? data?.invoices : data?.packingSlips)?.length ? (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_attachments')}
</Text>
<Stack gap="xs">
{(data.purpose === 'sales' ? data.invoices : data?.packingSlips)?.map((item) => (
<Text key={item.id} size="sm">
{relationLabel(item) || item.id}
</Text>
))}
</Stack>
</Paper>
) : null}
</Stack> </Stack>
); );
} }
@@ -19,6 +19,7 @@
"add_destination": "Add destination", "add_destination": "Add destination",
"remove_destination": "Remove destination", "remove_destination": "Remove destination",
"empty_route": "No route geometry", "empty_route": "No route geometry",
"section_attachments": "Attachments",
"purpose_sales": "Sales", "purpose_sales": "Sales",
"purpose_logistics": "Logistics", "purpose_logistics": "Logistics",
"status_draft": "Draft", "status_draft": "Draft",
@@ -19,6 +19,7 @@
"add_destination": "Tambah destinasi", "add_destination": "Tambah destinasi",
"remove_destination": "Hapus destinasi", "remove_destination": "Hapus destinasi",
"empty_route": "Tidak ada geometri rute", "empty_route": "Tidak ada geometri rute",
"section_attachments": "Lampiran",
"purpose_sales": "Penjualan", "purpose_sales": "Penjualan",
"purpose_logistics": "Logistik", "purpose_logistics": "Logistik",
"status_draft": "Draft", "status_draft": "Draft",
@@ -23,8 +23,6 @@
"import_success": "CSV imported.", "import_success": "CSV imported.",
"status_updated": "Status updated.", "status_updated": "Status updated.",
"create_sales_payment": "Create Sales Payment", "create_sales_payment": "Create Sales Payment",
"action_process": "Process",
"action_complete": "Complete",
"action_cancel": "Cancel", "action_cancel": "Cancel",
"status_draft": "Draft", "status_draft": "Draft",
"status_processed": "Processed", "status_processed": "Processed",
@@ -23,8 +23,6 @@
"import_success": "CSV berhasil diimpor.", "import_success": "CSV berhasil diimpor.",
"status_updated": "Status diperbarui.", "status_updated": "Status diperbarui.",
"create_sales_payment": "Buat Pembayaran Penjualan", "create_sales_payment": "Buat Pembayaran Penjualan",
"action_process": "Proses",
"action_complete": "Selesaikan",
"action_cancel": "Batalkan", "action_cancel": "Batalkan",
"status_draft": "Draft", "status_draft": "Draft",
"status_processed": "Diproses", "status_processed": "Diproses",
@@ -35,10 +35,7 @@ export default function SalesInvoicePageDetail() {
}} }}
> >
<Stack gap="md"> <Stack gap="md">
<DetailGeneral <DetailGeneral salesOrderHref={(id) => `${salesOrdersModuleConfig.webUrl}/detail/${id}`} showBalance />
salesOrderHref={(id) => `${salesOrdersModuleConfig.webUrl}/detail/${id}`}
showBalance
/>
<DetailLocation /> <DetailLocation />
<DetailProducts /> <DetailProducts />
</Stack> </Stack>
@@ -3,9 +3,13 @@ import type { SalesDocumentDto, SalesDocumentEntity } from '../../../shared/sale
export interface SalesOrderEntity extends SalesDocumentEntity { export interface SalesOrderEntity extends SalesDocumentEntity {
salesRequestId?: string | null; salesRequestId?: string | null;
salesRequest?: { id: string; code?: string; name?: string } | null; salesRequest?: { id: string; code?: string; name?: string } | null;
packingSlipIds?: string[];
invoiceIds?: string[];
} }
export interface SalesOrderDto extends SalesDocumentDto { export interface SalesOrderDto extends SalesDocumentDto {
salesRequestId?: string | null; salesRequestId?: string | null;
salesRequest?: { id: string; code?: string; name?: string } | null; salesRequest?: { id: string; code?: string; name?: string } | null;
packingSlipIds?: string[];
invoiceIds?: string[];
} }
@@ -18,6 +18,8 @@ export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer<SalesO
...base, ...base,
salesRequestId: relationId(orderDto.salesRequest) ?? orderDto.salesRequestId ?? null, salesRequestId: relationId(orderDto.salesRequest) ?? orderDto.salesRequestId ?? null,
salesRequest, salesRequest,
packingSlipIds: orderDto.packingSlipIds ?? [],
invoiceIds: orderDto.invoiceIds ?? [],
}; };
} }
@@ -0,0 +1,50 @@
import { Anchor, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { packingSlipsModuleConfig } from '../../../../../field/packing-slips/domain/constants';
import { salesInvoicesModuleConfig } from '../../../../invoices/domain/constants';
import type { SalesOrderEntity } from '../../../domain/entities';
export function DetailRelated() {
const { detailData } = useDetailPageContext<SalesOrderEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const packingSlipIds = detailData?.packingSlipIds ?? [];
const invoiceIds = detailData?.invoiceIds ?? [];
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_related')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t('common:fields.packingSlip')}
</Text>
{packingSlipIds.length === 0 ? (
<Text size="sm">-</Text>
) : (
packingSlipIds.map((id) => (
<Anchor key={id} href={`${packingSlipsModuleConfig.webUrl}/detail/${id}`}>
{id}
</Anchor>
))
)}
</Stack>
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t('common:fields.salesInvoice')}
</Text>
{invoiceIds.length === 0 ? (
<Text size="sm">-</Text>
) : (
invoiceIds.map((id) => (
<Anchor key={id} href={`${salesInvoicesModuleConfig.webUrl}/detail/${id}`}>
{id}
</Anchor>
))
)}
</Stack>
</SimpleGrid>
</Paper>
);
}
@@ -28,8 +28,10 @@
"status_updated": "Status updated.", "status_updated": "Status updated.",
"create_sales_invoice": "Create Sales Invoice", "create_sales_invoice": "Create Sales Invoice",
"action_process": "Process", "action_process": "Process",
"action_complete": "Complete",
"action_cancel": "Cancel", "action_cancel": "Cancel",
"section_related": "Related documents",
"generate_packing_slip": "Generate packing slip",
"process_order_help": "Processing creates a sales invoice and, unless skipped, a packing slip.",
"status_draft": "Draft", "status_draft": "Draft",
"status_processed": "Processed", "status_processed": "Processed",
"status_completed": "Completed", "status_completed": "Completed",
@@ -28,8 +28,10 @@
"status_updated": "Status diperbarui.", "status_updated": "Status diperbarui.",
"create_sales_invoice": "Buat Faktur Penjualan", "create_sales_invoice": "Buat Faktur Penjualan",
"action_process": "Proses", "action_process": "Proses",
"action_complete": "Selesaikan",
"action_cancel": "Batalkan", "action_cancel": "Batalkan",
"section_related": "Dokumen terkait",
"generate_packing_slip": "Buat surat jalan",
"process_order_help": "Memproses akan membuat faktur penjualan dan, kecuali dilewati, surat jalan.",
"status_draft": "Draft", "status_draft": "Draft",
"status_processed": "Diproses", "status_processed": "Diproses",
"status_completed": "Selesai", "status_completed": "Selesai",
@@ -6,6 +6,7 @@ import { DetailGeneral } from '../../../shared/detail-general';
import { DetailLocation } from '../../../shared/detail-location'; import { DetailLocation } from '../../../shared/detail-location';
import { DetailProducts } from '../../../shared/detail-products'; import { DetailProducts } from '../../../shared/detail-products';
import { DetailImages } from '../../../shared/detail-images'; import { DetailImages } from '../../../shared/detail-images';
import { DetailRelated } from '../components/detail-component/detail-related';
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
import { salesRequestsModuleConfig } from '../../../requests/domain/constants'; import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants'; import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants';
@@ -39,6 +40,7 @@ export default function SalesOrderPageDetail() {
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} /> <DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
<DetailLocation /> <DetailLocation />
<DetailProducts /> <DetailProducts />
<DetailRelated />
<DetailImages /> <DetailImages />
</Stack> </Stack>
{actions.modals} {actions.modals}
@@ -1,4 +1,14 @@
import { ActionIcon, Box, Button, FieldAsyncSelect, FieldTextInput, Group, Paper, Table, Text } from '@repo/ui/components'; import {
ActionIcon,
Box,
Button,
FieldAsyncSelect,
FieldTextInput,
Group,
Paper,
Table,
Text,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { useFieldArray, useWatch } from '@repo/ui/form'; import { useFieldArray, useWatch } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
@@ -66,12 +76,7 @@ export function FormAllocations() {
</Table> </Table>
</Box> </Box>
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Button <Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={() => append({ amount: '' })}>
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => append({ amount: '' })}
>
{t('add_allocation')} {t('add_allocation')}
</Button> </Button>
{fields.length === 0 ? ( {fields.length === 0 ? (
@@ -12,12 +12,7 @@ export function FormPaymentGeneral() {
</Text> </Text>
<Box> <Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput <FieldTextInput control={formControl.control} name="code" label={t('common:fields.code')} radius="md" />
control={formControl.control}
name="code"
label={t('common:fields.code')}
radius="md"
/>
<FieldDatePicker <FieldDatePicker
control={formControl.control} control={formControl.control}
name="date" name="date"
@@ -27,6 +27,7 @@
"action_submit": "Submit", "action_submit": "Submit",
"action_approve": "Approve", "action_approve": "Approve",
"action_reject": "Reject", "action_reject": "Reject",
"action_rollback": "Rollback",
"status_draft": "Draft", "status_draft": "Draft",
"status_pending": "Pending", "status_pending": "Pending",
"status_approved": "Approved", "status_approved": "Approved",
@@ -27,6 +27,7 @@
"action_submit": "Kirim", "action_submit": "Kirim",
"action_approve": "Setujui", "action_approve": "Setujui",
"action_reject": "Tolak", "action_reject": "Tolak",
"action_rollback": "Kembalikan",
"status_draft": "Draft", "status_draft": "Draft",
"status_pending": "Menunggu", "status_pending": "Menunggu",
"status_approved": "Disetujui", "status_approved": "Disetujui",
@@ -1,21 +1,24 @@
import { Button, FieldSelect, Group, Modal, Stack } from '@repo/ui/components'; import { Button, FieldSelect, Group, Modal, Stack } from '@repo/ui/components';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { documentStatuses, type SalesDocumentType } from './sales-status'; import { allowedTransitions, type SalesDocumentType } from './sales-status';
export function ChangeStatusModal({ export function ChangeStatusModal({
opened, opened,
onClose, onClose,
documentType, documentType,
currentStatus,
onSubmit, onSubmit,
}: { }: {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
documentType: SalesDocumentType; documentType: SalesDocumentType;
currentStatus?: string | null;
onSubmit: (status: string) => Promise<void> | void; onSubmit: (status: string) => Promise<void> | void;
}) { }) {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const form = useForm<{ status: string }>(); const form = useForm<{ status: string }>();
const options = allowedTransitions(documentType, currentStatus);
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
await onSubmit(values.status); await onSubmit(values.status);
@@ -32,7 +35,7 @@ export function ChangeStatusModal({
name="status" name="status"
label={t('common:fields.status')} label={t('common:fields.status')}
required required
data={documentStatuses(documentType).map((value) => ({ value, label: t(`status_${value}`) }))} data={options.map((value) => ({ value, label: t(`status_${value}`) }))}
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" type="button" onClick={onClose}> <Button variant="default" type="button" onClick={onClose}>
@@ -0,0 +1,70 @@
import { Button, FieldTextInput, Group, Modal, Stack, Text } from '@repo/ui/components';
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { relationLabel } from '../../field/shared/relation-label';
import type { SalesLineEntity } from './sales-document.entity';
export function CompletePackingModal({
opened,
onClose,
products,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
products: SalesLineEntity[];
onSubmit: (products: Array<{ productId: string; quantity: string }>) => Promise<void> | void;
}) {
const { t } = useEnterpriseModuleTranslationContext();
const form = useForm<{ products: Array<{ productId: string; quantity: string; label: string }> }>({
defaultValues: { products: [] },
});
const { fields } = useFieldArray({ control: form.control, name: 'products' });
useEffect(() => {
if (!opened) return;
form.reset({
products: products.map((line) => ({
productId: line.productId ?? line.product?.id ?? '',
quantity: line.quantity,
label: relationLabel(line.product) || line.productId || '',
})),
});
}, [form, opened, products]);
const handleSubmit = form.handleSubmit(async (values) => {
await onSubmit(
values.products.map((line) => ({
productId: line.productId,
quantity: line.quantity,
})),
);
onClose();
});
return (
<Modal opened={opened} onClose={onClose} title={t('complete_packing')} size="lg">
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text size="sm">{t('complete_packing_help')}</Text>
{fields.map((field, index) => (
<FieldTextInput
key={field.id}
control={form.control as any}
name={`products.${index}.quantity`}
label={`${t('delivered_quantity')}${form.getValues(`products.${index}.label`) || field.label}`}
required
/>
))}
<Group justify="flex-end">
<Button variant="default" type="button" onClick={onClose}>
{t('common:cancel')}
</Button>
<Button type="submit">{t('action_complete')}</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -59,7 +59,9 @@ export function DetailGeneral({
value={salesOrderId} value={salesOrderId}
render={() => render={() =>
salesOrderId ? ( salesOrderId ? (
<Anchor href={salesOrderHref(salesOrderId)}>{data?.salesOrder?.code || data?.salesOrderCode || salesOrderId}</Anchor> <Anchor href={salesOrderHref(salesOrderId)}>
{data?.salesOrder?.code || data?.salesOrderCode || salesOrderId}
</Anchor>
) : ( ) : (
'-' '-'
) )
@@ -72,7 +74,9 @@ export function DetailGeneral({
value={packingSlipId} value={packingSlipId}
render={() => render={() =>
packingSlipId ? ( packingSlipId ? (
<Anchor href={packingSlipHref(packingSlipId)}>{data?.packingSlip?.code || data?.packingSlipCode || packingSlipId}</Anchor> <Anchor href={packingSlipHref(packingSlipId)}>
{data?.packingSlip?.code || data?.packingSlipCode || packingSlipId}
</Anchor>
) : ( ) : (
'-' '-'
) )
@@ -0,0 +1,41 @@
import { Button, FieldCheckbox, Group, Modal, Stack, Text } from '@repo/ui/components';
import { useForm } from 'react-hook-form';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
export function ProcessOrderModal({
opened,
onClose,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
onSubmit: (generatePackingSlip: boolean) => Promise<void> | void;
}) {
const { t } = useEnterpriseModuleTranslationContext();
const form = useForm<{ generatePackingSlip: boolean }>({
defaultValues: { generatePackingSlip: true },
});
const handleSubmit = form.handleSubmit(async (values) => {
await onSubmit(values.generatePackingSlip !== false);
form.reset({ generatePackingSlip: true });
onClose();
});
return (
<Modal opened={opened} onClose={onClose} title={t('action_process')}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text size="sm">{t('process_order_help')}</Text>
<FieldCheckbox control={form.control as any} name="generatePackingSlip" label={t('generate_packing_slip')} />
<Group justify="flex-end">
<Button variant="default" type="button" onClick={onClose}>
{t('common:cancel')}
</Button>
<Button type="submit">{t('action_process')}</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -178,7 +178,7 @@ describe('sales document mapper', () => {
}); });
}); });
it('includes parent ids on create and omits images when requested', () => { it('includes parent ids on create and omits images when requested', () => {
const payload = toSalesWritePayload( const payload = toSalesWritePayload(
{ {
date: '2026-08-26', date: '2026-08-26',
@@ -198,9 +198,9 @@ describe('sales document mapper', () => {
expect(payload.salesOrderId).toBe('so-1'); expect(payload.salesOrderId).toBe('so-1');
expect(payload.packingSlipId).toBe('ps-1'); expect(payload.packingSlipId).toBe('ps-1');
expect(payload).not.toHaveProperty('images'); expect(payload).not.toHaveProperty('images');
}); });
it('flattens sales order and packing slip in filters', () => { it('flattens sales order and packing slip in filters', () => {
expect( expect(
toSalesFilterPayload({ toSalesFilterPayload({
salesOrder: { id: 'so-1' }, salesOrder: { id: 'so-1' },
@@ -208,4 +208,4 @@ describe('sales document mapper', () => {
status: 'draft', status: 'draft',
}), }),
).toEqual({ salesOrderId: 'so-1', packingSlipId: 'ps-1', status: 'draft' }); ).toEqual({ salesOrderId: 'so-1', packingSlipId: 'ps-1', status: 'draft' });
}); });
@@ -99,6 +99,7 @@ export function toSalesWritePayload(
includeSalesOrderId?: boolean; includeSalesOrderId?: boolean;
includePackingSlipId?: boolean; includePackingSlipId?: boolean;
omitImages?: boolean; omitImages?: boolean;
omitStaffFields?: boolean;
}, },
): Record<string, unknown> { ): Record<string, unknown> {
const products = (entity.products ?? []) const products = (entity.products ?? [])
@@ -142,14 +143,18 @@ export function toSalesWritePayload(
} }
if (options?.includeSalesOrderId) { if (options?.includeSalesOrderId) {
payload.salesOrderId = payload.salesOrderId =
relationId((entity as { salesOrder?: unknown }).salesOrder) ?? relationId((entity as { salesOrder?: unknown }).salesOrder) ?? (entity as { salesOrderId?: string }).salesOrderId;
(entity as { salesOrderId?: string }).salesOrderId;
} }
if (options?.includePackingSlipId) { if (options?.includePackingSlipId) {
payload.packingSlipId = payload.packingSlipId =
relationId((entity as { packingSlip?: unknown }).packingSlip) ?? relationId((entity as { packingSlip?: unknown }).packingSlip) ??
(entity as { packingSlipId?: string }).packingSlipId; (entity as { packingSlipId?: string }).packingSlipId;
} }
if (options?.omitStaffFields) {
delete payload.salesPersonId;
delete payload.branchId;
delete payload.divisionId;
}
return payload; return payload;
} }
@@ -47,6 +47,17 @@ describe('SalesDocumentRemoteDataServices', () => {
); );
}); });
it('includes extra fields when processing a sales order', async () => {
await service.changeStatus('so-1', 'processed', { generatePackingSlip: false });
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/sales-requests/so-1/status',
method: 'PATCH',
data: { status: 'processed', generatePackingSlip: false },
}),
);
});
it('posts bulk status via /bulk-status', async () => { it('posts bulk status via /bulk-status', async () => {
await service.bulkChangeStatus(['sr-1', 'sr-2'], 'approved'); await service.bulkChangeStatus(['sr-1', 'sr-2'], 'approved');
expect(httpClient.request).toHaveBeenCalledWith( expect(httpClient.request).toHaveBeenCalledWith(
@@ -11,20 +11,20 @@ export class SalesDocumentRemoteDataServices<E extends BaseEntity> extends Track
this.resourceUrl = config.apiUrl ?? ''; this.resourceUrl = config.apiUrl ?? '';
} }
changeStatus(id: string, status: string) { changeStatus(id: string, status: string, extra?: Record<string, unknown>) {
return this.customRequest({ return this.customRequest({
url: `${this.resourceUrl}/${id}/status`, url: `${this.resourceUrl}/${id}/status`,
method: 'PATCH', method: 'PATCH',
data: { status }, data: { status, ...extra },
headers: { 'ex-module-action': REQUEST_ACTION.EDIT }, headers: { 'ex-module-action': REQUEST_ACTION.EDIT },
}); });
} }
bulkChangeStatus(ids: Array<string | number>, status: string) { bulkChangeStatus(ids: Array<string | number>, status: string, extra?: Record<string, unknown>) {
return this.customRequest({ return this.customRequest({
url: `${this.resourceUrl}/bulk-status`, url: `${this.resourceUrl}/bulk-status`,
method: 'POST', method: 'POST',
data: { ids, status }, data: { ids, status, ...extra },
headers: { 'ex-module-action': REQUEST_ACTION.EDIT }, headers: { 'ex-module-action': REQUEST_ACTION.EDIT },
}); });
} }
@@ -89,3 +89,9 @@ export function createSalesInvoiceSchema(t: (key: string) => string) {
packingSlip: relationSchema.nullable().optional(), packingSlip: relationSchema.nullable().optional(),
}); });
} }
export function createSalesPackingSlipSchema(t: (key: string) => string) {
return salesDocumentBaseSchema(t).omit({ salesPerson: true, branch: true, division: true, images: true }).extend({
salesOrder: relationSchema.nullable().optional(),
});
}
@@ -20,18 +20,29 @@ describe('sales status transitions', () => {
expect(commonTransitions('order', ['draft'])).toEqual(['processed', 'cancelled']); expect(commonTransitions('order', ['draft'])).toEqual(['processed', 'cancelled']);
}); });
it('allows invoice processed to partial, completed, or cancelled', () => { it('does not allow user complete on a processed sales order', () => {
expect(allowedTransitions('invoice', 'processed')).toEqual(['partial', 'completed', 'cancelled']); expect(allowedTransitions('order', 'processed')).toEqual(['cancelled']);
expect(allowedTransitions('invoice', 'partial')).toEqual(['completed', 'cancelled']); expect(namedActionsFor('order', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']);
expect(namedActionsFor('order', 'processed').map((action) => action.key)).toEqual(['cancel']);
}); });
it('returns named invoice complete only from processed', () => { it('limits invoice user transitions to cancelled', () => {
expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']); expect(allowedTransitions('invoice', 'processed')).toEqual(['cancelled']);
expect(namedActionsFor('invoice', 'partial')).toEqual([]); expect(allowedTransitions('invoice', 'partial')).toEqual(['cancelled']);
expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['cancel']);
}); });
it('reuses request transitions and named actions for payments', () => { it('adds payment rollback from pending to draft', () => {
expect(allowedTransitions('payment', 'draft')).toEqual(['pending', 'rejected']); expect(allowedTransitions('payment', 'draft')).toEqual(['pending', 'rejected']);
expect(namedActionsFor('payment', 'pending').map((action) => action.key)).toEqual(['approve', 'reject']); expect(namedActionsFor('payment', 'pending').map((action) => action.key)).toEqual([
'approve',
'reject',
'rollback',
]);
});
it('allows packing processed to completed or cancelled', () => {
expect(allowedTransitions('packing', 'processed')).toEqual(['completed', 'cancelled']);
expect(namedActionsFor('packing', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']);
}); });
}); });
@@ -4,13 +4,16 @@ export type SalesRequestStatus = (typeof SALES_REQUEST_STATUSES)[number];
export const SALES_ORDER_STATUSES = ['draft', 'processed', 'completed', 'cancelled'] as const; export const SALES_ORDER_STATUSES = ['draft', 'processed', 'completed', 'cancelled'] as const;
export type SalesOrderStatus = (typeof SALES_ORDER_STATUSES)[number]; export type SalesOrderStatus = (typeof SALES_ORDER_STATUSES)[number];
export const PACKING_SLIP_STATUSES = ['draft', 'processed', 'completed', 'cancelled'] as const;
export type PackingSlipStatus = (typeof PACKING_SLIP_STATUSES)[number];
export const SALES_INVOICE_STATUSES = ['draft', 'processed', 'partial', 'completed', 'cancelled'] as const; export const SALES_INVOICE_STATUSES = ['draft', 'processed', 'partial', 'completed', 'cancelled'] as const;
export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number]; export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number];
export const SALES_PAYMENT_STATUSES = SALES_REQUEST_STATUSES; export const SALES_PAYMENT_STATUSES = SALES_REQUEST_STATUSES;
export type SalesPaymentStatus = SalesRequestStatus; export type SalesPaymentStatus = SalesRequestStatus;
export type SalesDocumentType = 'request' | 'order' | 'invoice' | 'payment'; export type SalesDocumentType = 'request' | 'order' | 'invoice' | 'payment' | 'packing';
const REQUEST_TRANSITIONS: Record<SalesRequestStatus, SalesRequestStatus[]> = { const REQUEST_TRANSITIONS: Record<SalesRequestStatus, SalesRequestStatus[]> = {
draft: ['pending', 'rejected'], draft: ['pending', 'rejected'],
@@ -20,6 +23,13 @@ const REQUEST_TRANSITIONS: Record<SalesRequestStatus, SalesRequestStatus[]> = {
}; };
const ORDER_TRANSITIONS: Record<SalesOrderStatus, SalesOrderStatus[]> = { const ORDER_TRANSITIONS: Record<SalesOrderStatus, SalesOrderStatus[]> = {
draft: ['processed', 'cancelled'],
processed: ['cancelled'],
completed: [],
cancelled: [],
};
const PACKING_TRANSITIONS: Record<PackingSlipStatus, PackingSlipStatus[]> = {
draft: ['processed', 'cancelled'], draft: ['processed', 'cancelled'],
processed: ['completed', 'cancelled'], processed: ['completed', 'cancelled'],
completed: [], completed: [],
@@ -27,10 +37,10 @@ const ORDER_TRANSITIONS: Record<SalesOrderStatus, SalesOrderStatus[]> = {
}; };
const INVOICE_TRANSITIONS: Record<SalesInvoiceStatus, SalesInvoiceStatus[]> = { const INVOICE_TRANSITIONS: Record<SalesInvoiceStatus, SalesInvoiceStatus[]> = {
draft: ['processed', 'cancelled'], draft: ['cancelled'],
processed: ['partial', 'completed', 'cancelled'], processed: ['cancelled'],
partial: ['completed', 'cancelled'], partial: ['cancelled'],
completed: [], completed: ['cancelled'],
cancelled: [], cancelled: [],
}; };
@@ -42,21 +52,29 @@ export const SALES_REQUEST_NAMED_ACTIONS = [
export const SALES_ORDER_NAMED_ACTIONS = [ export const SALES_ORDER_NAMED_ACTIONS = [
{ key: 'process', target: 'processed' as const, from: ['draft'] as const }, { key: 'process', target: 'processed' as const, from: ['draft'] as const },
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
] as const;
export const PACKING_SLIP_NAMED_ACTIONS = [
{ key: 'complete', target: 'completed' as const, from: ['processed'] as const }, { key: 'complete', target: 'completed' as const, from: ['processed'] as const },
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const }, { key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
] as const; ] as const;
export const SALES_INVOICE_NAMED_ACTIONS = [ export const SALES_INVOICE_NAMED_ACTIONS = [
{ key: 'process', target: 'processed' as const, from: ['draft'] as const }, { key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed', 'partial'] 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; ] as const;
export const SALES_PAYMENT_NAMED_ACTIONS = SALES_REQUEST_NAMED_ACTIONS; export const SALES_PAYMENT_NAMED_ACTIONS = [
{ key: 'submit', target: 'pending' as const, from: ['draft'] as const },
{ key: 'approve', target: 'approved' as const, from: ['pending'] as const },
{ key: 'reject', target: 'rejected' as const, from: ['pending'] as const },
{ key: 'rollback', target: 'draft' as const, from: ['pending'] as const },
] as const;
export function documentStatuses(type: SalesDocumentType): readonly string[] { export function documentStatuses(type: SalesDocumentType): readonly string[] {
if (type === 'request' || type === 'payment') return SALES_REQUEST_STATUSES; if (type === 'request' || type === 'payment') return SALES_REQUEST_STATUSES;
if (type === 'invoice') return SALES_INVOICE_STATUSES; if (type === 'invoice') return SALES_INVOICE_STATUSES;
if (type === 'packing') return PACKING_SLIP_STATUSES;
return SALES_ORDER_STATUSES; return SALES_ORDER_STATUSES;
} }
@@ -68,6 +86,9 @@ export function allowedTransitions(type: SalesDocumentType, current?: string | n
if (type === 'invoice') { if (type === 'invoice') {
return INVOICE_TRANSITIONS[current as SalesInvoiceStatus] ?? []; return INVOICE_TRANSITIONS[current as SalesInvoiceStatus] ?? [];
} }
if (type === 'packing') {
return PACKING_TRANSITIONS[current as PackingSlipStatus] ?? [];
}
return ORDER_TRANSITIONS[current as SalesOrderStatus] ?? []; return ORDER_TRANSITIONS[current as SalesOrderStatus] ?? [];
} }
@@ -80,10 +101,14 @@ export function commonTransitions(type: SalesDocumentType, statuses: Array<strin
export function namedActionsFor(type: SalesDocumentType, current?: string | null) { export function namedActionsFor(type: SalesDocumentType, current?: string | null) {
const actions = const actions =
type === 'request' || type === 'payment' type === 'request'
? SALES_REQUEST_NAMED_ACTIONS ? SALES_REQUEST_NAMED_ACTIONS
: type === 'payment'
? SALES_PAYMENT_NAMED_ACTIONS
: type === 'invoice' : type === 'invoice'
? SALES_INVOICE_NAMED_ACTIONS ? SALES_INVOICE_NAMED_ACTIONS
: type === 'packing'
? PACKING_SLIP_NAMED_ACTIONS
: SALES_ORDER_NAMED_ACTIONS; : SALES_ORDER_NAMED_ACTIONS;
return actions.filter((action) => current && (action.from as readonly string[]).includes(current)); return actions.filter((action) => current && (action.from as readonly string[]).includes(current));
} }
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, Receipt, CreditCard } from 'lucide-react'; import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, Receipt, CreditCard, Undo2 } from 'lucide-react';
import { notifications } from '@repo/ui/components'; import { notifications } from '@repo/ui/components';
import { import {
useDetailPageContext, useDetailPageContext,
@@ -9,11 +9,13 @@ import {
} from '@repo/ui/foundations'; } from '@repo/ui/foundations';
import { namedActionsFor, type SalesDocumentType } from './sales-status'; import { namedActionsFor, type SalesDocumentType } from './sales-status';
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service'; import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
import type { SalesDocumentEntity } from './sales-document.entity'; import type { SalesDocumentEntity, SalesLineEntity } from './sales-document.entity';
type SalesActionEntity = { id?: string | number; status?: string }; type SalesActionEntity = { id?: string | number; status?: string; products?: SalesLineEntity[] };
import { ChangeStatusModal } from './change-status-modal'; import { ChangeStatusModal } from './change-status-modal';
import { ImportCsvModal } from './import-csv-modal'; import { ImportCsvModal } from './import-csv-modal';
import { ProcessOrderModal } from './process-order-modal';
import { CompletePackingModal } from './complete-packing-modal';
const ACTION_ICONS: Record<string, typeof Check> = { const ACTION_ICONS: Record<string, typeof Check> = {
submit: Play, submit: Play,
@@ -22,6 +24,7 @@ const ACTION_ICONS: Record<string, typeof Check> = {
process: Play, process: Play,
complete: Check, complete: Check,
cancel: Ban, cancel: Ban,
rollback: Undo2,
}; };
export function useSalesDocumentActions(documentType: SalesDocumentType) { export function useSalesDocumentActions(documentType: SalesDocumentType) {
@@ -33,19 +36,41 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
>(); >();
const [statusOpened, setStatusOpened] = useState(false); const [statusOpened, setStatusOpened] = useState(false);
const [importOpened, setImportOpened] = useState(false); const [importOpened, setImportOpened] = useState(false);
const [processOpened, setProcessOpened] = useState(false);
const [completeOpened, setCompleteOpened] = useState(false);
const [pendingIds, setPendingIds] = useState<string[]>([]); const [pendingIds, setPendingIds] = useState<string[]>([]);
const [pendingStatus, setPendingStatus] = useState<string | undefined>();
const [completeProducts, setCompleteProducts] = useState<SalesLineEntity[]>([]);
const canEdit = privileges.ALLOW_EDIT; const canEdit = privileges.ALLOW_EDIT;
const canImport = privileges.ALLOW_IMPORT; const canImport = privileges.ALLOW_IMPORT;
const applyStatus = async (ids: string[], status: string) => { const applyStatus = async (ids: string[], status: string, extra?: Record<string, unknown>) => {
if (ids.length === 1) { if (ids.length === 1) {
await dataServices.changeStatus(ids[0], status); await dataServices.changeStatus(ids[0], status, extra);
} else { } else {
await dataServices.bulkChangeStatus(ids, status); await dataServices.bulkChangeStatus(ids, status, extra);
} }
notifications.show({ color: 'green', message: t('status_updated') }); notifications.show({ color: 'green', message: t('status_updated') });
}; };
const runNamedAction = async (ids: string[], actionKey: string, target: string, data?: SalesActionEntity) => {
if (documentType === 'order' && actionKey === 'process') {
setPendingIds(ids);
setProcessOpened(true);
return;
}
if (documentType === 'packing' && actionKey === 'complete') {
const source = data?.products?.length
? data
: ((await dataServices.getOne(ids[0])).data as { data?: SalesActionEntity })?.data;
setCompleteProducts(source?.products ?? []);
setPendingIds(ids);
setCompleteOpened(true);
return;
}
await applyStatus(ids, target);
};
const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => { const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => {
if (!canEdit) return defaultActions; if (!canEdit) return defaultActions;
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => { const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
@@ -55,19 +80,22 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
label: t(`action_${action.key}`), label: t(`action_${action.key}`),
icon: <Icon size={15} />, icon: <Icon size={15} />,
onClick: () => { onClick: () => {
void applyStatus([String(data.id)], action.target); void runNamedAction([String(data.id)], action.key, action.target, data);
}, },
}; };
}); });
if (documentType !== 'invoice') {
extras.push({ extras.push({
key: 'change-status', key: 'change-status',
label: t('change_status'), label: t('change_status'),
icon: <Play size={15} />, icon: <Play size={15} />,
onClick: () => { onClick: () => {
setPendingIds([String(data.id)]); setPendingIds([String(data.id)]);
setPendingStatus(data.status);
setStatusOpened(true); setStatusOpened(true);
}, },
}); });
}
return [...extras, ...defaultActions]; return [...extras, ...defaultActions];
}; };
@@ -84,13 +112,16 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
icon: <Icon size={16} />, icon: <Icon size={16} />,
variant: 'light' as const, variant: 'light' as const,
onClick: () => { onClick: () => {
void applyStatus( void runNamedAction(
selectedRows.map((row) => String(row.id)), selectedRows.map((row) => String(row.id)),
action.key,
action.target, action.target,
selectedRows[0],
); );
}, },
}; };
}); });
if (documentType !== 'invoice') {
extras.push({ extras.push({
key: 'bulk-change-status', key: 'bulk-change-status',
label: t('change_status'), label: t('change_status'),
@@ -98,9 +129,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
variant: 'light' as const, variant: 'light' as const,
onClick: () => { onClick: () => {
setPendingIds(selectedRows.map((row) => String(row.id))); setPendingIds(selectedRows.map((row) => String(row.id)));
setPendingStatus(selectedRows[0]?.status);
setStatusOpened(true); setStatusOpened(true);
}, },
}); });
}
return [...extras, ...defaultActions]; return [...extras, ...defaultActions];
}; };
@@ -163,10 +196,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
intent: 'primary' as const, intent: 'primary' as const,
variant: 'light' as const, variant: 'light' as const,
onClick: () => { onClick: () => {
void applyStatus([String(data.id)], action.target); void runNamedAction([String(data.id)], action.key, action.target, data);
}, },
}; };
}); });
if (documentType !== 'invoice') {
extras.push({ extras.push({
key: 'change-status', key: 'change-status',
label: t('change_status'), label: t('change_status'),
@@ -175,9 +209,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
variant: 'light' as const, variant: 'light' as const,
onClick: () => { onClick: () => {
setPendingIds([String(data.id)]); setPendingIds([String(data.id)]);
setPendingStatus(data.status);
setStatusOpened(true); setStatusOpened(true);
}, },
}); });
}
return [...extras, ...defaultActions]; return [...extras, ...defaultActions];
}; };
@@ -187,10 +223,26 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
opened={statusOpened} opened={statusOpened}
onClose={() => setStatusOpened(false)} onClose={() => setStatusOpened(false)}
documentType={documentType} documentType={documentType}
currentStatus={pendingStatus}
onSubmit={async (status) => { onSubmit={async (status) => {
await applyStatus(pendingIds, status); await applyStatus(pendingIds, status);
}} }}
/> />
<ProcessOrderModal
opened={processOpened}
onClose={() => setProcessOpened(false)}
onSubmit={async (generatePackingSlip) => {
await applyStatus(pendingIds, 'processed', { generatePackingSlip });
}}
/>
<CompletePackingModal
opened={completeOpened}
onClose={() => setCompleteOpened(false)}
products={completeProducts}
onSubmit={async (products) => {
await applyStatus(pendingIds, 'completed', { products });
}}
/>
<ImportCsvModal <ImportCsvModal
opened={importOpened} opened={importOpened}
onClose={() => setImportOpened(false)} onClose={() => setImportOpened(false)}
@@ -94,11 +94,7 @@ describe('filterMenuByViewPrivilege', () => {
}, },
]; ];
const filtered = filterMenuByViewPrivilege( const filtered = filterMenuByViewPrivilege(menu, { 'SALES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } }, false);
menu,
{ 'SALES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } },
false,
);
expect(filtered.find((item) => item.key === 'sales')?.children?.map((child) => child.key)).toEqual([ expect(filtered.find((item) => item.key === 'sales')?.children?.map((child) => child.key)).toEqual([
'orders', 'orders',
@@ -1,8 +1,10 @@
import type { PrivilegeEntity } from '@repo/ui/foundations'; import type { PrivilegeEntity } from '@repo/ui/foundations';
export function filterMenuByViewPrivilege< export function filterMenuByViewPrivilege<T extends { moduleKey?: string; children?: T[]; isPlaceholder?: boolean }>(
T extends { moduleKey?: string; children?: T[]; isPlaceholder?: boolean }, items: T[],
>(items: T[], privileges: Record<string, PrivilegeEntity>, isSuperadmin: boolean): T[] { privileges: Record<string, PrivilegeEntity>,
isSuperadmin: boolean,
): T[] {
if (isSuperadmin) { if (isSuperadmin) {
return items; return items;
} }