refactor(FE-137): simplify stock display in MovementForm and RecordingForm, enhance input handling in SelectInput

This commit is contained in:
rstubryan
2025-10-27 11:05:06 +07:00
parent 943c0e05b9
commit 58369b8ffa
3 changed files with 114 additions and 32 deletions
+24
View File
@@ -48,6 +48,7 @@ interface SelectInputBaseProps<T = OptionType> {
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
@@ -82,6 +83,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
delay = 300,
createables = false,
onInputChange,
startAdornment,
} = props;
const [internalInputValue, setInternalInputValue] = useState('');
@@ -205,6 +207,28 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
...(startAdornment ? {
Control: ({ children, innerRef, innerProps, menuIsOpen, isFocused, isDisabled }) => (
<div
ref={innerRef}
{...innerProps}
className={cn(
'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer! flex items-center',
{
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'border-gray-300': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
}
)}
>
<div className='flex-1 px-4! py-2! gap-1 flex items-center'>
{startAdornment}
{children}
</div>
</div>
),
} : {}),
}}
menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined
@@ -111,14 +111,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
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}`,
warehouseStockMap.get(w.id);
return {
value: w.id,
label: w.name,
area: w.area?.name,
location:
'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG')
@@ -223,7 +219,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
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')})`,
label: pw.product.name,
product_id: pw.product.id,
warehouse_id: pw.warehouse.id,
warehouse_name: pw.warehouse.name,
@@ -464,31 +460,77 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[productWarehouseOptions, type]
);
const getProductQtyAdornment = useCallback(
const getProductQtyBottomLabel = useCallback(
(productIdx: number) => {
if (type === 'detail') return null;
if (type === 'detail') return undefined;
const product = formik.values.products?.[productIdx];
if (!product || !product.product_id) return null;
if (!product || !product.product_id) return undefined;
const availableStock = getAvailableStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0;
const remainingStock = availableStock - requestedQty;
if (requestedQty > 0) {
return `Sisa: ${remainingStock.toLocaleString('en-US')}`;
}
return `Tersedia: ${availableStock.toLocaleString('en-US')}`;
},
[formik.values.products, getAvailableStock, type]
);
const getDeliveryProductQtyBottomLabel = useCallback(
(deliveryIdx: number, productIdx: number) => {
if (type === 'detail') return undefined;
const delivery = formik.values.deliveries?.[deliveryIdx];
if (!delivery) return undefined;
const deliveryProduct = delivery.products[productIdx];
if (!deliveryProduct || !deliveryProduct.product_id) return undefined;
const relatedProduct = formik.values.products?.find(
(p) => p.product_id === deliveryProduct.product_id
);
if (!relatedProduct) return undefined;
const totalQtyUsed =
formik.values.deliveries?.reduce((total, d, dIdx) => {
const productQty = d.products.reduce((sum, p, pIdx) => {
if (
p.product_id === deliveryProduct.product_id &&
!(dIdx === deliveryIdx && pIdx === productIdx)
) {
return sum + (Number(p.product_qty) || 0);
}
return sum;
}, 0);
return total + productQty;
}, 0) || 0;
const availableQty = Number(relatedProduct.product_qty) - totalQtyUsed;
return `Tersedia: ${availableQty.toLocaleString('en-US')}`;
},
[formik.values.deliveries, formik.values.products, type]
);
const getWarehouseStockAdornment = useCallback(
(warehouseId: number) => {
const stockInfo = warehouseStockMap.get(warehouseId);
if (!stockInfo) {
return (
<span className='text-sm text-gray-600 whitespace-nowrap'>
(sisa: {remainingStock.toLocaleString('id-ID')})
<span className='text-sm text-gray-500 whitespace-nowrap'>
(Kosong)
</span>
);
}
return (
<span className='text-sm text-gray-600 whitespace-nowrap'>
(tersedia: {availableStock.toLocaleString('id-ID')})
<span className='text-sm text-gray-500 whitespace-nowrap'>
(Tersedia {stockInfo.productCount} produk)
</span>
);
},
[formik.values.products, getAvailableStock, type]
[warehouseStockMap]
);
const getProductQtyError = useCallback(
@@ -501,7 +543,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const requestedQty = Number(product.product_qty) || 0;
if (requestedQty > availableStock) {
return `Qty melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`;
return `Qty melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('en-US')}`;
}
return null;
@@ -746,6 +788,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
errorMessage={formik.errors.source_warehouse_id as string}
isDisabled={type === 'detail'}
isClearable
startAdornment={
formik.values.source_warehouse_id
? getWarehouseStockAdornment(formik.values.source_warehouse_id)
: undefined
}
/>
{/* Area and Location Info */}
@@ -808,6 +855,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}
isDisabled={type === 'detail'}
isClearable
startAdornment={
formik.values.destination_warehouse_id
? getWarehouseStockAdornment(formik.values.destination_warehouse_id)
: undefined
}
/>
{/* Area and Location Info */}
@@ -981,14 +1033,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
/>
</td>
<td>
<TextInput
<NumberInput
required
type='number'
name={`products.${idx}.product_qty`}
value={product.product_qty ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
endAdornment={getProductQtyAdornment(idx)}
decimalScale={0}
allowNegative={false}
thousandSeparator=","
decimalSeparator="."
bottomLabel={getProductQtyBottomLabel(idx)}
isError={
isRepeaterInputError(
'products',
@@ -1240,13 +1295,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
/>
</td>
<td>
<TextInput
<NumberInput
required
type='number'
name={`deliveries.${idx}.products.0.product_qty`}
value={delivery.products[0]?.product_qty ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
decimalScale={0}
allowNegative={false}
thousandSeparator=","
decimalSeparator="."
isError={
isDeliveryProductInputError(idx, 0, 'product_qty')
.isError || Boolean(getDeliveryQtyError(idx, 0))
@@ -1257,6 +1315,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
getDeliveryQtyError(idx, 0) ||
undefined
}
bottomLabel={getDeliveryProductQtyBottomLabel(idx, 0)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-48',
@@ -123,8 +123,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
projectFlock.kandangs.forEach((kandang) => {
const isAlreadyRecorded = recordedProjectFlockIds.has(projectFlock.id);
const label = isAlreadyRecorded
? `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name} (Sudah Direcord)`
: `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name}`;
? `${projectFlock.flock.name} - ${kandang.name} (Sudah Direcord)`
: `${projectFlock.flock.name} - ${kandang.name}`;
options.push({
value: projectFlock.id,
@@ -140,22 +140,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isResponseSuccess(stockProducts)) {
stockProducts.data.forEach((product) => {
const warehouse = product.warehouse;
const stockText = product.quantity.toLocaleString('id-ID');
product.quantity.toLocaleString('en-US');
const hasPakanFlag = product.product.flags?.includes('PAKAN');
const hasOvkFlag = product.product.flags?.includes('OVK');
if (hasPakanFlag) {
options.push({
value: product.id,
label: `[PAKAN] ${product.product.name} - ${warehouse?.name || ''} (${stockText})`
label: `[PAKAN] ${product.product.name} - ${warehouse?.name || ''}`
});
}
if (hasOvkFlag) {
options.push({
value: product.id,
label: `[OVK] ${product.product.name} - ${warehouse?.name || ''} (${stockText})`
label: `[OVK] ${product.product.name} - ${warehouse?.name || ''}`
});
}
});
@@ -293,7 +292,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const availableStock = getAvailableStock(stock.product_warehouse_id);
const requestedUsage = Number(stock.usage_amount) || 0;
if (requestedUsage > availableStock) {
return `Jumlah pakai melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`;
return `Jumlah pakai melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('en-US')}`;
}
return null;
},
@@ -311,13 +310,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (requestedUsage > 0) {
return (
<span className='text-sm text-gray-600 whitespace-nowrap'>
(sisa: {remainingStock.toLocaleString('id-ID')})
(sisa: {remainingStock.toLocaleString('en-US')})
</span>
);
}
return (
<span className='text-sm text-gray-600 whitespace-nowrap'>
(tersedia: {availableStock.toLocaleString('id-ID')})
(tersedia: {availableStock.toLocaleString('en-US')})
</span>
);
},