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:
@@ -6,7 +6,8 @@ import type { MenuItemType } from '../types/menu.types';
|
||||
|
||||
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[] =>
|
||||
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
|
||||
@@ -58,7 +59,10 @@ describe('MENU_ITEMS', () => {
|
||||
'configuration-customers',
|
||||
'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', () => {
|
||||
|
||||
@@ -151,7 +151,7 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
key: 'logistics-packing-slips',
|
||||
label: 'nav:logistics-packing-slips',
|
||||
icon: Package,
|
||||
path: '/app/logistics/packing-slips',
|
||||
path: '/app/logistics/packing-slips/index',
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EmbeddedComingSoonPage } from '../../../../../core/components/coming-so
|
||||
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
|
||||
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
|
||||
const PlansModule = lazy(() => import('../plans/presentation/factory'));
|
||||
const PackingSlipsModule = lazy(() => import('../packing-slips/presentation/factory'));
|
||||
|
||||
export default function LogisticsFieldModule() {
|
||||
return (
|
||||
@@ -12,7 +13,7 @@ export default function LogisticsFieldModule() {
|
||||
<Route path="/employees/*" element={<EmployeesModule purpose="logistics" />} />
|
||||
<Route path="/cycles/*" element={<CyclesModule 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="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
|
||||
+13
@@ -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';
|
||||
+11
@@ -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';
|
||||
+13
@@ -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,
|
||||
});
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+64
@@ -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>
|
||||
);
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+38
@@ -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>
|
||||
);
|
||||
}
|
||||
+61
@@ -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>
|
||||
);
|
||||
}
|
||||
+72
@@ -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 }),
|
||||
}));
|
||||
+15
@@ -125,6 +125,21 @@ export function DetailGeneral() {
|
||||
</Button>
|
||||
</Group>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"add_destination": "Add destination",
|
||||
"remove_destination": "Remove destination",
|
||||
"empty_route": "No route geometry",
|
||||
"section_attachments": "Attachments",
|
||||
"purpose_sales": "Sales",
|
||||
"purpose_logistics": "Logistics",
|
||||
"status_draft": "Draft",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"add_destination": "Tambah destinasi",
|
||||
"remove_destination": "Hapus destinasi",
|
||||
"empty_route": "Tidak ada geometri rute",
|
||||
"section_attachments": "Lampiran",
|
||||
"purpose_sales": "Penjualan",
|
||||
"purpose_logistics": "Logistik",
|
||||
"status_draft": "Draft",
|
||||
|
||||
-2
@@ -23,8 +23,6 @@
|
||||
"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",
|
||||
|
||||
-2
@@ -23,8 +23,6 @@
|
||||
"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",
|
||||
|
||||
+1
-4
@@ -35,10 +35,7 @@ export default function SalesInvoicePageDetail() {
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DetailGeneral
|
||||
salesOrderHref={(id) => `${salesOrdersModuleConfig.webUrl}/detail/${id}`}
|
||||
showBalance
|
||||
/>
|
||||
<DetailGeneral salesOrderHref={(id) => `${salesOrdersModuleConfig.webUrl}/detail/${id}`} showBalance />
|
||||
<DetailLocation />
|
||||
<DetailProducts />
|
||||
</Stack>
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { SalesDocumentDto, SalesDocumentEntity } from '../../../shared/sale
|
||||
export interface SalesOrderEntity extends SalesDocumentEntity {
|
||||
salesRequestId?: string | null;
|
||||
salesRequest?: { id: string; code?: string; name?: string } | null;
|
||||
packingSlipIds?: string[];
|
||||
invoiceIds?: string[];
|
||||
}
|
||||
|
||||
export interface SalesOrderDto extends SalesDocumentDto {
|
||||
salesRequestId?: string | null;
|
||||
salesRequest?: { id: string; code?: string; name?: string } | null;
|
||||
packingSlipIds?: string[];
|
||||
invoiceIds?: string[];
|
||||
}
|
||||
|
||||
+2
@@ -18,6 +18,8 @@ export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer<SalesO
|
||||
...base,
|
||||
salesRequestId: relationId(orderDto.salesRequest) ?? orderDto.salesRequestId ?? null,
|
||||
salesRequest,
|
||||
packingSlipIds: orderDto.packingSlipIds ?? [],
|
||||
invoiceIds: orderDto.invoiceIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+50
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -28,8 +28,10 @@
|
||||
"status_updated": "Status updated.",
|
||||
"create_sales_invoice": "Create Sales Invoice",
|
||||
"action_process": "Process",
|
||||
"action_complete": "Complete",
|
||||
"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_processed": "Processed",
|
||||
"status_completed": "Completed",
|
||||
|
||||
+3
-1
@@ -28,8 +28,10 @@
|
||||
"status_updated": "Status diperbarui.",
|
||||
"create_sales_invoice": "Buat Faktur Penjualan",
|
||||
"action_process": "Proses",
|
||||
"action_complete": "Selesaikan",
|
||||
"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_processed": "Diproses",
|
||||
"status_completed": "Selesai",
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ import { DetailGeneral } from '../../../shared/detail-general';
|
||||
import { DetailLocation } from '../../../shared/detail-location';
|
||||
import { DetailProducts } from '../../../shared/detail-products';
|
||||
import { DetailImages } from '../../../shared/detail-images';
|
||||
import { DetailRelated } from '../components/detail-component/detail-related';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
|
||||
import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants';
|
||||
@@ -39,6 +40,7 @@ export default function SalesOrderPageDetail() {
|
||||
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
|
||||
<DetailLocation />
|
||||
<DetailProducts />
|
||||
<DetailRelated />
|
||||
<DetailImages />
|
||||
</Stack>
|
||||
{actions.modals}
|
||||
|
||||
+12
-7
@@ -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 { useFieldArray, useWatch } from '@repo/ui/form';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
@@ -66,12 +76,7 @@ export function FormAllocations() {
|
||||
</Table>
|
||||
</Box>
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={() => append({ amount: '' })}
|
||||
>
|
||||
<Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={() => append({ amount: '' })}>
|
||||
{t('add_allocation')}
|
||||
</Button>
|
||||
{fields.length === 0 ? (
|
||||
|
||||
+1
-6
@@ -12,12 +12,7 @@ export function FormPaymentGeneral() {
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="code"
|
||||
label={t('common:fields.code')}
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput control={formControl.control} name="code" label={t('common:fields.code')} radius="md" />
|
||||
<FieldDatePicker
|
||||
control={formControl.control}
|
||||
name="date"
|
||||
|
||||
+1
@@ -27,6 +27,7 @@
|
||||
"action_submit": "Submit",
|
||||
"action_approve": "Approve",
|
||||
"action_reject": "Reject",
|
||||
"action_rollback": "Rollback",
|
||||
"status_draft": "Draft",
|
||||
"status_pending": "Pending",
|
||||
"status_approved": "Approved",
|
||||
|
||||
+1
@@ -27,6 +27,7 @@
|
||||
"action_submit": "Kirim",
|
||||
"action_approve": "Setujui",
|
||||
"action_reject": "Tolak",
|
||||
"action_rollback": "Kembalikan",
|
||||
"status_draft": "Draft",
|
||||
"status_pending": "Menunggu",
|
||||
"status_approved": "Disetujui",
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { Button, FieldSelect, Group, Modal, Stack } from '@repo/ui/components';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { documentStatuses, type SalesDocumentType } from './sales-status';
|
||||
import { allowedTransitions, type SalesDocumentType } from './sales-status';
|
||||
|
||||
export function ChangeStatusModal({
|
||||
opened,
|
||||
onClose,
|
||||
documentType,
|
||||
currentStatus,
|
||||
onSubmit,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
documentType: SalesDocumentType;
|
||||
currentStatus?: string | null;
|
||||
onSubmit: (status: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const form = useForm<{ status: string }>();
|
||||
const options = allowedTransitions(documentType, currentStatus);
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await onSubmit(values.status);
|
||||
@@ -32,7 +35,7 @@ export function ChangeStatusModal({
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
required
|
||||
data={documentStatuses(documentType).map((value) => ({ value, label: t(`status_${value}`) }))}
|
||||
data={options.map((value) => ({ value, label: t(`status_${value}`) }))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<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}
|
||||
render={() =>
|
||||
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}
|
||||
render={() =>
|
||||
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(
|
||||
{
|
||||
date: '2026-08-26',
|
||||
@@ -198,9 +198,9 @@ describe('sales document mapper', () => {
|
||||
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', () => {
|
||||
it('flattens sales order and packing slip in filters', () => {
|
||||
expect(
|
||||
toSalesFilterPayload({
|
||||
salesOrder: { id: 'so-1' },
|
||||
@@ -208,4 +208,4 @@ describe('sales document mapper', () => {
|
||||
status: 'draft',
|
||||
}),
|
||||
).toEqual({ salesOrderId: 'so-1', packingSlipId: 'ps-1', status: 'draft' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,7 @@ export function toSalesWritePayload(
|
||||
includeSalesOrderId?: boolean;
|
||||
includePackingSlipId?: boolean;
|
||||
omitImages?: boolean;
|
||||
omitStaffFields?: boolean;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
const products = (entity.products ?? [])
|
||||
@@ -142,14 +143,18 @@ export function toSalesWritePayload(
|
||||
}
|
||||
if (options?.includeSalesOrderId) {
|
||||
payload.salesOrderId =
|
||||
relationId((entity as { salesOrder?: unknown }).salesOrder) ??
|
||||
(entity as { salesOrderId?: string }).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;
|
||||
}
|
||||
if (options?.omitStaffFields) {
|
||||
delete payload.salesPersonId;
|
||||
delete payload.branchId;
|
||||
delete payload.divisionId;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
await service.bulkChangeStatus(['sr-1', 'sr-2'], 'approved');
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
|
||||
@@ -11,20 +11,20 @@ export class SalesDocumentRemoteDataServices<E extends BaseEntity> extends Track
|
||||
this.resourceUrl = config.apiUrl ?? '';
|
||||
}
|
||||
|
||||
changeStatus(id: string, status: string) {
|
||||
changeStatus(id: string, status: string, extra?: Record<string, unknown>) {
|
||||
return this.customRequest({
|
||||
url: `${this.resourceUrl}/${id}/status`,
|
||||
method: 'PATCH',
|
||||
data: { status },
|
||||
data: { status, ...extra },
|
||||
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({
|
||||
url: `${this.resourceUrl}/bulk-status`,
|
||||
method: 'POST',
|
||||
data: { ids, status },
|
||||
data: { ids, status, ...extra },
|
||||
headers: { 'ex-module-action': REQUEST_ACTION.EDIT },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,3 +89,9 @@ export function createSalesInvoiceSchema(t: (key: string) => string) {
|
||||
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']);
|
||||
});
|
||||
|
||||
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('does not allow user complete on a processed sales order', () => {
|
||||
expect(allowedTransitions('order', 'processed')).toEqual(['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', () => {
|
||||
expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']);
|
||||
expect(namedActionsFor('invoice', 'partial')).toEqual([]);
|
||||
it('limits invoice user transitions to cancelled', () => {
|
||||
expect(allowedTransitions('invoice', 'processed')).toEqual(['cancelled']);
|
||||
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(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 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 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';
|
||||
export type SalesDocumentType = 'request' | 'order' | 'invoice' | 'payment' | 'packing';
|
||||
|
||||
const REQUEST_TRANSITIONS: Record<SalesRequestStatus, SalesRequestStatus[]> = {
|
||||
draft: ['pending', 'rejected'],
|
||||
@@ -20,6 +23,13 @@ const REQUEST_TRANSITIONS: Record<SalesRequestStatus, SalesRequestStatus[]> = {
|
||||
};
|
||||
|
||||
const ORDER_TRANSITIONS: Record<SalesOrderStatus, SalesOrderStatus[]> = {
|
||||
draft: ['processed', 'cancelled'],
|
||||
processed: ['cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
const PACKING_TRANSITIONS: Record<PackingSlipStatus, PackingSlipStatus[]> = {
|
||||
draft: ['processed', 'cancelled'],
|
||||
processed: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
@@ -27,10 +37,10 @@ const ORDER_TRANSITIONS: Record<SalesOrderStatus, SalesOrderStatus[]> = {
|
||||
};
|
||||
|
||||
const INVOICE_TRANSITIONS: Record<SalesInvoiceStatus, SalesInvoiceStatus[]> = {
|
||||
draft: ['processed', 'cancelled'],
|
||||
processed: ['partial', 'completed', 'cancelled'],
|
||||
partial: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
draft: ['cancelled'],
|
||||
processed: ['cancelled'],
|
||||
partial: ['cancelled'],
|
||||
completed: ['cancelled'],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
@@ -42,21 +52,29 @@ export const SALES_REQUEST_NAMED_ACTIONS = [
|
||||
|
||||
export const SALES_ORDER_NAMED_ACTIONS = [
|
||||
{ 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: '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 },
|
||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed', 'partial'] 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[] {
|
||||
if (type === 'request' || type === 'payment') return SALES_REQUEST_STATUSES;
|
||||
if (type === 'invoice') return SALES_INVOICE_STATUSES;
|
||||
if (type === 'packing') return PACKING_SLIP_STATUSES;
|
||||
return SALES_ORDER_STATUSES;
|
||||
}
|
||||
|
||||
@@ -68,6 +86,9 @@ export function allowedTransitions(type: SalesDocumentType, current?: string | n
|
||||
if (type === 'invoice') {
|
||||
return INVOICE_TRANSITIONS[current as SalesInvoiceStatus] ?? [];
|
||||
}
|
||||
if (type === 'packing') {
|
||||
return PACKING_TRANSITIONS[current as PackingSlipStatus] ?? [];
|
||||
}
|
||||
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) {
|
||||
const actions =
|
||||
type === 'request' || type === 'payment'
|
||||
type === 'request'
|
||||
? SALES_REQUEST_NAMED_ACTIONS
|
||||
: type === 'payment'
|
||||
? SALES_PAYMENT_NAMED_ACTIONS
|
||||
: type === 'invoice'
|
||||
? SALES_INVOICE_NAMED_ACTIONS
|
||||
: type === 'packing'
|
||||
? PACKING_SLIP_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, 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 {
|
||||
useDetailPageContext,
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
} from '@repo/ui/foundations';
|
||||
import { namedActionsFor, type SalesDocumentType } from './sales-status';
|
||||
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
import 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 { ImportCsvModal } from './import-csv-modal';
|
||||
import { ProcessOrderModal } from './process-order-modal';
|
||||
import { CompletePackingModal } from './complete-packing-modal';
|
||||
|
||||
const ACTION_ICONS: Record<string, typeof Check> = {
|
||||
submit: Play,
|
||||
@@ -22,6 +24,7 @@ const ACTION_ICONS: Record<string, typeof Check> = {
|
||||
process: Play,
|
||||
complete: Check,
|
||||
cancel: Ban,
|
||||
rollback: Undo2,
|
||||
};
|
||||
|
||||
export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
@@ -33,19 +36,41 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
>();
|
||||
const [statusOpened, setStatusOpened] = useState(false);
|
||||
const [importOpened, setImportOpened] = useState(false);
|
||||
const [processOpened, setProcessOpened] = useState(false);
|
||||
const [completeOpened, setCompleteOpened] = useState(false);
|
||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||
const [pendingStatus, setPendingStatus] = useState<string | undefined>();
|
||||
const [completeProducts, setCompleteProducts] = useState<SalesLineEntity[]>([]);
|
||||
const canEdit = privileges.ALLOW_EDIT;
|
||||
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) {
|
||||
await dataServices.changeStatus(ids[0], status);
|
||||
await dataServices.changeStatus(ids[0], status, extra);
|
||||
} else {
|
||||
await dataServices.bulkChangeStatus(ids, status);
|
||||
await dataServices.bulkChangeStatus(ids, status, extra);
|
||||
}
|
||||
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[]) => {
|
||||
if (!canEdit) return defaultActions;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
@@ -55,19 +80,22 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
label: t(`action_${action.key}`),
|
||||
icon: <Icon size={15} />,
|
||||
onClick: () => {
|
||||
void applyStatus([String(data.id)], action.target);
|
||||
void runNamedAction([String(data.id)], action.key, action.target, data);
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={15} />,
|
||||
onClick: () => {
|
||||
setPendingIds([String(data.id)]);
|
||||
setPendingStatus(data.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
};
|
||||
|
||||
@@ -84,13 +112,16 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
icon: <Icon size={16} />,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
void applyStatus(
|
||||
void runNamedAction(
|
||||
selectedRows.map((row) => String(row.id)),
|
||||
action.key,
|
||||
action.target,
|
||||
selectedRows[0],
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'bulk-change-status',
|
||||
label: t('change_status'),
|
||||
@@ -98,9 +129,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
setPendingIds(selectedRows.map((row) => String(row.id)));
|
||||
setPendingStatus(selectedRows[0]?.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
};
|
||||
|
||||
@@ -163,10 +196,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
void applyStatus([String(data.id)], action.target);
|
||||
void runNamedAction([String(data.id)], action.key, action.target, data);
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'change-status',
|
||||
label: t('change_status'),
|
||||
@@ -175,9 +209,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
setPendingIds([String(data.id)]);
|
||||
setPendingStatus(data.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
};
|
||||
|
||||
@@ -187,10 +223,26 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
opened={statusOpened}
|
||||
onClose={() => setStatusOpened(false)}
|
||||
documentType={documentType}
|
||||
currentStatus={pendingStatus}
|
||||
onSubmit={async (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
|
||||
opened={importOpened}
|
||||
onClose={() => setImportOpened(false)}
|
||||
|
||||
@@ -94,11 +94,7 @@ describe('filterMenuByViewPrivilege', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = filterMenuByViewPrivilege(
|
||||
menu,
|
||||
{ 'SALES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } },
|
||||
false,
|
||||
);
|
||||
const filtered = filterMenuByViewPrivilege(menu, { 'SALES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } }, false);
|
||||
|
||||
expect(filtered.find((item) => item.key === 'sales')?.children?.map((child) => child.key)).toEqual([
|
||||
'orders',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||
|
||||
export function filterMenuByViewPrivilege<
|
||||
T extends { moduleKey?: string; children?: T[]; isPlaceholder?: boolean },
|
||||
>(items: T[], privileges: Record<string, PrivilegeEntity>, isSuperadmin: boolean): T[] {
|
||||
export function filterMenuByViewPrivilege<T extends { moduleKey?: string; children?: T[]; isPlaceholder?: boolean }>(
|
||||
items: T[],
|
||||
privileges: Record<string, PrivilegeEntity>,
|
||||
isSuperadmin: boolean,
|
||||
): T[] {
|
||||
if (isSuperadmin) {
|
||||
return items;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user