refactor(FE-64): integrate product and supplier selection with API data fetching in MovementForm

This commit is contained in:
rstubryan
2025-10-24 21:10:03 +07:00
parent 9c5dc0dbb5
commit 896a0c6de2
@@ -37,24 +37,102 @@ interface MovementFormProps {
}
const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== STATE MANAGEMENT =====
const [, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [productWarehouseSelectInputValue, setProductWarehouseSelectInputValue] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [warehouseSelectInputValue, setWarehouseSelectInputValue] = useState('');
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
// ===== FORM HANDLERS =====
const {
deleteModal,
movementFormErrorMessage,
isDeleteLoading,
createMovementHandler,
updateMovementHandler,
deleteMovementClickHandler,
confirmationModalDeleteClickHandler,
} = useMovementFormHandlers(initialValues?.id);
// ===== INTERFACES =====
interface WarehouseOptionType extends OptionType {
area?: string;
location?: string;
}
interface ProductWarehouseOptionType extends OptionType {
product_id: number;
warehouse_id: number;
warehouse_name: string;
quantity: number;
}
// ===== API DATA FETCHING =====
const allProductWarehousesUrl = `${ProductWarehouseApi.basePath}`;
const { data: allProductWarehouses } = useSWR(
allProductWarehousesUrl,
ProductWarehouseApi.getAllFetcher
);
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
// ===== DATA PROCESSING =====
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]);
const warehouseOptions = isResponseSuccess(warehouses)
? 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,
};
})
: [];
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data.map((s) => ({ value: s.id, label: s.name }))
: [];
// ===== FORM INITIALIZATION =====
const formikInitialValues = useMemo<MovementFormValues>(
() => getMovementFormInitialValues(initialValues),
[initialValues]
@@ -77,7 +155,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (d.document && d.document instanceof File) {
documents.push(d.document);
documentIndex = documents.length - 1;
} else {
}
return {
@@ -122,91 +199,39 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
},
});
const addProduct = () => {
const newProducts = [
...(formik.values.products || []),
{
product: null,
product_id: 0,
product_qty: 0,
},
];
formik.setFieldValue('products', newProducts);
};
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const getProductWarehousesUrl = useCallback(() => {
const productWarehouseParams = new URLSearchParams({
search: productWarehouseSelectInputValue,
});
if (formik.values.source_warehouse_id) {
productWarehouseParams.append(
'warehouse_id',
formik.values.source_warehouse_id.toString()
);
}
return `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`;
}, [formik.values.source_warehouse_id, productWarehouseSelectInputValue]);
const removeProduct = useCallback(
(i: number) => {
const updatedProducts =
formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
}, []) ?? [];
const productWarehousesUrl = getProductWarehousesUrl();
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } =
useSWR(
formik.values.source_warehouse_id ? productWarehousesUrl : null,
ProductWarehouseApi.getAllFetcher
);
formik.setFieldValue('products', updatedProducts);
},
[formik]
);
const bulkRemoveProduct = useCallback(() => {
const updatedProducts =
formik.values.products?.filter(
(_, idx) => !selectedProducts.includes(idx)
) ?? [];
formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]);
}, [formik, selectedProducts]);
const addDelivery = () => {
formik.setFieldValue('deliveries', [
...(formik.values.deliveries || []),
{
delivery_cost: undefined,
delivery_cost_per_item: undefined,
document: null,
driver_name: '',
vehicle_plate: '',
supplier: null,
supplier_id: 0,
products: [
{
product: null,
product_id: 0,
product_qty: 0,
},
],
},
]);
};
const removeDelivery = useCallback(
(i: number) => {
const updatedDeliveries =
formik.values.deliveries?.reduce(
(acc: DeliverySchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
},
[]
) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries);
},
[formik]
);
const bulkRemoveDelivery = useCallback(() => {
const updatedDeliveries =
formik.values.deliveries?.filter(
(_, idx) => !selectedDeliveries.includes(idx)
) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]);
}, [formik, selectedDeliveries]);
const productWarehouseOptions = isResponseSuccess(productWarehouses)
? productWarehouses?.data.map((pw) => ({
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,
quantity: pw.quantity,
}))
: [];
// ===== HELPER FUNCTIONS =====
const isRepeaterInputError = <T extends 'products' | 'deliveries'>(
arrayName: T,
column: T extends 'products' ? keyof ProductSchema : keyof DeliverySchema,
@@ -263,118 +288,98 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
};
};
interface WarehouseOptionType extends OptionType {
area?: string;
location?: string;
}
// ===== EVENT HANDLERS =====
// Product Handlers
const addProduct = () => {
const newProducts = [
...(formik.values.products || []),
{
product: null,
product_id: 0,
product_qty: 0,
},
];
formik.setFieldValue('products', newProducts);
};
interface ProductWarehouseOptionType extends OptionType {
product_id: number;
warehouse_id: number;
warehouse_name: string;
quantity: number;
}
const removeProduct = useCallback(
(i: number) => {
const updatedProducts =
formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
}, []) ?? [];
const allProductWarehousesUrl = `${ProductWarehouseApi.basePath}`;
const { data: allProductWarehouses } = useSWR(
allProductWarehousesUrl,
ProductWarehouseApi.getAllFetcher
formik.setFieldValue('products', updatedProducts);
},
[formik]
);
const warehouseStockMap = useMemo(() => {
if (!isResponseSuccess(allProductWarehouses)) return new Map();
const bulkRemoveProduct = useCallback(() => {
const updatedProducts =
formik.values.products?.filter(
(_, idx) => !selectedProducts.includes(idx)
) ?? [];
formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]);
}, [formik, selectedProducts]);
const stockMap = new Map<
number,
{ totalQty: number; productCount: number }
>();
// Delivery Handlers
const addDelivery = () => {
formik.setFieldValue('deliveries', [
...(formik.values.deliveries || []),
{
delivery_cost: undefined,
delivery_cost_per_item: undefined,
document: null,
driver_name: '',
vehicle_plate: '',
supplier: null,
supplier_id: 0,
products: [
{
product: null,
product_id: 0,
product_qty: 0,
},
],
},
]);
};
allProductWarehouses.data.forEach((pw) => {
const warehouseId = pw.warehouse.id;
const existing = stockMap.get(warehouseId) || {
totalQty: 0,
productCount: 0,
};
const removeDelivery = useCallback(
(i: number) => {
const updatedDeliveries =
formik.values.deliveries?.reduce(
(acc: DeliverySchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
},
[]
) ?? [];
stockMap.set(warehouseId, {
totalQty: existing.totalQty + pw.quantity,
productCount: existing.productCount + 1,
});
});
return stockMap;
}, [allProductWarehouses]);
// Warehouse selection
const [warehouseSelectInputValue, setWarehouseSelectInputValue] =
useState('');
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
formik.setFieldValue('deliveries', updatedDeliveries);
},
[formik]
);
const warehouseOptions = isResponseSuccess(warehouses)
? 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,
};
})
: [];
const bulkRemoveDelivery = useCallback(() => {
const updatedDeliveries =
formik.values.deliveries?.filter(
(_, idx) => !selectedDeliveries.includes(idx)
) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]);
}, [formik, selectedDeliveries]);
// 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.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,
quantity: pw.quantity,
}))
: [];
// Supplier selection
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data.map((s) => ({ value: s.id, label: s.name }))
: [];
// Handle cost calculation when delivery_cost changes
// Cost Calculation Handlers
const handleDeliveryCostChange = useCallback(
(idx: number, value: string) => {
const numValue = parseFloat(value) || 0;
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, numValue);
(idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value);
const delivery = formik.values.deliveries?.[idx];
if (delivery) {
@@ -382,13 +387,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(sum, p) => sum + p.product_qty,
0
);
if (productQty > 0 && numValue > 0) {
const perItem = numValue / productQty;
if (productQty > 0 && value > 0) {
const perItem = value / productQty;
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} else if (numValue === 0) {
} else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
}
}
@@ -396,13 +401,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[formik]
);
// Handle cost calculation when delivery_cost_per_item changes
const handleDeliveryCostPerItemChange = useCallback(
(idx: number, value: string) => {
const numValue = parseFloat(value) || 0;
(idx: number, value: number) => {
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
numValue
value
);
const delivery = formik.values.deliveries?.[idx];
@@ -411,10 +414,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(sum, p) => sum + p.product_qty,
0
);
if (productQty > 0 && numValue > 0) {
const totalCost = numValue * productQty;
if (productQty > 0 && value > 0) {
const totalCost = value * productQty;
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost);
} else if (numValue === 0) {
} else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, 0);
}
}
@@ -422,57 +425,27 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[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
);
const handleDeliveryCostChangeWrapper = useCallback(
(idx: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value.replace(/[^\d,.-]/g, '');
const normalizedValue = rawValue.replace(/,/g, '');
const value = parseFloat(normalizedValue) || 0;
handleDeliveryCostChange(idx, value);
},
[handleDeliveryCostChange]
);
// 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;
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
?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0))
.join(','),
]);
useEffect(() => {
if (
formik.values.source_warehouse_id &&
type !== 'edit' &&
type !== 'detail'
) {
formik.setFieldValue('products', []);
formik.setFieldValue('deliveries', []);
}
}, [formik.values.source_warehouse_id]);
const handleDeliveryCostPerItemChangeWrapper = useCallback(
(idx: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value.replace(/[^\d,.-]/g, '');
const normalizedValue = rawValue.replace(/,/g, '');
const value = parseFloat(normalizedValue) || 0;
handleDeliveryCostPerItemChange(idx, value);
},
[handleDeliveryCostPerItemChange]
);
// UTILITY FUNCTIONS
const getFilteredProductWarehouseOptions = useCallback(() => {
return (
formik.values.products
@@ -618,6 +591,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[formik.values.deliveries, formik.values.products, type]
);
// ===== COMPUTED VALUES =====
const invalidQtyRows = useMemo(
() =>
type === 'detail'
@@ -650,6 +624,54 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
}, [formik.values.products, getProductQtyError, type]);
// ===== EFFECTS =====
useEffect(() => {
formik.values.deliveries?.forEach((delivery, idx) => {
const productQty = delivery.products.reduce(
(sum, p) => sum + p.product_qty,
0
);
if (
delivery.delivery_cost &&
delivery.delivery_cost > 0 &&
productQty > 0
) {
const perItem = delivery.delivery_cost / productQty;
if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) {
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
}
} 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
?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0))
.join(','),
]);
useEffect(() => {
if (
formik.values.source_warehouse_id &&
type !== 'edit' &&
type !== 'detail'
) {
formik.setFieldValue('products', []);
formik.setFieldValue('deliveries', []);
}
}, [formik.values.source_warehouse_id]);
return (
<>
<section className='w-full'>
@@ -839,7 +861,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<tr>
{type !== 'detail' && (
<th>
<div className='flex justify-center'>
<CheckboxInput
name='select-all-products'
checked={
@@ -865,7 +886,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
</th>
)}
<th>
@@ -893,31 +913,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{formik.values.products?.map((product, idx) => (
<tr key={`product-row-${idx}-${product.product_id}`}>
{type !== 'detail' && (
<td>
<div className='flex justify-center'>
<CheckboxInput
name={`product-${idx}`}
checked={selectedProducts.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts([
...selectedProducts,
idx,
]);
} else {
setSelectedProducts(
selectedProducts.filter((i) => i !== idx)
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
<td className="!align-middle">
<CheckboxInput
name={`product-${idx}`}
checked={selectedProducts.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>,
) => {
if (e.target.checked) {
setSelectedProducts([
...selectedProducts,
idx,
]);
} else {
setSelectedProducts(
selectedProducts.filter((i) => i !== idx),
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</td>
)}
<td>
@@ -1061,7 +1079,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<tr>
{type !== 'detail' && (
<th>
<div className='flex justify-center'>
<CheckboxInput
name='select-all-deliveries'
checked={
@@ -1087,7 +1104,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
</th>
)}
<th>
@@ -1161,33 +1177,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{formik.values.deliveries?.map((delivery, idx) => (
<tr key={`delivery-row-${idx}`}>
{type !== 'detail' && (
<td>
<div className='flex justify-center'>
<CheckboxInput
name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
idx,
]);
} else {
setSelectedDeliveries(
selectedDeliveries.filter(
(i) => i !== idx
)
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
<td className="!align-middle">
<CheckboxInput
name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>,
) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
idx,
]);
} else {
setSelectedDeliveries(
selectedDeliveries.filter(
(i) => i !== idx,
),
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</td>
)}
<td>
@@ -1373,9 +1387,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
name={`deliveries.${idx}.delivery_cost`}
value={delivery.delivery_cost || ''}
onChange={(e) =>
handleDeliveryCostChange(idx, e.target.value)
}
onChange={handleDeliveryCostChangeWrapper(idx)}
onBlur={formik.handleBlur}
maskType='currency'
decimals={0}
@@ -1397,12 +1409,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
name={`deliveries.${idx}.delivery_cost_per_item`}
value={delivery.delivery_cost_per_item || ''}
onChange={(e) =>
handleDeliveryCostPerItemChange(
idx,
e.target.value
)
}
onChange={handleDeliveryCostPerItemChangeWrapper(idx)}
onBlur={formik.handleBlur}
maskType='currency'
decimals={0}