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 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>
|
||||||
|
|||||||
+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>
|
</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",
|
||||||
|
|||||||
-2
@@ -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",
|
||||||
|
|||||||
-2
@@ -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",
|
||||||
|
|||||||
+1
-4
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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 ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.",
|
"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",
|
||||||
|
|||||||
+3
-1
@@ -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",
|
||||||
|
|||||||
+2
@@ -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}
|
||||||
|
|||||||
+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 { 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 ? (
|
||||||
|
|||||||
+1
-6
@@ -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"
|
||||||
|
|||||||
+1
@@ -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",
|
||||||
|
|||||||
+1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user