diff --git a/apps/web/src/apps/main/layouts/data/menu.data.test.ts b/apps/web/src/apps/main/layouts/data/menu.data.test.ts index 1f7aaef..e39e4c4 100644 --- a/apps/web/src/apps/main/layouts/data/menu.data.test.ts +++ b/apps/web/src/apps/main/layouts/data/menu.data.test.ts @@ -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', () => { diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts index c0e7dd0..57c0059 100644 --- a/apps/web/src/apps/main/layouts/data/menu.data.ts +++ b/apps/web/src/apps/main/layouts/data/menu.data.ts @@ -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', }, { diff --git a/apps/web/src/apps/main/modules/field/logistics/index.tsx b/apps/web/src/apps/main/modules/field/logistics/index.tsx index 8846bcd..2a6f9be 100644 --- a/apps/web/src/apps/main/modules/field/logistics/index.tsx +++ b/apps/web/src/apps/main/modules/field/logistics/index.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/apps/web/src/apps/main/modules/field/packing-slips/data/packing-slip.remote.service.ts b/apps/web/src/apps/main/modules/field/packing-slips/data/packing-slip.remote.service.ts new file mode 100644 index 0000000..8b9db0f --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/data/packing-slip.remote.service.ts @@ -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 { + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { + super(httpClient, { + ...config, + apiUrl: config.apiUrl ?? '/packing-slips', + }); + } +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/domain/constants/index.ts b/apps/web/src/apps/main/modules/field/packing-slips/domain/constants/index.ts new file mode 100644 index 0000000..c3e7456 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './packing-slip.constants'; diff --git a/apps/web/src/apps/main/modules/field/packing-slips/domain/constants/packing-slip.constants.ts b/apps/web/src/apps/main/modules/field/packing-slips/domain/constants/packing-slip.constants.ts new file mode 100644 index 0000000..60a27aa --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/domain/constants/packing-slip.constants.ts @@ -0,0 +1,11 @@ +import { ModuleConfigEntity } from '@repo/ui/foundations'; +import type { PackingSlipEntity } from '../entities'; + +export const packingSlipsModuleConfig: ModuleConfigEntity = { + moduleKey: 'SALES.PACKING_SLIP', + translationNamespace: 'PACKING_SLIPS', + apiUrl: '/packing-slips', + webUrl: '/app/logistics/packing-slips', + moduleCategory: 'FULL_PAGE', + moduleType: 'TRANSACTION', +} as const; diff --git a/apps/web/src/apps/main/modules/field/packing-slips/domain/entities/index.ts b/apps/web/src/apps/main/modules/field/packing-slips/domain/entities/index.ts new file mode 100644 index 0000000..c920273 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './packing-slip.entity'; diff --git a/apps/web/src/apps/main/modules/field/packing-slips/domain/entities/packing-slip.entity.ts b/apps/web/src/apps/main/modules/field/packing-slips/domain/entities/packing-slip.entity.ts new file mode 100644 index 0000000..cd81c4e --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/domain/entities/packing-slip.entity.ts @@ -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; +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/domain/factories/index.ts b/apps/web/src/apps/main/modules/field/packing-slips/domain/factories/index.ts new file mode 100644 index 0000000..86061e6 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/domain/factories/index.ts @@ -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, +}); diff --git a/apps/web/src/apps/main/modules/field/packing-slips/domain/transformers/packing-slip.remote.transformer.ts b/apps/web/src/apps/main/modules/field/packing-slips/domain/transformers/packing-slip.remote.transformer.ts new file mode 100644 index 0000000..32bec78 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/domain/transformers/packing-slip.remote.transformer.ts @@ -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 { + 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): Partial { + return omitEmptyFields( + toSalesWritePayload(entity, { + includeSalesOrderId: true, + omitImages: true, + omitStaffFields: true, + }), + ) as Partial; + } + + transformEditPayload(entity: Partial): Partial { + return toSalesWritePayload(entity, { + omitImages: true, + omitStaffFields: true, + }) as Partial; + } + + transformPayloadFilter(filter: Record): Record { + return toSalesFilterPayload(filter); + } +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/presentation/components/form-component/form-packing-general.tsx b/apps/web/src/apps/main/modules/field/packing-slips/presentation/components/form-component/form-packing-general.tsx new file mode 100644 index 0000000..19f9709 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/presentation/components/form-component/form-packing-general.tsx @@ -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 ( + + + {t('section_general')} + + + + + + + control={formControl.control} + name="customer" + label={t('common:fields.customer')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadCustomerOptions} + defaultOptions={customer ? [customer] : []} + renderLabel={relationLabel} + /> + + control={formControl.control} + name="salesOrder" + label={t('common:fields.salesOrder')} + valueKey="id" + labelKey="code" + searchable + clearable + loadOptions={loadSalesOrderOptions} + defaultOptions={salesOrder ? [salesOrder] : []} + renderLabel={relationLabel} + /> + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/field/packing-slips/presentation/factory/index.tsx new file mode 100644 index 0000000..123ab49 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/presentation/factory/index.tsx @@ -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 ( + + config={packingSlipsModuleConfig} + dataServices={packingSlipsModuleDataService} + store={packingSlipsStore} + > + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/presentation/languages/en/packing-slips.json b/apps/web/src/apps/main/modules/field/packing-slips/presentation/languages/en/packing-slips.json new file mode 100644 index 0000000..0dc64ac --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/presentation/languages/en/packing-slips.json @@ -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 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" +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/presentation/languages/id/packing-slips.json b/apps/web/src/apps/main/modules/field/packing-slips/presentation/languages/id/packing-slips.json new file mode 100644 index 0000000..33eb84b --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/presentation/languages/id/packing-slips.json @@ -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 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" +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.detail.tsx b/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.detail.tsx new file mode 100644 index 0000000..42ce0b8 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.detail.tsx @@ -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 ( + + actions.detailStatusActions(data as PackingSlipEntity, pageActions ?? []) + } + > + + `${salesOrdersModuleConfig.webUrl}/detail/${id}`} /> + + + + {actions.modals} + + ); +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.form.tsx b/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.form.tsx new file mode 100644 index 0000000..555938c --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.form.tsx @@ -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 ( + + + + + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.index.tsx b/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.index.tsx new file mode 100644 index 0000000..eed2826 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/presentation/pages/packing-slip.page.index.tsx @@ -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[] = 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 ? : null), + }), + [t], + ); + + return ( + }} /> + ), + 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 + } + > + actions.namedRowActions(data, defaultActions)} + customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)} + /> + {actions.modals} + + ); +} diff --git a/apps/web/src/apps/main/modules/field/packing-slips/presentation/store/index.ts b/apps/web/src/apps/main/modules/field/packing-slips/presentation/store/index.ts new file mode 100644 index 0000000..25a4c7b --- /dev/null +++ b/apps/web/src/apps/main/modules/field/packing-slips/presentation/store/index.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; +import { EnterpriseModuleState } from '@repo/ui/foundations'; +import { PackingSlipEntity } from '../../domain/entities'; + +export interface PackingSlipsStoreState extends EnterpriseModuleState {} + +export const packingSlipsStore = create((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 }), +})); diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/detail-component/detail-general.tsx index 12b9b0c..ab19fc9 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/components/detail-component/detail-general.tsx +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/detail-component/detail-general.tsx @@ -125,6 +125,21 @@ export function DetailGeneral() { + + {(data?.purpose === 'sales' ? data?.invoices : data?.packingSlips)?.length ? ( + + + {t('section_attachments')} + + + {(data.purpose === 'sales' ? data.invoices : data?.packingSlips)?.map((item) => ( + + {relationLabel(item) || item.id} + + ))} + + + ) : null} ); } diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json b/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json index 067f995..d18fe54 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json +++ b/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json @@ -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", diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json b/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json index dc6c689..54d8c92 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json +++ b/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json @@ -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", diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json index a1ffafd..91c1c67 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json @@ -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", diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json index 7a835bc..65a84b4 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json @@ -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", diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx index e3258ed..76b5ee8 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx @@ -35,10 +35,7 @@ export default function SalesInvoicePageDetail() { }} > - `${salesOrdersModuleConfig.webUrl}/detail/${id}`} - showBalance - /> + `${salesOrdersModuleConfig.webUrl}/detail/${id}`} showBalance /> diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts b/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts index b39ab3e..1e3d43e 100644 --- a/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts +++ b/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts @@ -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[]; } diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts index a1e3f50..05e98ef 100644 --- a/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts +++ b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts @@ -18,6 +18,8 @@ export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer(); + const { t } = useEnterpriseModuleTranslationContext(); + const packingSlipIds = detailData?.packingSlipIds ?? []; + const invoiceIds = detailData?.invoiceIds ?? []; + + return ( + + + {t('section_related')} + + + + + {t('common:fields.packingSlip')} + + {packingSlipIds.length === 0 ? ( + - + ) : ( + packingSlipIds.map((id) => ( + + {id} + + )) + )} + + + + {t('common:fields.salesInvoice')} + + {invoiceIds.length === 0 ? ( + - + ) : ( + invoiceIds.map((id) => ( + + {id} + + )) + )} + + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json index e4e1816..b8e3abb 100644 --- a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json +++ b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json @@ -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", diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json index b1555e4..812f068 100644 --- a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json +++ b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json @@ -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", diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx index e7b628d..56fa619 100644 --- a/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx +++ b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx @@ -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() { `${salesRequestsModuleConfig.webUrl}/detail/${id}`} /> + {actions.modals} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx index 7066825..a273138 100644 --- a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx @@ -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() { - {fields.length === 0 ? ( diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx index 734e746..4434ed9 100644 --- a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx @@ -12,12 +12,7 @@ export function FormPaymentGeneral() { - + void; documentType: SalesDocumentType; + currentStatus?: string | null; onSubmit: (status: string) => Promise | 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}`) }))} /> + + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx b/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx index d6f57ab..4e55bb1 100644 --- a/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx +++ b/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx @@ -59,7 +59,9 @@ export function DetailGeneral({ value={salesOrderId} render={() => salesOrderId ? ( - {data?.salesOrder?.code || data?.salesOrderCode || salesOrderId} + + {data?.salesOrder?.code || data?.salesOrderCode || salesOrderId} + ) : ( '-' ) @@ -72,7 +74,9 @@ export function DetailGeneral({ value={packingSlipId} render={() => packingSlipId ? ( - {data?.packingSlip?.code || data?.packingSlipCode || packingSlipId} + + {data?.packingSlip?.code || data?.packingSlipCode || packingSlipId} + ) : ( '-' ) diff --git a/apps/web/src/apps/main/modules/sales/shared/filter-content.tsx b/apps/web/src/apps/main/modules/sales/shared/filter-content.tsx index 1e81a73..a71e3bc 100644 --- a/apps/web/src/apps/main/modules/sales/shared/filter-content.tsx +++ b/apps/web/src/apps/main/modules/sales/shared/filter-content.tsx @@ -37,50 +37,50 @@ export function SalesFilterFormContent({ /> {documentType !== 'payment' ? ( <> - - control={form.control} - name="customer" - label={t('common:fields.customer')} - valueKey="id" - labelKey="name" - searchable - clearable - loadOptions={loadCustomerOptions} - renderLabel={relationLabel} - /> - - control={form.control} - name="salesPerson" - label={t('common:fields.salesPerson')} - valueKey="id" - labelKey="name" - searchable - clearable - loadOptions={loadSalesEmployeeOptions} - renderLabel={relationLabel} - /> - - control={form.control} - name="branch" - label={t('common:fields.branch')} - valueKey="id" - labelKey="name" - searchable - clearable - loadOptions={loadBranchOptions} - renderLabel={relationLabel} - /> - - control={form.control} - name="division" - label={t('common:fields.division')} - valueKey="id" - labelKey="name" - searchable - clearable - loadOptions={loadDivisionOptions} - renderLabel={relationLabel} - /> + + control={form.control} + name="customer" + label={t('common:fields.customer')} + valueKey="id" + labelKey="name" + searchable + clearable + loadOptions={loadCustomerOptions} + renderLabel={relationLabel} + /> + + control={form.control} + name="salesPerson" + label={t('common:fields.salesPerson')} + valueKey="id" + labelKey="name" + searchable + clearable + loadOptions={loadSalesEmployeeOptions} + renderLabel={relationLabel} + /> + + control={form.control} + name="branch" + label={t('common:fields.branch')} + valueKey="id" + labelKey="name" + searchable + clearable + loadOptions={loadBranchOptions} + renderLabel={relationLabel} + /> + + control={form.control} + name="division" + label={t('common:fields.division')} + valueKey="id" + labelKey="name" + searchable + clearable + loadOptions={loadDivisionOptions} + renderLabel={relationLabel} + /> ) : null} {documentType === 'invoice' ? ( diff --git a/apps/web/src/apps/main/modules/sales/shared/process-order-modal.tsx b/apps/web/src/apps/main/modules/sales/shared/process-order-modal.tsx new file mode 100644 index 0000000..d5db81d --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/process-order-modal.tsx @@ -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; +}) { + 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 ( + +
+ + {t('process_order_help')} + + + + + + +
+
+ ); +} diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts index 42c13a6..ce5d1ee 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts @@ -178,34 +178,34 @@ describe('sales document mapper', () => { }); }); - it('includes parent ids on create and omits images when requested', () => { - const payload = toSalesWritePayload( - { - date: '2026-08-26', - salesPerson: { id: 'emp-1' } as any, - branch: { id: 'br-1' } as any, - division: { id: 'div-1' } as any, - customer: { id: 'cus-1' } as any, - address: 'Jl Sudirman 1', - products: [{ product: { id: 'prd-1' } as any, quantity: '1' }], - images: [{ url: 'https://cdn.example/a.png' }], - salesOrder: { id: 'so-1' } as any, - packingSlip: { id: 'ps-1' } as any, - } as any, - { includeSalesOrderId: true, includePackingSlipId: true, omitImages: true }, - ); +it('includes parent ids on create and omits images when requested', () => { + const payload = toSalesWritePayload( + { + date: '2026-08-26', + salesPerson: { id: 'emp-1' } as any, + branch: { id: 'br-1' } as any, + division: { id: 'div-1' } as any, + customer: { id: 'cus-1' } as any, + address: 'Jl Sudirman 1', + products: [{ product: { id: 'prd-1' } as any, quantity: '1' }], + images: [{ url: 'https://cdn.example/a.png' }], + salesOrder: { id: 'so-1' } as any, + packingSlip: { id: 'ps-1' } as any, + } as any, + { includeSalesOrderId: true, includePackingSlipId: true, omitImages: true }, + ); - expect(payload.salesOrderId).toBe('so-1'); - expect(payload.packingSlipId).toBe('ps-1'); - expect(payload).not.toHaveProperty('images'); - }); + expect(payload.salesOrderId).toBe('so-1'); + expect(payload.packingSlipId).toBe('ps-1'); + expect(payload).not.toHaveProperty('images'); +}); - it('flattens sales order and packing slip in filters', () => { - expect( - toSalesFilterPayload({ - salesOrder: { id: 'so-1' }, - packingSlip: { id: 'ps-1' }, - status: 'draft', - }), - ).toEqual({ salesOrderId: 'so-1', packingSlipId: 'ps-1', status: 'draft' }); - }); +it('flattens sales order and packing slip in filters', () => { + expect( + toSalesFilterPayload({ + salesOrder: { id: 'so-1' }, + packingSlip: { id: 'ps-1' }, + status: 'draft', + }), + ).toEqual({ salesOrderId: 'so-1', packingSlipId: 'ps-1', status: 'draft' }); +}); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts index e7fdb56..e2eaa11 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts @@ -99,6 +99,7 @@ export function toSalesWritePayload( includeSalesOrderId?: boolean; includePackingSlipId?: boolean; omitImages?: boolean; + omitStaffFields?: boolean; }, ): Record { 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; } diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.test.ts index cd019f5..6c57fa9 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.test.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.test.ts @@ -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( diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.ts index 44c0b15..f738813 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.remote.service.ts @@ -11,20 +11,20 @@ export class SalesDocumentRemoteDataServices extends Track this.resourceUrl = config.apiUrl ?? ''; } - changeStatus(id: string, status: string) { + changeStatus(id: string, status: string, extra?: Record) { 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, status: string) { + bulkChangeStatus(ids: Array, status: string, extra?: Record) { return this.customRequest({ url: `${this.resourceUrl}/bulk-status`, method: 'POST', - data: { ids, status }, + data: { ids, status, ...extra }, headers: { 'ex-module-action': REQUEST_ACTION.EDIT }, }); } diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts index 075407d..d63c118 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts @@ -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(), + }); +} diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts index 3c0524e..cb99720 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts @@ -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']); }); }); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-status.ts b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts index 7ffd637..ada63e5 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-status.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts @@ -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 = { draft: ['pending', 'rejected'], @@ -20,6 +23,13 @@ const REQUEST_TRANSITIONS: Record = { }; const ORDER_TRANSITIONS: Record = { + draft: ['processed', 'cancelled'], + processed: ['cancelled'], + completed: [], + cancelled: [], +}; + +const PACKING_TRANSITIONS: Record = { draft: ['processed', 'cancelled'], processed: ['completed', 'cancelled'], completed: [], @@ -27,10 +37,10 @@ const ORDER_TRANSITIONS: Record = { }; const INVOICE_TRANSITIONS: Record = { - 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,11 +101,15 @@ export function commonTransitions(type: SalesDocumentType, statuses: Array current && (action.from as readonly string[]).includes(current)); } diff --git a/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx b/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx index 9913d8f..bbac68b 100644 --- a/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx +++ b/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx @@ -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 = { submit: Play, @@ -22,6 +24,7 @@ const ACTION_ICONS: Record = { 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([]); + const [pendingStatus, setPendingStatus] = useState(); + const [completeProducts, setCompleteProducts] = useState([]); 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) => { 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: , onClick: () => { - void applyStatus([String(data.id)], action.target); + void runNamedAction([String(data.id)], action.key, action.target, data); }, }; }); - extras.push({ - key: 'change-status', - label: t('change_status'), - icon: , - onClick: () => { - setPendingIds([String(data.id)]); - setStatusOpened(true); - }, - }); + if (documentType !== 'invoice') { + extras.push({ + key: 'change-status', + label: t('change_status'), + icon: , + onClick: () => { + setPendingIds([String(data.id)]); + setPendingStatus(data.status); + setStatusOpened(true); + }, + }); + } return [...extras, ...defaultActions]; }; @@ -84,23 +112,28 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) { icon: , variant: 'light' as const, onClick: () => { - void applyStatus( + void runNamedAction( selectedRows.map((row) => String(row.id)), + action.key, action.target, + selectedRows[0], ); }, }; }); - extras.push({ - key: 'bulk-change-status', - label: t('change_status'), - icon: , - variant: 'light' as const, - onClick: () => { - setPendingIds(selectedRows.map((row) => String(row.id))); - setStatusOpened(true); - }, - }); + if (documentType !== 'invoice') { + extras.push({ + key: 'bulk-change-status', + label: t('change_status'), + icon: , + variant: 'light' as const, + onClick: () => { + setPendingIds(selectedRows.map((row) => String(row.id))); + setPendingStatus(selectedRows[0]?.status); + setStatusOpened(true); + }, + }); + } return [...extras, ...defaultActions]; }; @@ -163,21 +196,24 @@ 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); }, }; }); - extras.push({ - key: 'change-status', - label: t('change_status'), - icon: , - intent: 'primary' as const, - variant: 'light' as const, - onClick: () => { - setPendingIds([String(data.id)]); - setStatusOpened(true); - }, - }); + if (documentType !== 'invoice') { + extras.push({ + key: 'change-status', + label: t('change_status'), + icon: , + intent: 'primary' as const, + 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); }} /> + setProcessOpened(false)} + onSubmit={async (generatePackingSlip) => { + await applyStatus(pendingIds, 'processed', { generatePackingSlip }); + }} + /> + setCompleteOpened(false)} + products={completeProducts} + onSubmit={async (products) => { + await applyStatus(pendingIds, 'completed', { products }); + }} + /> setImportOpened(false)} diff --git a/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts b/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts index 6b31a57..47987d3 100644 --- a/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts +++ b/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts @@ -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', diff --git a/apps/web/src/core/lib/filter-menu-by-view-privilege.ts b/apps/web/src/core/lib/filter-menu-by-view-privilege.ts index 89afdec..936f237 100644 --- a/apps/web/src/core/lib/filter-menu-by-view-privilege.ts +++ b/apps/web/src/core/lib/filter-menu-by-view-privilege.ts @@ -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, isSuperadmin: boolean): T[] { +export function filterMenuByViewPrivilege( + items: T[], + privileges: Record, + isSuperadmin: boolean, +): T[] { if (isSuperadmin) { return items; }