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,
} from '@/components/pages/inventory/movement/form/MovementForm.schema';
import { useMovementFormHandlers } from './useMovementFormHandlers';
import {
ProductApi,
SupplierApi,
WarehouseApi,
} 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';
@@ -40,6 +37,10 @@ interface MovementFormProps {
const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
@@ -67,7 +68,43 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
validateOnMount: false,
enableReinitialize: true,
onSubmit: async (values) => {
console.log('=== FORM SUBMIT DEBUG ===');
console.log('1. Form values received:', values);
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 = {
transfer_reason: values.transfer_reason,
transfer_date: values.transfer_date,
@@ -77,26 +114,34 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
product_id: p.product_id,
product_qty: p.product_qty,
})),
deliveries: values.deliveries.map((d) => ({
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,
})),
})),
deliveries: deliveriesPayload,
};
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) {
case 'add':
await createMovementHandler(payload);
await createMovementHandler(payload, documents);
break;
case 'edit':
await updateMovementHandler(initialValues?.id as number, payload);
await updateMovementHandler(
initialValues?.id as number,
payload,
documents
);
break;
}
},
@@ -144,7 +189,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{
delivery_cost: 0,
delivery_cost_per_item: 0,
document: '',
document: null,
driver_name: '',
vehicle_plate: '',
supplier: null,
@@ -265,15 +310,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}))
: [];
// Product selection
const [productSelectInputValue, setProductSelectInputValue] = useState('');
const productsUrl = `${ProductApi.basePath}?${new URLSearchParams({ search: productSelectInputValue }).toString()}`;
const { data: products, isLoading: isLoadingProducts } = useSWR(
productsUrl,
ProductApi.getAllFetcher
);
const productOptions = isResponseSuccess(products)
? products?.data.map((p) => ({ value: p.id, label: p.name }))
// Product Warehouse selection - Filter by source warehouse
const productWarehouseParams = new URLSearchParams({
search: productWarehouseSelectInputValue,
});
if (formik.values.source_warehouse_id) {
productWarehouseParams.append(
'warehouse_id',
formik.values.source_warehouse_id.toString()
);
}
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
@@ -303,7 +364,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
});
}, [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 (
formik.values.products
?.filter((p) => p.product)
@@ -314,6 +382,33 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
}, [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(
(deliveryIdx: number, deliveryProductIdx: number, qty: number) => {
const delivery = formik.values.deliveries?.[deliveryIdx];
@@ -406,6 +501,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[invalidQtyRows]
);
const hasExceededStock = useMemo(() => {
return (
formik.values.products?.some((product, idx) => {
return getProductQtyError(idx) !== null;
}) ?? false
);
}, [formik.values.products, getProductQtyError]);
return (
<>
<section className='w-full max-w-5xl'>
@@ -656,10 +759,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(val as OptionType)?.value
);
}}
options={productOptions}
onInputChange={setProductSelectInputValue}
isLoading={isLoadingProducts}
isDisabled={type === 'detail'}
options={productWarehouseOptions}
onInputChange={setProductWarehouseSelectInputValue}
isLoading={isLoadingProductWarehouses}
isDisabled={
type === 'detail' ||
!formik.values.source_warehouse_id
}
placeholder={
!formik.values.source_warehouse_id
? 'Pilih gudang asal terlebih dahulu'
: 'Pilih produk'
}
isClearable
{...isRepeaterInputError(
'products',
@@ -669,23 +780,46 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
/>
</td>
<td>
<TextInput
required
type='number'
name={`products.${idx}.product_qty`}
value={product.product_qty ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
{...isRepeaterInputError(
'products',
'product_qty',
idx
<div className='flex flex-col gap-2'>
<TextInput
required
type='number'
name={`products.${idx}.product_qty`}
value={product.product_qty ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
isRepeaterInputError(
'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'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</div>
</td>
{type !== 'detail' && (
<td>
@@ -819,7 +953,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(val as OptionType)?.value
);
}}
options={getFilteredProductOptions()}
options={getFilteredProductWarehouseOptions()}
isDisabled={type === 'detail'}
isClearable
/>
@@ -886,7 +1020,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</td>
<td>
<FileInput
required
name={`deliveries.${idx}.document`}
onChange={(e) => {
const file = e.target.files?.[0];
@@ -1016,7 +1149,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
: undefined
}
onDelete={deleteMovementClickHandler}
disableSubmit={hasInvalidQty}
disableSubmit={hasInvalidQty || hasExceededStock}
/>
{movementFormErrorMessage && (
@@ -24,7 +24,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
let finalPayload: CreateMovementPayload | FormData;
if (documents.length > 0) {
// Ada dokumen: kirim sebagai FormData dengan "data" field
console.log('3. Creating FormData (has documents)');
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
@@ -35,7 +34,9 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
console.log('4. FormData entries:');
for (const [key, value] of formData.entries()) {
if (value instanceof File) {
console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`);
console.log(
` ${key}: [File] ${value.name} (${value.size} bytes)`
);
} else {
console.log(` ${key}: ${value}`);
}
@@ -43,7 +44,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
finalPayload = formData as unknown as CreateMovementPayload;
} else {
// Tidak ada dokumen: kirim sebagai JSON biasa
console.log('3. Sending as JSON (no documents)');
console.log('4. Payload:', JSON.stringify(payload, null, 2));
finalPayload = payload;
@@ -64,7 +64,11 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
);
const updateMovementHandler = useCallback(
async (movementId: number, payload: UpdateMovementPayload, documents: File[] = []) => {
async (
movementId: number,
payload: UpdateMovementPayload,
documents: File[] = []
) => {
console.log('=== UPDATE HANDLER DEBUG ===');
console.log('1. Received payload:', payload);
console.log('2. Movement ID:', movementId);
@@ -73,7 +77,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
let finalPayload: UpdateMovementPayload | FormData;
if (documents.length > 0) {
// Ada dokumen: kirim sebagai FormData dengan "data" field
console.log('4. Creating FormData (has documents)');
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
@@ -84,7 +87,9 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
console.log('5. FormData entries:');
for (const [key, value] of formData.entries()) {
if (value instanceof File) {
console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`);
console.log(
` ${key}: [File] ${value.name} (${value.size} bytes)`
);
} else {
console.log(` ${key}: ${value}`);
}
@@ -92,7 +97,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => {
finalPayload = formData as unknown as UpdateMovementPayload;
} else {
// Tidak ada dokumen: kirim sebagai JSON biasa
console.log('4. Sending as JSON (no documents)');
console.log('5. Payload:', JSON.stringify(payload, null, 2));
finalPayload = payload;