fix(FE): add sales person, refactor calculate total weight and price, add uom information and implement lazy load select

This commit is contained in:
randy-ar
2026-01-15 00:32:55 +07:00
parent 7a6bee57c2
commit 5e7f55000a
5 changed files with 286 additions and 100 deletions
@@ -11,6 +11,13 @@ import {
type MarketingSchemaType = {
customer_id: number | undefined;
sales_person_id: number | undefined;
sales_person:
| {
value: number;
label: string;
}
| undefined
| null;
customer:
| {
value: number;
@@ -33,7 +40,11 @@ type DeliveryOrderSchemaType = {
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'),
sales_person_id: Yup.number().required('Sales Person wajib diisi!'),
sales_person_id: Yup.number().required('Sales wajib diisi!'),
sales_person: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
}).nullable(),
customer: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
@@ -50,6 +50,8 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { CreatedUser } from '@/types/api/api-general';
import { UserApi } from '@/services/api/user';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -244,7 +246,15 @@ const MarketingForm = ({
const {
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
setInputValue: setInputCustomerValue,
loadMore: loadMoreCustomer,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const {
options: salesOptions,
isLoadingOptions: isLoadingSalesOptions,
setInputValue: setInputSalesValue,
loadMore: loadMoreSales,
} = useSelect<CreatedUser>(UserApi.basePath, 'id', 'name');
// ================== SETUP FORMIK ==================
const formikInitialValues = useMemo<
@@ -255,6 +265,12 @@ const MarketingForm = ({
notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined,
sales_person_id: initialValues?.sales_person?.id || 1,
sales_person: initialValues?.sales_person
? {
value: initialValues.sales_person.id,
label: initialValues.sales_person.name,
}
: null,
customer: initialValues?.customer
? {
value: initialValues.customer.id,
@@ -443,6 +459,13 @@ const MarketingForm = ({
},
[]
);
const handleChangeSalesPerson = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('sales_person_id', (val as OptionType)?.value);
formik.setFieldValue('sales_person', val as OptionType);
},
[]
);
const handleDelete = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
@@ -580,6 +603,7 @@ const MarketingForm = ({
className={{
wrapper: 'bg-white w-full',
}}
variant='bordered'
>
<div className='grid sm:grid-cols-2 gap-3 mt-3'>
<SelectInput
@@ -588,6 +612,8 @@ const MarketingForm = ({
isLoading={isLoadingCustomerOptions}
value={formik.values.customer}
onChange={handleChangeCustomer}
onInputChange={setInputCustomerValue}
onMenuScrollToBottom={loadMoreCustomer}
isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id)
}
@@ -617,6 +643,7 @@ const MarketingForm = ({
className={{
wrapper: 'bg-white w-full',
}}
variant='bordered'
>
<MemoizedSalesOrderProductTable
formType={formType}
@@ -651,19 +678,42 @@ const MarketingForm = ({
{/* Input Notes */}
<div className='grid sm:grid-cols-2 gap-3'>
<DebouncedTextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={formType === 'add_deliver' || formType === 'edit_deliver'}
/>
<div className='flex flex-col h-full justify-between items-end py-6'>
<div className='flex flex-col h-full items-end gap-3'>
<SelectInput
label='Sales'
options={salesOptions}
isLoading={isLoadingSalesOptions}
value={formik.values.sales_person}
onChange={handleChangeSalesPerson}
onInputChange={setInputSalesValue}
onMenuScrollToBottom={loadMoreSales}
isError={
formik.touched.sales_person_id &&
Boolean(formik.errors.sales_person_id)
}
errorMessage={formik.errors.sales_person_id}
isClearable
placeholder='Pilih Sales'
isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
<DebouncedTextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
</div>
<div className='flex flex-col h-full justify-end items-end'>
<span>Total Penjualan</span>
<span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}{' '}
@@ -18,6 +18,11 @@ import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
import { ProductApi } from '@/services/api/master-data';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const DeliveryOrderProductForm = ({
formState,
@@ -43,6 +48,17 @@ const DeliveryOrderProductForm = ({
);
const [currentInput, setCurrentInput] = useState<string>('');
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProduct?.value
? ProductApi.basePath + '/' + selectedProduct?.value
: null,
() =>
selectedProduct?.value
? ProductApi.getSingle(Number(selectedProduct?.value))
: undefined
);
const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id
);
@@ -113,22 +129,60 @@ const DeliveryOrderProductForm = ({
const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue('total_price', Number(qty) * Number(unit_price));
} else if (qty && total_price && field === 'total_price') {
formik.setFieldValue('unit_price', Number(total_price) / Number(qty));
const qty = Number(formik.values.qty || 0);
const avgWeight = Number(formik.values.avg_weight || 0);
const totalWeight = Number(formik.values.total_weight || 0);
const unitPrice = Number(formik.values.unit_price || 0);
const totalPrice = Number(formik.values.total_price || 0);
if (qty <= 0) return;
switch (field) {
// ===== SOURCE FIELDS =====
case 'qty': {
if (avgWeight > 0) {
formik.setFieldValue('total_weight', roundWeight(qty * avgWeight));
}
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight));
} else if (qty && total_weight && field === 'total_weight') {
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
case 'avg_weight': {
if (avgWeight > 0) {
const tw = roundWeight(qty * avgWeight);
formik.setFieldValue('total_weight', tw);
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
}
break;
}
case 'unit_price': {
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
// ===== TOTAL EDITABLE =====
case 'total_weight': {
if (totalWeight > 0) {
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
}
break;
}
case 'total_price': {
if (totalPrice > 0) {
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
}
break;
}
}
};
@@ -183,7 +237,7 @@ const DeliveryOrderProductForm = ({
</div>
)}
<div className='grid sm:grid-cols-2 gap-4'>
<div className='grid sm:grid-cols-3 gap-4'>
<SelectInput
options={options}
label='Produk'
@@ -287,7 +341,9 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.vehicle_number)}
errorMessage={formik.errors.vehicle_number}
/>
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-3 gap-4'>
<NumberInput
required
label='Kuantitas'
@@ -301,33 +357,28 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
</span>
</div>
}
bottomLabel={
formik.values.marketing_product_id
? 'Stok dijual: ' +
salesOrders?.find(
(item) => item.id === formik.values.marketing_product_id
)?.qty
)?.qty +
' ' +
(isResponseSuccess(productData)
? productData?.data?.uom.name
: '')
: ''
}
/>
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Harga Satuan (Rp)'
@@ -342,7 +393,20 @@ const DeliveryOrderProductForm = ({
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Total Bobot (Kg)'
@@ -11,7 +11,7 @@ import SelectInput, {
useSelect,
} from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data';
import { ProductApi, UomApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput';
@@ -26,6 +26,10 @@ import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const SalesOrderProductForm = ({
initialValues,
@@ -39,6 +43,19 @@ const SalesOrderProductForm = ({
}) => {
const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>('');
const [selectedProductWarehouse, setSelectedProductWarehouse] =
useState<ProductWarehouse | null>(null);
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProductWarehouse?.product_id
? ProductApi.basePath + '/' + selectedProductWarehouse?.product_id
: null,
() =>
selectedProductWarehouse?.product_id
? ProductApi.getSingle(selectedProductWarehouse?.product_id)
: undefined
);
// ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({
@@ -69,17 +86,21 @@ const SalesOrderProductForm = ({
const {
options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions,
setInputValue: setKandangInputValue,
loadMore: loadMoreKandang,
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
const {
options: warehouseSourceOptions,
rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouse,
} = useSelect<ProductWarehouse>(
ProductWarehouseApi.basePath,
'id',
'product.name',
'search',
'',
{
warehouse_id: formik.values.kandang_id?.toString() ?? '',
}
@@ -112,6 +133,7 @@ const SalesOrderProductForm = ({
const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === newId
);
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty');
} else {
@@ -139,34 +161,60 @@ const SalesOrderProductForm = ({
const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue(
'total_price',
(qty as number) * (unit_price as number)
);
} else if (qty && total_price && field === 'total_price') {
formik.setFieldValue(
'unit_price',
(total_price as number) / (qty as number)
);
const qty = Number(formik.values.qty || 0);
const avgWeight = Number(formik.values.avg_weight || 0);
const totalWeight = Number(formik.values.total_weight || 0);
const unitPrice = Number(formik.values.unit_price || 0);
const totalPrice = Number(formik.values.total_price || 0);
if (qty <= 0) return;
switch (field) {
// ===== SOURCE FIELDS =====
case 'qty': {
if (avgWeight > 0) {
formik.setFieldValue('total_weight', roundWeight(qty * avgWeight));
}
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue(
'total_weight',
(qty as number) * (avg_weight as number)
);
} else if (qty && total_weight && field === 'total_weight') {
formik.setFieldValue(
'avg_weight',
(total_weight as number) / (qty as number)
);
case 'avg_weight': {
if (avgWeight > 0) {
const tw = roundWeight(qty * avgWeight);
formik.setFieldValue('total_weight', tw);
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
}
break;
}
case 'unit_price': {
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
// ===== TOTAL EDITABLE =====
case 'total_weight': {
if (totalWeight > 0) {
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
}
break;
}
case 'total_price': {
if (totalPrice > 0) {
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
}
break;
}
}
};
@@ -188,7 +236,7 @@ const SalesOrderProductForm = ({
</Alert>
</div>
)}
<div className='grid sm:grid-cols-2 gap-4 z-200'>
<div className='grid sm:grid-cols-3 gap-4 z-200'>
<PatternInput
name='vehicle_number'
label='No. Polisi'
@@ -215,6 +263,8 @@ const SalesOrderProductForm = ({
value={formik.values.kandang}
onChange={kandangChangeHandler}
isClearable
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandang}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
@@ -228,6 +278,8 @@ const SalesOrderProductForm = ({
isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse}
isClearable
placeholder={
formik.values.kandang_id
@@ -243,6 +295,9 @@ const SalesOrderProductForm = ({
}
errorMessage={formik.errors.product_warehouse_id}
/>
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-3 gap-4 z-200'>
<NumberInput
required
label='Kuantitas'
@@ -256,6 +311,15 @@ const SalesOrderProductForm = ({
isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
</span>
</div>
}
bottomLabel={
isResponseSuccess(warehouseSourceRawData) &&
formik.values.product_warehouse_id
@@ -264,32 +328,13 @@ const SalesOrderProductForm = ({
(item) => item.id === formik.values.product_warehouse_id
)?.quantity ?? 0
)} ${
warehouseSourceRawData?.data?.find(
(item) => item.id === formik.values.product_warehouse_id
)?.product?.uom?.name ?? ''
isResponseSuccess(productData)
? productData?.data?.uom.name
: ''
}`
: ''
}
/>
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4 z-200'>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Harga Satuan (Rp)'
@@ -306,6 +351,22 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Total Bobot (Kg)'
@@ -563,7 +563,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
todayRecordings.forEach((recording) => {
const recordingDate = recording.record_datetime?.split('T')[0];
if (recordingDate === today) {
recordedIds.add(recording.project_flock.project_flock_kandang_id);
recordedIds.add(recording.project_flock?.project_flock_kandang_id);
}
});