diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index f33888e2..2cc5d910 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -11,7 +11,7 @@ export type ProductSchema = { }; export type DeliverySchema = { - delivery_cost: number; + delivery_cost?: number | undefined; delivery_cost_per_item?: number | undefined; document?: File | string | null; driver_name: string; @@ -57,13 +57,35 @@ const DeliveryProductObjectSchema = Yup.object({ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ delivery_cost: Yup.number() - .required('Biaya pengiriman wajib diisi!') + .transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .min(1, 'Biaya minimal 1!') - .typeError('Biaya harus berupa angka!'), + .typeError('Biaya harus berupa angka!') + .test( + 'one-of-cost-fields', + 'Biaya pengiriman atau biaya per item wajib diisi!', + function (value) { + const { delivery_cost_per_item } = this.parent; + return ( + (value !== undefined && value > 0) || + (delivery_cost_per_item !== undefined && delivery_cost_per_item > 0) + ); + } + ), delivery_cost_per_item: Yup.number() - .transform((value) => (isNaN(value) ? undefined : value)) + .transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .min(1, 'Biaya per item minimal 1!') - .typeError('Biaya per item harus berupa angka!'), + .typeError('Biaya per item harus berupa angka!') + .test( + 'one-of-cost-fields', + 'Biaya pengiriman atau biaya per item wajib diisi!', + function (value) { + const { delivery_cost } = this.parent; + return ( + (value !== undefined && value > 0) || + (delivery_cost !== undefined && delivery_cost > 0) + ); + } + ), document_index: Yup.number().optional(), document: Yup.mixed() .nullable() @@ -92,6 +114,8 @@ export const MovementFormSchema = Yup.object({ source_warehouse: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), + area: Yup.string().optional(), + location: Yup.string().optional(), }).nullable(), source_warehouse_id: Yup.number() .required('Gudang asal wajib diisi!') @@ -99,6 +123,8 @@ export const MovementFormSchema = Yup.object({ destination_warehouse: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), + area: Yup.string().optional(), + location: Yup.string().optional(), }).nullable(), destination_warehouse_id: Yup.number() .required('Gudang tujuan wajib diisi!') @@ -120,9 +146,12 @@ export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( initialValues?: Movement ): MovementFormValues => { - const detailIdToProductId = new Map(); + const detailIdToProductId = new Map(); initialValues?.details?.forEach((detail) => { - detailIdToProductId.set(detail.id, detail.product_id); + detailIdToProductId.set(detail.id, { + id: detail.product.id, + name: detail.product.name, + }); }); return { @@ -132,6 +161,8 @@ export const getMovementFormInitialValues = ( ? { value: initialValues.source_warehouse.id, label: initialValues.source_warehouse.name, + area: initialValues.source_warehouse.area?.name, + location: initialValues.source_warehouse.location?.name, } : null, source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, @@ -139,14 +170,19 @@ export const getMovementFormInitialValues = ( ? { value: initialValues.destination_warehouse.id, label: initialValues.destination_warehouse.name, + area: initialValues.destination_warehouse.area?.name, + location: initialValues.destination_warehouse.location?.name, } : null, destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, products: - initialValues?.details?.map((p) => ({ - product: { value: p.product_id, label: `Product ID: ${p.product_id}` }, - product_id: p.product_id, - product_qty: p.quantity, + initialValues?.details?.map((detail) => ({ + product: { + value: detail.product.id, + label: detail.product.name, + }, + product_id: detail.product.id, + product_qty: detail.quantity, })) ?? [], deliveries: initialValues?.deliveries?.map((d) => { @@ -160,16 +196,16 @@ export const getMovementFormInitialValues = ( supplier: d.supplier ? { value: d.supplier.id, label: d.supplier.name } : null, - supplier_id: d.supplier_id, + supplier_id: d.supplier?.id ?? 0, products: d.items.map((item) => { - const productId = - detailIdToProductId.get(item.stock_transfer_detail_id) ?? 0; + const productData = detailIdToProductId.get( + item.stock_transfer_detail_id + ); return { - product: - productId > 0 - ? { value: productId, label: `Product ID: ${productId}` } - : null, - product_id: productId, + product: productData + ? { value: productData.id, label: productData.name } + : null, + product_id: productData?.id ?? 0, product_qty: item.quantity, }; }), diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 3ff448f8..a35937f7 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -25,11 +25,7 @@ import { DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; -import { - SupplierApi, - WarehouseApi, - ProductApi, -} from '@/services/api/master-data'; +import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; @@ -47,9 +43,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); - const [fetchedProductIds, setFetchedProductIds] = useState>( - new Set() - ); const { deleteModal, @@ -87,7 +80,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } return { - delivery_cost: d.delivery_cost, + delivery_cost: d.delivery_cost ?? 0, delivery_cost_per_item: d.delivery_cost_per_item ?? 0, document_index: documentIndex, driver_name: d.driver_name, @@ -167,8 +160,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formik.setFieldValue('deliveries', [ ...(formik.values.deliveries || []), { - delivery_cost: 0, - delivery_cost_per_item: 0, + delivery_cost: undefined, + delivery_cost_per_item: undefined, document: null, driver_name: '', vehicle_plate: '', @@ -270,6 +263,43 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { location?: string; } + interface ProductWarehouseOptionType extends OptionType { + product_id: number; + warehouse_id: number; + warehouse_name: string; + quantity: number; + } + + const allProductWarehousesUrl = `${ProductWarehouseApi.basePath}`; + const { data: allProductWarehouses } = useSWR( + allProductWarehousesUrl, + ProductWarehouseApi.getAllFetcher + ); + + const warehouseStockMap = useMemo(() => { + if (!isResponseSuccess(allProductWarehouses)) return new Map(); + + const stockMap = new Map< + number, + { totalQty: number; productCount: number } + >(); + + allProductWarehouses.data.forEach((pw) => { + const warehouseId = pw.warehouse.id; + const existing = stockMap.get(warehouseId) || { + totalQty: 0, + productCount: 0, + }; + + stockMap.set(warehouseId, { + totalQty: existing.totalQty + pw.quantity, + productCount: existing.productCount + 1, + }); + }); + + return stockMap; + }, [allProductWarehouses]); + // Warehouse selection const [warehouseSelectInputValue, setWarehouseSelectInputValue] = useState(''); @@ -279,15 +309,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { WarehouseApi.getAllFetcher ); const warehouseOptions = isResponseSuccess(warehouses) - ? warehouses?.data.map((w) => ({ - value: w.id, - label: w.name, - area: w.area?.name, - location: - 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') - ? w.location?.name - : undefined, - })) + ? warehouses?.data.map((w) => { + const stockInfo = warehouseStockMap.get(w.id); + const stockLabel = stockInfo + ? ` (Stock: ${stockInfo.totalQty.toLocaleString('id-ID')} items, ${stockInfo.productCount} produk)` + : ' (Kosong)'; + + return { + value: w.id, + label: `${w.name}${stockLabel}`, + area: w.area?.name, + location: + 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') + ? w.location?.name + : undefined, + }; + }) : []; // Product Warehouse selection - Filter by source warehouse @@ -308,8 +345,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); const productWarehouseOptions = isResponseSuccess(productWarehouses) ? productWarehouses?.data.map((pw) => ({ - value: pw.id, - label: pw.product.name, + value: pw.product.id, + label: `${pw.product.name} - ${pw.warehouse.name} (Stock: ${pw.quantity.toLocaleString('id-ID')})`, product_id: pw.product.id, warehouse_id: pw.warehouse.id, warehouse_name: pw.warehouse.name, @@ -328,21 +365,97 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) : []; + // Handle cost calculation when delivery_cost changes + const handleDeliveryCostChange = useCallback( + (idx: number, value: string) => { + const numValue = parseFloat(value) || 0; + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, numValue); + + const delivery = formik.values.deliveries?.[idx]; + if (delivery) { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + if (productQty > 0 && numValue > 0) { + const perItem = numValue / productQty; + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + perItem + ); + } else if (numValue === 0) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0); + } + } + }, + [formik] + ); + + // Handle cost calculation when delivery_cost_per_item changes + const handleDeliveryCostPerItemChange = useCallback( + (idx: number, value: string) => { + const numValue = parseFloat(value) || 0; + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + numValue + ); + + const delivery = formik.values.deliveries?.[idx]; + if (delivery) { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + if (productQty > 0 && numValue > 0) { + const totalCost = numValue * productQty; + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); + } else if (numValue === 0) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, 0); + } + } + }, + [formik] + ); + + // Auto-recalculate when product quantity changes useEffect(() => { formik.values.deliveries?.forEach((delivery, idx) => { const productQty = delivery.products.reduce( (sum, p) => sum + p.product_qty, 0 ); - if (productQty && delivery.delivery_cost) { + + // If delivery_cost is set, recalculate delivery_cost_per_item + if ( + delivery.delivery_cost && + delivery.delivery_cost > 0 && + productQty > 0 + ) { const perItem = delivery.delivery_cost / productQty; - formik.setFieldValue( - `deliveries.${idx}.delivery_cost_per_item`, - perItem - ); + if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + perItem + ); + } + } + // If delivery_cost_per_item is set, recalculate delivery_cost + else if ( + delivery.delivery_cost_per_item && + delivery.delivery_cost_per_item > 0 && + productQty > 0 + ) { + const totalCost = delivery.delivery_cost_per_item * productQty; + if (Math.abs((delivery.delivery_cost || 0) - totalCost) > 0.01) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); + } } }); - }, [formik.values.deliveries]); + }, [ + formik.values.deliveries + ?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0)) + .join(','), + ]); useEffect(() => { if ( @@ -355,173 +468,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } }, [formik.values.source_warehouse_id]); - // Effect to populate product labels from ProductWarehouse data - useEffect(() => { - if (!productWarehouses || !isResponseSuccess(productWarehouses)) return; - if (type !== 'edit' && type !== 'detail') return; - - let hasUpdates = false; - const updatedProducts = formik.values.products?.map((product) => { - if (product.product && product.product.label.startsWith('Product ID:')) { - const productWarehouse = productWarehouses.data.find( - (pw) => pw.product.id === product.product_id - ); - if (productWarehouse) { - hasUpdates = true; - return { - ...product, - product: { - value: productWarehouse.product.id, - label: productWarehouse.product.name, - }, - }; - } - } - return product; - }); - - if (hasUpdates && updatedProducts) { - formik.setFieldValue('products', updatedProducts); - - const updatedDeliveries = formik.values.deliveries?.map((delivery) => { - const updatedDeliveryProducts = delivery.products.map( - (deliveryProduct) => { - if ( - deliveryProduct.product && - deliveryProduct.product.label.startsWith('Product ID:') - ) { - const productWarehouse = productWarehouses.data.find( - (pw) => pw.product.id === deliveryProduct.product_id - ); - if (productWarehouse) { - return { - ...deliveryProduct, - product: { - value: productWarehouse.product.id, - label: productWarehouse.product.name, - }, - }; - } - } - return deliveryProduct; - } - ); - return { - ...delivery, - products: updatedDeliveryProducts, - }; - }); - formik.setFieldValue('deliveries', updatedDeliveries); - } - }, [productWarehouses, type]); - - useEffect(() => { - if (type !== 'edit' && type !== 'detail') return; - - const productIdsToFetch: number[] = []; - - formik.values.products?.forEach((product) => { - if ( - product.product && - product.product.label.startsWith('Product ID:') && - product.product_id > 0 && - !fetchedProductIds.has(product.product_id) - ) { - productIdsToFetch.push(product.product_id); - } - }); - - formik.values.deliveries?.forEach((delivery) => { - delivery.products.forEach((deliveryProduct) => { - if ( - deliveryProduct.product && - deliveryProduct.product.label.startsWith('Product ID:') && - deliveryProduct.product_id > 0 && - !fetchedProductIds.has(deliveryProduct.product_id) - ) { - if (!productIdsToFetch.includes(deliveryProduct.product_id)) { - productIdsToFetch.push(deliveryProduct.product_id); - } - } - }); - }); - - if (productIdsToFetch.length === 0) return; - - const fetchProducts = async () => { - const productMap = new Map(); - const newFetchedIds = new Set(fetchedProductIds); - - for (const productId of productIdsToFetch) { - try { - const response = await ProductApi.getSingle(productId); - if (isResponseSuccess(response)) { - const product = response.data; - productMap.set(product.id, { id: product.id, name: product.name }); - newFetchedIds.add(productId); - } - } catch (error) { - console.error(`Failed to fetch product ${productId}:`, error); - newFetchedIds.add(productId); - } - } - - if (productMap.size > 0) { - const updatedProducts = formik.values.products?.map((p) => { - const productData = productMap.get(p.product_id); - if (productData) { - return { - ...p, - product: { - value: productData.id, - label: productData.name, - }, - }; - } - return p; - }); - - const updatedDeliveries = formik.values.deliveries?.map((delivery) => { - const updatedDeliveryProducts = delivery.products.map( - (deliveryProduct) => { - const productData = productMap.get(deliveryProduct.product_id); - if (productData) { - return { - ...deliveryProduct, - product: { - value: productData.id, - label: productData.name, - }, - }; - } - return deliveryProduct; - } - ); - return { - ...delivery, - products: updatedDeliveryProducts, - }; - }); - - if (updatedProducts) { - formik.setFieldValue('products', updatedProducts); - } - if (updatedDeliveries) { - formik.setFieldValue('deliveries', updatedDeliveries); - } - } - - setFetchedProductIds(newFetchedIds); - }; - - fetchProducts(); - }, [ - formik.values.products, - formik.values.deliveries, - type, - fetchedProductIds, - ]); - const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products @@ -544,6 +490,33 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [productWarehouseOptions, type] ); + const getProductQtyAdornment = useCallback( + (productIdx: number) => { + if (type === 'detail') return null; + const product = formik.values.products?.[productIdx]; + if (!product || !product.product_id) return null; + + const availableStock = getAvailableStock(product.product_id); + const requestedQty = Number(product.product_qty) || 0; + const remainingStock = availableStock - requestedQty; + + if (requestedQty > 0) { + return ( + + (sisa: {remainingStock.toLocaleString('id-ID')}) + + ); + } + + return ( + + (tersedia: {availableStock.toLocaleString('id-ID')}) + + ); + }, + [formik.values.products, getAvailableStock, type] + ); + const getProductQtyError = useCallback( (productIdx: number) => { if (type === 'detail') return null; @@ -919,7 +892,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); formik.setFieldValue( `products.${idx}.product_id`, - (val as OptionType)?.value + (val as ProductWarehouseOptionType)?.value ); }} options={productWarehouseOptions} @@ -943,46 +916,35 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { /> -
- - {type !== 'detail' && product.product_id && ( -
- - Stok tersedia: - {' '} - {getAvailableStock( - product.product_id - ).toLocaleString('id-ID')} -
- )} -
+ {type !== 'detail' && ( @@ -1210,8 +1172,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { required type='number' name={`deliveries.${idx}.delivery_cost`} - value={delivery.delivery_cost} - onChange={formik.handleChange} + value={delivery.delivery_cost || ''} + onChange={(e) => + handleDeliveryCostChange(idx, e.target.value) + } onBlur={formik.handleBlur} {...isRepeaterInputError( 'deliveries', @@ -1223,17 +1187,23 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + handleDeliveryCostPerItemChange( + idx, + e.target.value + ) } - readOnly - className={{ - input: 'bg-base-200', - }} + onBlur={formik.handleBlur} + {...isRepeaterInputError( + 'deliveries', + 'delivery_cost_per_item', + idx + )} + readOnly={type === 'detail'} /> diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index 9e156a1e..87a03f95 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -1,23 +1,37 @@ import { BaseMetadata } from '@/types/api/api-general'; import { Supplier } from '@/types/api/master-data/supplier'; -import { Warehouse } from '@/types/api/master-data/warehouse'; + +type MovementWarehouse = { + id: number; + name: string; + location: { + id: number; + name: string; + } | null; + area: { + id: number; + name: string; + }; +}; export type BaseMovement = { id: number; transfer_reason: string; transfer_date: string; - source_warehouse: Warehouse; - destination_warehouse: Warehouse; + source_warehouse: MovementWarehouse; + destination_warehouse: MovementWarehouse; details: { id: number; - product_id: number; + product: { + id: number; + name: string; + }; quantity: number; before_quantity: number; after_quantity: number; }[]; deliveries: { id: number; - supplier_id: number; supplier: Supplier; vehicle_plate: string; driver_name: string;