feat: enhance packing slips module with new action and language support
- Added "Process" action to the packing slips module, allowing users to transition packing slips from draft to processed status. - Updated English and Indonesian language files to include the new "Process" action, improving user experience and consistency across languages. - Refactored sales status handling to accommodate the new action, ensuring proper workflow integration. These changes enhance the packing slips functionality by providing additional actions and improving language support, streamlining user interactions.
This commit is contained in:
+1
@@ -23,6 +23,7 @@
|
||||
"status_updated": "Status updated.",
|
||||
"action_complete": "Complete",
|
||||
"action_cancel": "Cancel",
|
||||
"action_process": "Process",
|
||||
"complete_packing": "Complete packing",
|
||||
"complete_packing_help": "Enter delivered quantity for each line. Remaining quantity opens a new packing slip.",
|
||||
"delivered_quantity": "Delivered quantity",
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"status_updated": "Status diperbarui.",
|
||||
"action_complete": "Selesaikan",
|
||||
"action_cancel": "Batalkan",
|
||||
"action_process": "Proses",
|
||||
"complete_packing": "Selesaikan packing",
|
||||
"complete_packing_help": "Masukkan kuantitas terkirim untuk setiap baris. Sisa kuantitas akan membuka surat jalan baru.",
|
||||
"delivered_quantity": "Kuantitas terkirim",
|
||||
|
||||
+3
-10
@@ -1,4 +1,3 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { salesOrdersModuleConfig } from '../../domain/constants';
|
||||
@@ -9,12 +8,10 @@ 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';
|
||||
import type { SalesOrderEntity } from '../../domain/entities';
|
||||
|
||||
export default function SalesOrderPageDetail() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const navigate = useNavigate();
|
||||
const actions = useSalesDocumentActions('order');
|
||||
|
||||
return (
|
||||
@@ -28,13 +25,9 @@ export default function SalesOrderPageDetail() {
|
||||
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
customPageActions={(data, pageActions) => {
|
||||
const createInvoice = actions.createInvoiceAction(data as SalesOrderEntity, () => {
|
||||
navigate(`${salesInvoicesModuleConfig.webUrl}/create?salesOrderId=${data.id}`);
|
||||
});
|
||||
const withStatus = actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? []);
|
||||
return createInvoice ? [createInvoice, ...withStatus] : withStatus;
|
||||
}}
|
||||
customPageActions={(data, pageActions) =>
|
||||
actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Button, FieldSelect, Group, Modal, Stack } from '@repo/ui/components';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { allowedTransitions, type SalesDocumentType } from './sales-status';
|
||||
|
||||
export function ChangeStatusModal({
|
||||
opened,
|
||||
onClose,
|
||||
documentType,
|
||||
currentStatus,
|
||||
onSubmit,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
documentType: SalesDocumentType;
|
||||
currentStatus?: string | null;
|
||||
onSubmit: (status: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const form = useForm<{ status: string }>();
|
||||
const options = allowedTransitions(documentType, currentStatus);
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await onSubmit(values.status);
|
||||
form.reset();
|
||||
onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t('change_status')}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<FieldSelect
|
||||
control={form.control as any}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
required
|
||||
data={options.map((value) => ({ value, label: t(`status_${value}`) }))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" type="button" onClick={onClose}>
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('change_status')}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allowedTransitions, commonTransitions, namedActionsFor } from './sales-status';
|
||||
import { ModuleAction } from '@repo/ui/foundations';
|
||||
import { allowedTransitions, commonTransitions, keepSalesChromeActions, namedActionsFor } from './sales-status';
|
||||
|
||||
describe('sales status transitions', () => {
|
||||
it('allows request draft to pending or rejected', () => {
|
||||
@@ -30,8 +31,10 @@ describe('sales status transitions', () => {
|
||||
expect(allowedTransitions('invoice', 'draft')).toEqual(['processed', 'cancelled']);
|
||||
expect(allowedTransitions('invoice', 'processed')).toEqual(['cancelled']);
|
||||
expect(allowedTransitions('invoice', 'partial')).toEqual(['cancelled']);
|
||||
expect(allowedTransitions('invoice', 'completed')).toEqual(['cancelled']);
|
||||
expect(namedActionsFor('invoice', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']);
|
||||
expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['cancel']);
|
||||
expect(namedActionsFor('invoice', 'completed').map((action) => action.key)).toEqual(['cancel']);
|
||||
});
|
||||
|
||||
it('adds payment rollback from pending to draft', () => {
|
||||
@@ -43,8 +46,33 @@ describe('sales status transitions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows packing draft to processed or cancelled', () => {
|
||||
expect(allowedTransitions('packing', 'draft')).toEqual(['processed', 'cancelled']);
|
||||
expect(namedActionsFor('packing', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']);
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
it('strips generic transaction status chrome from default actions', () => {
|
||||
const defaultActions = [
|
||||
{ key: 'VIEW', label: 'View' },
|
||||
{ key: ModuleAction.EDIT, label: 'Edit' },
|
||||
{ key: ModuleAction.HOLD, label: 'Hold' },
|
||||
{ key: ModuleAction.ROLLBACK, label: 'Rollback' },
|
||||
{ key: ModuleAction.CANCEL, label: 'Cancel' },
|
||||
{ key: ModuleAction.CONFIRM, label: 'Confirm' },
|
||||
{ key: 'change-status', label: 'Change status' },
|
||||
{ key: 'bulk-change-status', label: 'Change status' },
|
||||
{ key: ModuleAction.DELETE, label: 'Delete' },
|
||||
];
|
||||
|
||||
expect(keepSalesChromeActions(defaultActions).map((action) => action.key)).toEqual([
|
||||
'VIEW',
|
||||
ModuleAction.EDIT,
|
||||
ModuleAction.DELETE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,13 +56,18 @@ export const SALES_ORDER_NAMED_ACTIONS = [
|
||||
] as const;
|
||||
|
||||
export const PACKING_SLIP_NAMED_ACTIONS = [
|
||||
{ key: 'process', target: 'processed' as const, from: ['draft'] as const },
|
||||
{ key: 'complete', target: 'completed' as const, from: ['processed'] as const },
|
||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
|
||||
] as const;
|
||||
|
||||
export const SALES_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: 'cancel',
|
||||
target: 'cancelled' as const,
|
||||
from: ['draft', 'processed', 'partial', 'completed'] as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const SALES_PAYMENT_NAMED_ACTIONS = [
|
||||
@@ -120,3 +125,16 @@ export function documentStatusFilterOptions(t: (key: string) => string, type: Sa
|
||||
label: t(`status_${value}`),
|
||||
}));
|
||||
}
|
||||
|
||||
export const SALES_GENERIC_STATUS_ACTION_KEYS = new Set([
|
||||
'HOLD',
|
||||
'CONFIRM',
|
||||
'CANCEL',
|
||||
'ROLLBACK',
|
||||
'change-status',
|
||||
'bulk-change-status',
|
||||
]);
|
||||
|
||||
export function keepSalesChromeActions<T extends { key?: string }>(actions: T[]): T[] {
|
||||
return actions.filter((action) => !action.key || !SALES_GENERIC_STATUS_ACTION_KEYS.has(action.key));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, Receipt, CreditCard, Undo2 } from 'lucide-react';
|
||||
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, CreditCard, Undo2 } from 'lucide-react';
|
||||
import { notifications } from '@repo/ui/components';
|
||||
import {
|
||||
useDetailPageContext,
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
useEnterpriseModuleDataServiceContext,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
} from '@repo/ui/foundations';
|
||||
import { namedActionsFor, type SalesDocumentType } from './sales-status';
|
||||
import { keepSalesChromeActions, namedActionsFor, type SalesDocumentType } from './sales-status';
|
||||
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
|
||||
import type { SalesDocumentEntity, SalesLineEntity } from './sales-document.entity';
|
||||
|
||||
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';
|
||||
@@ -34,12 +33,10 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
SalesDocumentEntity,
|
||||
SalesDocumentRemoteDataServices<SalesDocumentEntity>
|
||||
>();
|
||||
const [statusOpened, setStatusOpened] = useState(false);
|
||||
const [importOpened, setImportOpened] = useState(false);
|
||||
const [processOpened, setProcessOpened] = useState(false);
|
||||
const [completeOpened, setCompleteOpened] = useState(false);
|
||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||
const [pendingStatus, setPendingStatus] = useState<string | undefined>();
|
||||
const [completeProducts, setCompleteProducts] = useState<SalesLineEntity[]>([]);
|
||||
const canEdit = privileges.ALLOW_EDIT;
|
||||
const canImport = privileges.ALLOW_IMPORT;
|
||||
@@ -72,7 +69,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
};
|
||||
|
||||
const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => {
|
||||
if (!canEdit) return defaultActions;
|
||||
const chromeActions = keepSalesChromeActions(defaultActions);
|
||||
if (!canEdit) return chromeActions;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
return {
|
||||
@@ -84,23 +82,12 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={15} />,
|
||||
onClick: () => {
|
||||
setPendingIds([String(data.id)]);
|
||||
setPendingStatus(data.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
return [...extras, ...chromeActions];
|
||||
};
|
||||
|
||||
const namedBulkActions = (selectedRows: SalesActionEntity[], defaultActions: any[]) => {
|
||||
if (!canEdit || selectedRows.length === 0) return defaultActions;
|
||||
const chromeActions = keepSalesChromeActions(defaultActions);
|
||||
if (!canEdit || selectedRows.length === 0) return chromeActions;
|
||||
const statuses = selectedRows.map((row) => row.status);
|
||||
const extras: any[] = namedActionsFor(documentType, statuses[0])
|
||||
.filter((action) => statuses.every((status) => (action.from as readonly string[]).includes(status ?? '')))
|
||||
@@ -121,20 +108,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'bulk-change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={16} />,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
setPendingIds(selectedRows.map((row) => String(row.id)));
|
||||
setPendingStatus(selectedRows[0]?.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
return [...extras, ...chromeActions];
|
||||
};
|
||||
|
||||
const importPageAction = canImport
|
||||
@@ -161,18 +135,6 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const createInvoiceAction = (_data: SalesActionEntity, onClick: () => void) =>
|
||||
canEdit
|
||||
? {
|
||||
key: 'create-invoice',
|
||||
label: t('create_sales_invoice'),
|
||||
icon: <Receipt size={16} />,
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
onClick,
|
||||
}
|
||||
: null;
|
||||
|
||||
const createPaymentAction = (_data: SalesActionEntity, onClick: () => void) =>
|
||||
canEdit
|
||||
? {
|
||||
@@ -186,7 +148,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
: null;
|
||||
|
||||
const detailStatusActions = (data: SalesActionEntity, defaultActions: any[]) => {
|
||||
if (!canEdit) return defaultActions;
|
||||
const chromeActions = keepSalesChromeActions(defaultActions);
|
||||
if (!canEdit) return chromeActions;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
return {
|
||||
@@ -200,34 +163,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={16} />,
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
setPendingIds([String(data.id)]);
|
||||
setPendingStatus(data.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
return [...extras, ...chromeActions];
|
||||
};
|
||||
|
||||
const modals = (
|
||||
<>
|
||||
<ChangeStatusModal
|
||||
opened={statusOpened}
|
||||
onClose={() => setStatusOpened(false)}
|
||||
documentType={documentType}
|
||||
currentStatus={pendingStatus}
|
||||
onSubmit={async (status) => {
|
||||
await applyStatus(pendingIds, status);
|
||||
}}
|
||||
/>
|
||||
<ProcessOrderModal
|
||||
opened={processOpened}
|
||||
onClose={() => setProcessOpened(false)}
|
||||
@@ -260,7 +200,6 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
namedBulkActions,
|
||||
importPageAction,
|
||||
createOrderAction,
|
||||
createInvoiceAction,
|
||||
createPaymentAction,
|
||||
detailStatusActions,
|
||||
applyStatus,
|
||||
|
||||
Reference in New Issue
Block a user