mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
feat(FE-62,63,65): enhance MovementForm with product warehouse selection, delivery document handling, and stock validation
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user