From 5b28067203d9b5f68b398817a3ea66291cff27c1 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 21 Nov 2025 22:46:50 +0700 Subject: [PATCH] feat(FE-208,212,213): enhance PurchaseOrderStaffApprovalForm with product and warehouse selection for new items --- .../form/order/PurchaseOrderForm.schema.ts | 95 ++- .../order/PurchaseOrderStaffApprovalForm.tsx | 568 +++++++++++++++--- src/types/api/purchase/purchase.d.ts | 8 +- 3 files changed, 593 insertions(+), 78 deletions(-) diff --git a/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts b/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts index bfe4ed92..e0a5b7e4 100644 --- a/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts +++ b/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts @@ -6,6 +6,16 @@ type PurchaseRequestStaffApprovalFormSchemaType = { notes: string | null; items: { purchase_item_id?: number; + product_id?: number | null; + warehouse_id?: number | null; + product?: { + value: number; + label: string; + } | null; + warehouse?: { + value: number; + label: string; + } | null; qty: number; price: number | string; total_price: number | string; @@ -41,6 +51,16 @@ type PurchaseRequestAcceptApprovalFormSchemaType = { export type PurchaseStaffApprovalItemSchema = { purchase_item_id?: number; + product_id?: number | null; + warehouse_id?: number | null; + product?: { + value: number; + label: string; + } | null; + warehouse?: { + value: number; + label: string; + } | null; qty: number; price: number | string; total_price: number | string; @@ -74,8 +94,28 @@ const PurchaseStaffApprovalItemObjectSchema: Yup.ObjectSchema { + const isExisting = + item.purchase_item_id && item.purchase_item_id > 0; + + const isNew = !item.purchase_item_id || item.purchase_item_id === 0; + + if (isExisting) { + return true; + } + + if (isNew) { + return Boolean( + item.product_id && + item.product_id > 0 && + item.warehouse_id && + item.warehouse_id > 0 + ); + } + + return false; + }); + } + ), }); export const PurchaseDeleteItemsSchema: Yup.ObjectSchema = @@ -240,7 +309,10 @@ export const PurchaseRequestStaffApprovalFormInitialValues: PurchaseRequestStaff notes: '', items: [ { - purchase_item_id: 0, + product_id: 0, + warehouse_id: 0, + product: null, + warehouse: null, qty: 0, price: '', total_price: '', @@ -257,13 +329,26 @@ export const PurchaseRequestStaffApprovalFormDefaultValues = ( items: purchase?.items ? purchase.items.map((item) => ({ purchase_item_id: item.id, - qty: item.qty, + product_id: item.product_id || 0, + warehouse_id: item.warehouse?.id || 0, + product: { + value: item.product_id || 0, + label: item.product?.name || '', + }, + warehouse: { + value: item.warehouse?.id || 0, + label: item.warehouse?.name || '', + }, + qty: item.sub_qty || item.qty || 0, price: item.price, total_price: item.total_price, })) : [ { - purchase_item_id: 0, + product_id: 0, + warehouse_id: 0, + product: null, + warehouse: null, qty: 0, price: '', total_price: '', diff --git a/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx index d07270bd..8a988bae 100644 --- a/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx +++ b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx @@ -5,15 +5,25 @@ import { useFormik } from 'formik'; import { Icon } from '@iconify/react'; import { toast } from 'react-hot-toast'; import { useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; import NumberInput from '@/components/input/NumberInput'; -import SelectInput from '@/components/input/SelectInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { useModal } from '@/components/Modal'; +import { WarehouseApi, SupplierApi } from '@/services/api/master-data'; +import { SupplierProducts } from '@/types/api/master-data/supplier'; +import { isResponseSuccess } from '@/lib/api-helper'; import { PurchaseRequestStaffApprovalFormDefaultValues, PurchaseRequestStaffApprovalFormInitialValues, PurchaseRequestStaffApprovalFormSchema, + PurchaseStaffApprovalItemSchema, } from './PurchaseOrderForm.schema'; import { isResponseError } from '@/lib/api-helper'; import { PurchaseApi } from '@/services/api/purchase'; @@ -43,8 +53,15 @@ const PurchaseOrderStaffApprovalForm = ({ }: PurchaseOrderStaffApprovalFormProps) => { const router = useRouter(); const searchParams = useSearchParams(); + const deleteModal = useModal(); const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = useState(''); + const [selectedItemForDelete, setSelectedItemForDelete] = useState< + number | null + >(null); + const [selectedItemIndex, setSelectedItemIndex] = useState( + null + ); // ===== UTILITY FUNCTIONS ===== const canUpdatePurchaseItems = useMemo(() => { @@ -55,19 +72,11 @@ const PurchaseOrderStaffApprovalForm = ({ }, [initialValues?.approval]); const isRepeaterInputError = ( - purchaseItemId: number, - field: 'price' | 'total_price' + idx: number, + field: 'price' | 'total_price' | 'product_id' | 'warehouse_id' ): { isError: boolean; errorMessage: string } => { - const formItemIndex = formik.values.items?.findIndex( - (item) => item.purchase_item_id === purchaseItemId - ); - - if (formItemIndex === -1) { - return { isError: false, errorMessage: '' }; - } - - const touchedItem = formik.touched.items?.[formItemIndex]; - const errorItem = formik.errors.items?.[formItemIndex] as + const touchedItem = formik.touched.items?.[idx]; + const errorItem = formik.errors.items?.[idx] as | Record | undefined; @@ -84,6 +93,10 @@ const PurchaseOrderStaffApprovalForm = ({ }; }; + const isNewItem = (item: PurchaseStaffApprovalItemSchema) => { + return !item.purchase_item_id || item.purchase_item_id === 0; + }; + // ===== SUBMISSION HANDLERS ===== const createStaffApprovalHandler = useCallback( async (payload: CreateStaffApprovalRequestPayload) => { @@ -113,7 +126,13 @@ const PurchaseOrderStaffApprovalForm = ({ onModalClose?.(); router.refresh(); }, - [initialValues?.id, searchParams, refreshApprovals, onModalClose, onRefetchData] + [ + initialValues?.id, + searchParams, + refreshApprovals, + onModalClose, + onRefetchData, + ] ); const updateStaffApprovalHandler = useCallback( @@ -134,6 +153,86 @@ const PurchaseOrderStaffApprovalForm = ({ [refreshApprovals, onModalClose, onRefetchData] ); + // ===== DELETE HANDLER ===== + const deleteItemsHandler = useCallback(async () => { + const purchaseRequestId = searchParams.get('purchaseId') + ? parseInt(searchParams.get('purchaseId')!) + : initialValues?.id || 1; + + if (!purchaseRequestId) { + toast.error('Purchase Request ID is required'); + return; + } + + const itemIdsToDelete = selectedItemForDelete + ? [selectedItemForDelete] + : []; + + if (itemIdsToDelete.length === 0) { + toast.error('Tidak ada item yang dipilih untuk dihapus'); + return; + } + + try { + const res = await PurchaseApi.items.delete(purchaseRequestId, { + item_ids: itemIdsToDelete, + }); + + if (isResponseError(res)) { + toast.error(res.message || 'Gagal menghapus item pembelian'); + return; + } + + const successMessage = 'Item pembelian berhasil dihapus'; + toast.success(successMessage); + + refreshApprovals?.(); + onRefetchData?.(); + deleteModal.closeModal(); + setSelectedItemForDelete(null); + setSelectedItemIndex(null); + + if (selectedItemIndex !== null) { + const updatedPurchaseItems = formik.values.items?.filter( + (_, i) => i !== selectedItemIndex + ); + formik.setFieldValue('items', updatedPurchaseItems); + } + } catch (error) { + toast.error('Terjadi kesalahan saat menghapus item pembelian'); + console.error('Delete item error:', error); + } + }, [ + initialValues?.id, + searchParams, + selectedItemForDelete, + selectedItemIndex, + refreshApprovals, + onRefetchData, + deleteModal, + ]); + + // ===== API DATA FETCHING FOR SUPPLIER PRODUCTS ===== + const { data: supplierData, isLoading: isLoadingSupplierProducts } = useSWR( + initialValues?.supplier?.id + ? SupplierApi.basePath + '/' + initialValues.supplier.id + : null, + (url: string) => SupplierApi.getSingle(Number(url.split('/').pop())) + ); + + const supplierProductOptions = useMemo(() => { + if (!supplierData || !isResponseSuccess(supplierData)) { + return []; + } + + const supplier = supplierData.data as SupplierProducts; + const products = supplier.products || []; + return products.map((product) => ({ + value: product.id, + label: product.name, + })); + }, [supplierData]); + // ===== FORM CONFIGURATION ===== const formikInitialValues = useMemo(() => { return initialValues @@ -154,35 +253,97 @@ const PurchaseOrderStaffApprovalForm = ({ return; } - const payload: CreateStaffApprovalRequestPayload = { - action: values.action || 'APPROVED', - notes: values.notes || '', - items: purchaseItems.map((purchaseItem, idx) => { - const formItem = values.items?.[idx]; - return { - purchase_item_id: purchaseItem.id, - qty: purchaseItem.quantity || 0, + const itemsPayload = (values.items || []).map((formItem) => { + const isNewItemForm = + !formItem.purchase_item_id || formItem.purchase_item_id === 0; + + let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; + + if (isNewItemForm) { + cleanPayload = { + product_id: Number(formItem.product_id) || 0, + warehouse_id: Number(formItem.warehouse_id) || 0, + qty: Number(formItem.qty) || 0, price: - typeof formItem?.price === 'string' + typeof formItem.price === 'string' ? parseFloat(formItem.price) || 0 - : formItem?.price || 0, + : Number(formItem.price) || 0, total_price: - typeof formItem?.total_price === 'string' + typeof formItem.total_price === 'string' ? parseFloat(formItem.total_price) || 0 - : formItem?.total_price || 0, + : Number(formItem.total_price) || 0, }; - }), + } else { + cleanPayload = { + purchase_item_id: Number(formItem.purchase_item_id) || 0, + qty: Number(formItem.qty) || 0, + price: + typeof formItem.price === 'string' + ? parseFloat(formItem.price) || 0 + : Number(formItem.price) || 0, + total_price: + typeof formItem.total_price === 'string' + ? parseFloat(formItem.total_price) || 0 + : Number(formItem.total_price) || 0, + }; + } + return cleanPayload; + }); + + const payload: UpdateStaffApprovalRequestPayload = { + action: values.action || 'APPROVED', + notes: values.notes || null, + items: itemsPayload, }; if (type === 'add') { - await createStaffApprovalHandler(payload); + await createStaffApprovalHandler( + payload as CreateStaffApprovalRequestPayload + ); } else if (type === 'edit') { + const updateItemsPayload = (values.items || []).map((formItem) => { + const isNewItemForm = + !formItem.purchase_item_id || formItem.purchase_item_id === 0; + + let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; + + if (isNewItemForm) { + cleanPayload = { + product_id: Number(formItem.product_id) || 0, + warehouse_id: Number(formItem.warehouse_id) || 0, + qty: Number(formItem.qty) || 0, + price: + typeof formItem.price === 'string' + ? parseFloat(formItem.price) || 0 + : Number(formItem.price) || 0, + total_price: + typeof formItem.total_price === 'string' + ? parseFloat(formItem.total_price) || 0 + : Number(formItem.total_price) || 0, + }; + } else { + cleanPayload = { + purchase_item_id: Number(formItem.purchase_item_id) || 0, + qty: Number(formItem.qty) || 0, + price: + typeof formItem.price === 'string' + ? parseFloat(formItem.price) || 0 + : Number(formItem.price) || 0, + total_price: + typeof formItem.total_price === 'string' + ? parseFloat(formItem.total_price) || 0 + : Number(formItem.total_price) || 0, + }; + } + + return cleanPayload; + }); + const updatePayload: UpdateStaffApprovalRequestPayload = { action: values.action || 'APPROVED', notes: values.notes || null, - items: payload.items, + items: updateItemsPayload, }; - await updateStaffApprovalHandler( initialValues?.id as number, updatePayload @@ -198,7 +359,7 @@ const PurchaseOrderStaffApprovalForm = ({ value: item.id, label: item.product.name, id: item.id, - quantity: item.sub_qty, + quantity: item.sub_qty || item.qty || 0, product_id: item.product_id, warehouse_id: item.warehouse.id, product: { @@ -244,68 +405,113 @@ const PurchaseOrderStaffApprovalForm = ({ return Object.values(warehouseGroups); }, [purchaseItems]); - const productOptions = useMemo(() => { - return purchaseItems.map((item) => ({ - value: item.product_id, - label: item.product.name, - })); - }, [purchaseItems]); - useEffect(() => { if (purchaseItems.length > 0 && initialValues?.items) { - const updatedItems = purchaseItems.map((purchaseItem, idx) => { + const updatedItems = purchaseItems.map((purchaseItem) => { const originalItem = initialValues.items?.find( (item) => item.id === purchaseItem.id ); - return { + const itemData = { purchase_item_id: purchaseItem.id, product_id: purchaseItem.product_id || 0, warehouse_id: purchaseItem.warehouse_id || 0, - qty: purchaseItem.quantity || 0, + qty: originalItem?.qty || purchaseItem.quantity || 0, price: type === 'edit' && originalItem ? originalItem.price : '', total_price: type === 'edit' && originalItem ? originalItem.total_price : '', }; + return itemData; }); formik.setFieldValue('items', updatedItems); } }, [purchaseItems, type, initialValues]); // ===== PURCHASE ITEM OPERATIONS ===== + const addPurchaseItem = () => { + const existingWarehouseId = + formik.values.items?.find((item) => (item.warehouse_id || 0) > 0) + ?.warehouse_id || + groupedPurchaseItems[0]?.warehouseId || + 0; + + const warehouseObject = initialValues?.items?.find( + (item) => item.warehouse.id === existingWarehouseId + )?.warehouse; + + const newItem = { + product_id: 0, + product: null, + warehouse_id: existingWarehouseId, + warehouse: warehouseObject + ? { + value: warehouseObject.id, + label: warehouseObject.name, + } + : null, + qty: 0, + price: '', + total_price: '', + }; + const newItems = [...(formik.values.items || []), newItem]; + formik.setFieldValue('items', newItems); + }; + + const removePurchaseItem = (idx: number) => { + const itemToRemove = formik.values.items?.[idx]; + + if ( + !itemToRemove?.purchase_item_id || + itemToRemove.purchase_item_id === 0 + ) { + const updatedPurchaseItems = formik.values.items?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('items', updatedPurchaseItems); + return; + } + setSelectedItemForDelete(itemToRemove.purchase_item_id); + setSelectedItemIndex(idx); + deleteModal.openModal(); + }; + + const handleProductChange = ( + idx: number, + val: OptionType | OptionType[] | null + ) => { + const product = val as OptionType | null; + const productId = (product as OptionType)?.value || 0; + + formik.setFieldTouched(`items.${idx}.product`, true); + formik.setFieldValue(`items.${idx}.product`, product); + formik.setFieldTouched(`items.${idx}.product_id`, true); + formik.setFieldValue(`items.${idx}.product_id`, productId); + }; + const handlePurchaseItemChange = ( - purchaseItemId: number, + idx: number, field: 'price' | 'total_price', value: string | number ) => { - const formItemIndex = formik.values.items?.findIndex( - (item) => item.purchase_item_id === purchaseItemId - ); - const purchaseItem = purchaseItems.find( - (item) => item.id === purchaseItemId - ); + const formItem = formik.values.items?.[idx]; - if (formItemIndex === -1 || !purchaseItem) return; + if (!formItem) { + return; + } if (field === 'price' || field === 'total_price') { const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value; - formik.setFieldValue(`items.${formItemIndex}.${field}`, numValue); - if (field === 'price' && purchaseItem.quantity > 0 && numValue >= 0) { - const calculatedTotal = numValue * purchaseItem.quantity; - formik.setFieldValue( - `items.${formItemIndex}.total_price`, - calculatedTotal - ); + formik.setFieldValue(`items.${idx}.${field}`, numValue); + + if (field === 'price' && formItem.qty > 0 && numValue >= 0) { + const calculatedTotal = numValue * formItem.qty; + formik.setFieldValue(`items.${idx}.total_price`, calculatedTotal); } - if ( - field === 'total_price' && - purchaseItem.quantity > 0 && - numValue >= 0 - ) { - const calculatedPrice = numValue / purchaseItem.quantity; - formik.setFieldValue(`items.${formItemIndex}.price`, calculatedPrice); + if (field === 'total_price' && formItem.qty > 0 && numValue >= 0) { + const calculatedPrice = numValue / formItem.qty; + formik.setFieldValue(`items.${idx}.price`, calculatedPrice); } } }; @@ -350,9 +556,11 @@ const PurchaseOrderStaffApprovalForm = ({ Total (Rp.) * + Action + {/* Existing Items */} {warehouseData.items.map((purchaseItem) => { const formItem = formik.values.items?.find( (item) => @@ -363,6 +571,13 @@ const PurchaseOrderStaffApprovalForm = ({ (item) => item.purchase_item_id === purchaseItem.id ); + + if ( + !formItem?.purchase_item_id || + formItem.purchase_item_id === 0 + ) + return null; + return ( @@ -373,7 +588,7 @@ const PurchaseOrderStaffApprovalForm = ({ label: purchaseItem?.product?.name || '', }} - options={productOptions} + options={supplierProductOptions} isClearable={false} isSearchable={false} className={{ @@ -441,7 +656,7 @@ const PurchaseOrderStaffApprovalForm = ({ value={formItem?.price || ''} onChange={(e) => handlePurchaseItemChange( - purchaseItem.id, + formItemIndex, 'price', e.target.value ) @@ -455,13 +670,13 @@ const PurchaseOrderStaffApprovalForm = ({ inputPrefix={'Rp'} isError={ isRepeaterInputError( - purchaseItem.id, + formItemIndex, 'price' ).isError } errorMessage={ isRepeaterInputError( - purchaseItem.id, + formItemIndex, 'price' ).errorMessage } @@ -478,7 +693,7 @@ const PurchaseOrderStaffApprovalForm = ({ value={formItem?.total_price || ''} onChange={(e) => handlePurchaseItemChange( - purchaseItem.id, + formItemIndex, 'total_price', e.target.value ) @@ -492,13 +707,13 @@ const PurchaseOrderStaffApprovalForm = ({ inputPrefix={'Rp'} isError={ isRepeaterInputError( - purchaseItem.id, + formItemIndex, 'total_price' ).isError } errorMessage={ isRepeaterInputError( - purchaseItem.id, + formItemIndex, 'total_price' ).errorMessage } @@ -508,6 +723,190 @@ const PurchaseOrderStaffApprovalForm = ({ }} /> + +
+ {canUpdatePurchaseItems && ( + + )} +
+ + + ); + })} + + {/* New Items */} + {formik.values.items?.map((formItem, idx) => { + const isNewItemForm = isNewItem(formItem); + if (!isNewItemForm) return null; + + return ( + + + + handleProductChange(idx, val) + } + options={supplierProductOptions} + isLoading={isLoadingSupplierProducts} + isError={ + isRepeaterInputError(idx, 'product_id') + .isError + } + errorMessage={ + isRepeaterInputError(idx, 'product_id') + .errorMessage + } + placeholder='Pilih Produk' + className={{ + wrapper: + 'min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + + + { + const numValue = + typeof e.target.value === 'string' + ? parseFloat(e.target.value) || 0 + : e.target.value; + formik.setFieldValue( + `items.${idx}.qty`, + numValue + ); + }} + onBlur={formik.handleBlur} + placeholder='Masukkan jumlah' + allowNegative={false} + decimalScale={0} + className={{ + wrapper: 'min-w-24', + }} + /> + + + + + + + handlePurchaseItemChange( + idx, + 'price', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan harga satuan' + allowNegative={false} + decimalScale={2} + thousandSeparator=',' + decimalSeparator='.' + inputPrefix={'Rp'} + isError={ + isRepeaterInputError(idx, 'price') + .isError + } + errorMessage={ + isRepeaterInputError(idx, 'price') + .errorMessage + } + className={{ + wrapper: + 'min-w-48 md:min-w-64 lg:min-w-72', + }} + /> + + + + handlePurchaseItemChange( + idx, + 'total_price', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan total harga' + allowNegative={false} + decimalScale={2} + thousandSeparator=',' + decimalSeparator='.' + inputPrefix={'Rp'} + isError={ + isRepeaterInputError(idx, 'total_price') + .isError + } + errorMessage={ + isRepeaterInputError(idx, 'total_price') + .errorMessage + } + className={{ + wrapper: + 'min-w-48 md:min-w-64 lg:min-w-72', + }} + /> + + +
+ +
+ ); })} @@ -516,6 +915,19 @@ const PurchaseOrderStaffApprovalForm = ({ + {/* Add Item Button */} +
+ +
+ {/* Add divider after table except for last item */} {index < groupedPurchaseItems.length - 1 && (
@@ -583,6 +995,22 @@ const PurchaseOrderStaffApprovalForm = ({ )} + + {/* Delete Confirmation Modal */} + ); }; diff --git a/src/types/api/purchase/purchase.d.ts b/src/types/api/purchase/purchase.d.ts index cb944ca0..56cbd810 100644 --- a/src/types/api/purchase/purchase.d.ts +++ b/src/types/api/purchase/purchase.d.ts @@ -92,12 +92,14 @@ export type CreateStaffApprovalRequestPayload = { export type UpdateStaffApprovalRequestPayload = { action: 'APPROVED' | 'REJECTED'; notes?: string | null; - items: { - purchase_item_id: number; + items: Array<{ + purchase_item_id?: number; + product_id?: number; + warehouse_id?: number; qty: number; price: number; total_price: number; - }[]; + }>; }; export type CreateManagerApprovalRequestPayload = {