feat(FE-62,63,65): enhance MovementForm with product warehouse selection, delivery document handling, and stock validation

This commit is contained in:
rstubryan
2025-10-16 15:29:26 +07:00
parent c6a0c542aa
commit f5ce898bd2
2 changed files with 197 additions and 60 deletions
@@ -25,11 +25,8 @@ import {
DeliverySchema, DeliverySchema,
} from '@/components/pages/inventory/movement/form/MovementForm.schema'; } from '@/components/pages/inventory/movement/form/MovementForm.schema';
import { useMovementFormHandlers } from './useMovementFormHandlers'; import { useMovementFormHandlers } from './useMovementFormHandlers';
import { import { SupplierApi, WarehouseApi } from '@/services/api/master-data';
ProductApi, import { ProductWarehouseApi } from '@/services/api/inventory';
SupplierApi,
WarehouseApi,
} from '@/services/api/master-data';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import FileInput from '@/components/input/FileInput'; import FileInput from '@/components/input/FileInput';
@@ -40,6 +37,10 @@ interface MovementFormProps {
const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [, setMovementFormErrorMessage] = useState(''); const [, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
@@ -67,7 +68,43 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
validateOnMount: false, validateOnMount: false,
enableReinitialize: true, enableReinitialize: true,
onSubmit: async (values) => { onSubmit: async (values) => {
console.log('=== FORM SUBMIT DEBUG ===');
console.log('1. Form values received:', values);
setMovementFormErrorMessage(''); setMovementFormErrorMessage('');
const documents: File[] = [];
const deliveriesPayload = values.deliveries.map((d, idx) => {
let documentIndex = 0;
console.log(`2. Processing delivery ${idx}:`, {
driver_name: d.driver_name,
document: d.document,
documentType: d.document instanceof File ? 'File' : typeof d.document,
documentSize: d.document instanceof File ? d.document.size : 'N/A',
});
if (d.document && d.document instanceof File) {
documents.push(d.document);
documentIndex = documents.length - 1;
console.log(` → Document added at index ${documentIndex}`);
} else {
console.log(` → No document for delivery ${idx}, using index 0`);
}
return {
delivery_cost: d.delivery_cost,
delivery_cost_per_item: d.delivery_cost_per_item ?? 0,
document_index: documentIndex,
driver_name: d.driver_name,
vehicle_plate: d.vehicle_plate,
supplier_id: d.supplier_id,
products: d.products.map((p) => ({
product_id: p.product_id,
product_qty: p.product_qty,
})),
};
});
const payload: CreateMovementPayload = { const payload: CreateMovementPayload = {
transfer_reason: values.transfer_reason, transfer_reason: values.transfer_reason,
transfer_date: values.transfer_date, transfer_date: values.transfer_date,
@@ -77,26 +114,34 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
product_id: p.product_id, product_id: p.product_id,
product_qty: p.product_qty, product_qty: p.product_qty,
})), })),
deliveries: values.deliveries.map((d) => ({ deliveries: deliveriesPayload,
delivery_cost: d.delivery_cost,
delivery_cost_per_item: d.delivery_cost_per_item ?? 0,
document: d.document instanceof File ? d.document : d.document,
driver_name: d.driver_name,
vehicle_plate: d.vehicle_plate,
supplier_id: d.supplier_id,
products: d.products.map((p) => ({
product_id: p.product_id,
product_qty: p.product_qty,
})),
})),
}; };
console.log('3. Final payload structure:', {
...payload,
});
console.log(
'4. Document indices in deliveries:',
deliveriesPayload.map((d, i) => ({
delivery: i,
document_index: d.document_index,
}))
);
console.log('5. Total documents:', documents.length);
console.log('=== END SUBMIT DEBUG ===');
switch (type) { switch (type) {
case 'add': case 'add':
await createMovementHandler(payload); await createMovementHandler(payload, documents);
break; break;
case 'edit': case 'edit':
await updateMovementHandler(initialValues?.id as number, payload); await updateMovementHandler(
initialValues?.id as number,
payload,
documents
);
break; break;
} }
}, },
@@ -144,7 +189,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{ {
delivery_cost: 0, delivery_cost: 0,
delivery_cost_per_item: 0, delivery_cost_per_item: 0,
document: '', document: null,
driver_name: '', driver_name: '',
vehicle_plate: '', vehicle_plate: '',
supplier: null, supplier: null,
@@ -265,15 +310,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
})) }))
: []; : [];
// Product selection // Product Warehouse selection - Filter by source warehouse
const [productSelectInputValue, setProductSelectInputValue] = useState(''); const productWarehouseParams = new URLSearchParams({
const productsUrl = `${ProductApi.basePath}?${new URLSearchParams({ search: productSelectInputValue }).toString()}`; search: productWarehouseSelectInputValue,
const { data: products, isLoading: isLoadingProducts } = useSWR( });
productsUrl, if (formik.values.source_warehouse_id) {
ProductApi.getAllFetcher productWarehouseParams.append(
); 'warehouse_id',
const productOptions = isResponseSuccess(products) formik.values.source_warehouse_id.toString()
? products?.data.map((p) => ({ value: p.id, label: p.name })) );
}
const productWarehousesUrl = `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`;
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } =
useSWR(
formik.values.source_warehouse_id ? productWarehousesUrl : null,
ProductWarehouseApi.getAllFetcher
);
const productWarehouseOptions = isResponseSuccess(productWarehouses)
? productWarehouses?.data.map((pw) => ({
value: pw.id,
label: pw.product.name,
product_id: pw.product.id,
warehouse_id: pw.warehouse.id,
warehouse_name: pw.warehouse.name,
quantity: pw.quantity,
}))
: []; : [];
// Supplier selection // Supplier selection
@@ -303,7 +364,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}); });
}, [formik.values.deliveries]); }, [formik.values.deliveries]);
const getFilteredProductOptions = useCallback(() => { useEffect(() => {
if (formik.values.source_warehouse_id && type !== 'edit') {
formik.setFieldValue('products', []);
formik.setFieldValue('deliveries', []);
}
}, [formik.values.source_warehouse_id]);
const getFilteredProductWarehouseOptions = useCallback(() => {
return ( return (
formik.values.products formik.values.products
?.filter((p) => p.product) ?.filter((p) => p.product)
@@ -314,6 +382,33 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
); );
}, [formik.values.products]); }, [formik.values.products]);
const getAvailableStock = useCallback(
(productId: number) => {
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.quantity ?? 0;
},
[productWarehouseOptions]
);
const getProductQtyError = useCallback(
(productIdx: number) => {
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;
if (requestedQty > availableStock) {
return `Qty melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`;
}
return null;
},
[formik.values.products, getAvailableStock]
);
const validateDeliveryQty = useCallback( const validateDeliveryQty = useCallback(
(deliveryIdx: number, deliveryProductIdx: number, qty: number) => { (deliveryIdx: number, deliveryProductIdx: number, qty: number) => {
const delivery = formik.values.deliveries?.[deliveryIdx]; const delivery = formik.values.deliveries?.[deliveryIdx];
@@ -406,6 +501,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[invalidQtyRows] [invalidQtyRows]
); );
const hasExceededStock = useMemo(() => {
return (
formik.values.products?.some((product, idx) => {
return getProductQtyError(idx) !== null;
}) ?? false
);
}, [formik.values.products, getProductQtyError]);
return ( return (
<> <>
<section className='w-full max-w-5xl'> <section className='w-full max-w-5xl'>
@@ -656,10 +759,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(val as OptionType)?.value (val as OptionType)?.value
); );
}} }}
options={productOptions} options={productWarehouseOptions}
onInputChange={setProductSelectInputValue} onInputChange={setProductWarehouseSelectInputValue}
isLoading={isLoadingProducts} isLoading={isLoadingProductWarehouses}
isDisabled={type === 'detail'} isDisabled={
type === 'detail' ||
!formik.values.source_warehouse_id
}
placeholder={
!formik.values.source_warehouse_id
? 'Pilih gudang asal terlebih dahulu'
: 'Pilih produk'
}
isClearable isClearable
{...isRepeaterInputError( {...isRepeaterInputError(
'products', 'products',
@@ -669,23 +780,46 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
/> />
</td> </td>
<td> <td>
<TextInput <div className='flex flex-col gap-2'>
required <TextInput
type='number' required
name={`products.${idx}.product_qty`} type='number'
value={product.product_qty ?? ''} name={`products.${idx}.product_qty`}
onChange={formik.handleChange} value={product.product_qty ?? ''}
onBlur={formik.handleBlur} onChange={formik.handleChange}
{...isRepeaterInputError( onBlur={formik.handleBlur}
'products', isError={
'product_qty', isRepeaterInputError(
idx 'products',
'product_qty',
idx
).isError || Boolean(getProductQtyError(idx))
}
errorMessage={
isRepeaterInputError(
'products',
'product_qty',
idx
).errorMessage ||
getProductQtyError(idx) ||
undefined
}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
{product.product_id && (
<div className='text-sm text-gray-600'>
<span className='font-semibold'>
Stok tersedia:
</span>{' '}
{getAvailableStock(
product.product_id
).toLocaleString('id-ID')}
</div>
)} )}
readOnly={type === 'detail'} </div>
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td> </td>
{type !== 'detail' && ( {type !== 'detail' && (
<td> <td>
@@ -819,7 +953,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(val as OptionType)?.value (val as OptionType)?.value
); );
}} }}
options={getFilteredProductOptions()} options={getFilteredProductWarehouseOptions()}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
@@ -886,7 +1020,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</td> </td>
<td> <td>
<FileInput <FileInput
required
name={`deliveries.${idx}.document`} name={`deliveries.${idx}.document`}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -1016,7 +1149,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
: undefined : undefined
} }
onDelete={deleteMovementClickHandler} onDelete={deleteMovementClickHandler}
disableSubmit={hasInvalidQty} disableSubmit={hasInvalidQty || hasExceededStock}
/> />
{movementFormErrorMessage && ( {movementFormErrorMessage && (
@@ -24,7 +24,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
let finalPayload: CreateMovementPayload | FormData; let finalPayload: CreateMovementPayload | FormData;
if (documents.length > 0) { if (documents.length > 0) {
// Ada dokumen: kirim sebagai FormData dengan "data" field
console.log('3. Creating FormData (has documents)'); console.log('3. Creating FormData (has documents)');
const formData = new FormData(); const formData = new FormData();
formData.append('data', JSON.stringify(payload)); formData.append('data', JSON.stringify(payload));
@@ -35,7 +34,9 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
console.log('4. FormData entries:'); console.log('4. FormData entries:');
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value instanceof File) { if (value instanceof File) {
console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); console.log(
` ${key}: [File] ${value.name} (${value.size} bytes)`
);
} else { } else {
console.log(` ${key}: ${value}`); console.log(` ${key}: ${value}`);
} }
@@ -43,7 +44,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
finalPayload = formData as unknown as CreateMovementPayload; finalPayload = formData as unknown as CreateMovementPayload;
} else { } else {
// Tidak ada dokumen: kirim sebagai JSON biasa
console.log('3. Sending as JSON (no documents)'); console.log('3. Sending as JSON (no documents)');
console.log('4. Payload:', JSON.stringify(payload, null, 2)); console.log('4. Payload:', JSON.stringify(payload, null, 2));
finalPayload = payload; finalPayload = payload;
@@ -64,7 +64,11 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
); );
const updateMovementHandler = useCallback( const updateMovementHandler = useCallback(
async (movementId: number, payload: UpdateMovementPayload, documents: File[] = []) => { async (
movementId: number,
payload: UpdateMovementPayload,
documents: File[] = []
) => {
console.log('=== UPDATE HANDLER DEBUG ==='); console.log('=== UPDATE HANDLER DEBUG ===');
console.log('1. Received payload:', payload); console.log('1. Received payload:', payload);
console.log('2. Movement ID:', movementId); console.log('2. Movement ID:', movementId);
@@ -73,7 +77,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
let finalPayload: UpdateMovementPayload | FormData; let finalPayload: UpdateMovementPayload | FormData;
if (documents.length > 0) { if (documents.length > 0) {
// Ada dokumen: kirim sebagai FormData dengan "data" field
console.log('4. Creating FormData (has documents)'); console.log('4. Creating FormData (has documents)');
const formData = new FormData(); const formData = new FormData();
formData.append('data', JSON.stringify(payload)); formData.append('data', JSON.stringify(payload));
@@ -84,7 +87,9 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
console.log('5. FormData entries:'); console.log('5. FormData entries:');
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value instanceof File) { if (value instanceof File) {
console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); console.log(
` ${key}: [File] ${value.name} (${value.size} bytes)`
);
} else { } else {
console.log(` ${key}: ${value}`); console.log(` ${key}: ${value}`);
} }
@@ -92,7 +97,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
finalPayload = formData as unknown as UpdateMovementPayload; finalPayload = formData as unknown as UpdateMovementPayload;
} else { } else {
// Tidak ada dokumen: kirim sebagai JSON biasa
console.log('4. Sending as JSON (no documents)'); console.log('4. Sending as JSON (no documents)');
console.log('5. Payload:', JSON.stringify(payload, null, 2)); console.log('5. Payload:', JSON.stringify(payload, null, 2));
finalPayload = payload; finalPayload = payload;