mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-24 07:15:44 +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,
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user