Files
lti-web-client/src/components/pages/marketing/SalesOrderFormModal.tsx
T

788 lines
28 KiB
TypeScript

'use client';
import Button from '@/components/Button';
import AlertErrorList from '@/components/helper/form/FormErrors';
import RequirePermission from '@/components/helper/RequirePermission';
import DateInput from '@/components/input/DateInput';
import DebouncedTextArea from '@/components/input/DebouncedTextArea';
import NumberInput from '@/components/input/NumberInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
DeliveryProductToFieldValues,
mergeSOwithDO,
SalesProductToFieldValues,
DeliveryOrderFormValues,
DeliveryOrderSchema,
getFilledMarketingFormInitialValues,
SalesOrderFormValues,
SalesOrderSchema,
} from '@/components/pages/marketing/form/MarketingForm.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import SalesOrderProductForm from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProductForm';
import SalesOrderProductTable from '@/components/pages/marketing/form/table-view/SalesOrderProductTable';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate } from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { CustomerApi } from '@/services/api/master-data';
import { UserApi } from '@/services/api/user';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { CreatedUser } from '@/types/api/api-general';
import {
CreateSalesOrderPayload,
CreateSalesOrderProductPayload,
Marketing,
UpdateDeliveryOrderPayload,
UpdateSalesOrderPayload,
} from '@/types/api/marketing/marketing';
import { Customer } from '@/types/api/master-data/customer';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import { useRouter, useSearchParams } from 'next/navigation';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR, { useSWRConfig } from 'swr';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
const SalesOrderFormModal = ({
initialValues,
}: {
initialValues?: Marketing;
}) => {
const router = useRouter();
const searchParams = useSearchParams();
const modalAction = searchParams.get('action');
const marketingId = searchParams.get('id');
const [currentModalAction, setCurrentModalAction] = useState<string | null>(
modalAction
);
const isModalActionForForm =
modalAction === 'add' ||
modalAction === 'edit' ||
modalAction === 'add_delivery' ||
modalAction === 'edit_delivery';
const { mutate } = useSWRConfig();
const refreshMarketing = () => {
mutate(
(key) => typeof key === 'string' && key.includes(MarketingApi.basePath)
);
};
const { data: marketing } = useSWR(
isModalActionForForm && marketingId
? `detail-marketing-${marketingId}`
: undefined,
() => MarketingApi.getSingle(Number(marketingId))
);
// ================== FETCH OPTIONS ==================
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');
/**
* Step 1: General Information
* Step 2: Repeater Add Product
* Step 3: Submit
*/
const [step, setStep] = useState(1);
const formModal = useModal();
const successModal = useModal();
const deleteModal = useModal();
const formRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [formikLastValues, setFormikLastValues] = useState<
SalesOrderFormValues | undefined
>(undefined);
const [formErrorMessage, setFormErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [selectedMarketingProduct, setSelectedMarketingProduct] =
useState<SalesOrderProductFormValues | null>(null);
const [selectedDeliveryProduct, setSelectedDeliveryProduct] =
useState<DeliveryOrderProductFormValues | null>(null);
const [deliveryFormState, setDeliveryFormState] = useState<'add' | 'edit'>(
'add'
);
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
DeliveryOrderProductFormValues[]
>(
mergeSOwithDO(
initialValues?.sales_order?.map(SalesProductToFieldValues) ?? [],
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? []
)
);
// ================== SETUP FORMIK ==================
const formikInitialValues = useMemo<
SalesOrderFormValues & DeliveryOrderFormValues
>(() => {
return {
so_date: initialValues?.so_date || undefined,
notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined,
sales_person_id: initialValues?.sales_person?.id || undefined,
sales_person: initialValues?.sales_person
? {
value: initialValues.sales_person.id,
label: initialValues.sales_person.name,
}
: null,
customer: initialValues?.customer
? {
value: initialValues.customer.id,
label: initialValues.customer.name,
}
: null,
sales_order:
initialValues?.sales_order?.map((product) =>
SalesProductToFieldValues(product)
) ?? [],
delivery_order: mergeSOwithDO(
initialValues?.sales_order?.map(SalesProductToFieldValues) ?? [],
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? []
),
};
}, [initialValues]);
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
enableReinitialize: true,
initialValues: formikInitialValues,
validationSchema:
modalAction == 'add_deliver' || modalAction == 'edit_deliver'
? DeliveryOrderSchema
: SalesOrderSchema,
validateOnMount: true,
onSubmit: async (values) => {
const payload =
modalAction != 'add_deliver' && modalAction != 'edit_deliver'
? ({
customer_id: values.customer_id as number,
sales_person_id: values.sales_person_id as number,
date: formatDate(values.so_date as string, 'yyyy-MM-DD'),
notes: values.notes as string,
marketing_products: values.sales_order.map((product) => {
// Workaround untuk TELUR + QTY: kirim "KG" karena BE tidak support "QTY"
const convertionUnitValue =
product.convertion_unit?.value?.toUpperCase();
const normalizedConvertionUnit =
product.marketing_type?.value?.toLowerCase() === 'telur'
? convertionUnitValue === 'PETI'
? 'PETI'
: 'KG' // termasuk "QTY" dan "KG"
: undefined;
// Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM"
let marketingTypeValue =
product.marketing_type?.value?.toUpperCase() || '';
if (marketingTypeValue === 'AYAM,AYAM_PULLET') {
marketingTypeValue = product.week ? 'AYAM_PULLET' : 'AYAM';
}
return {
vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(String(product.unit_price || 0)),
total_weight: parseFloat(String(product.total_weight || 0)),
qty: parseFloat(String(product.qty || 0)),
avg_weight: parseFloat(String(product.avg_weight || 0)),
total_price: parseFloat(String(product.total_price || 0)),
marketing_type: marketingTypeValue,
convertion_unit: normalizedConvertionUnit,
weight_per_convertion:
product.weight_per_convertion ?? undefined,
week: product.week ?? undefined,
} as CreateSalesOrderProductPayload;
}),
} as CreateSalesOrderPayload)
: ({
marketing_id: initialValues?.id as number,
delivery_products: values.delivery_order
.map((product) => {
if (Boolean(product.delivery_date)) {
return {
marketing_product_id:
product.marketing_product_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
delivery_date: formatDate(
product.delivery_date as string,
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
};
}
})
.filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload);
switch (modalAction) {
case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload);
break;
case 'edit':
await updateMarketingHandler(payload as UpdateSalesOrderPayload);
break;
default:
break;
}
},
});
// ===== Formik Error List =====
const { formErrorList, setFormErrorList, close, handleFormSubmit } =
useFormikErrorList(formik, {
onAfterSubmit: () => {
router.push('/marketing');
},
});
// ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true);
const createMarketingRes = await SalesOrderApi.create(values);
if (isResponseSuccess(createMarketingRes)) {
closeModalHandler(false);
successModal.openModal();
refreshMarketing();
}
if (isResponseError(createMarketingRes)) {
toast.error(createMarketingRes?.message as string);
}
setIsLoading(false);
};
const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => {
setIsLoading(true);
if (!marketingId) {
toast.error('Marketing ID is required');
setIsLoading(false);
return;
}
const updateMarketingRes = await SalesOrderApi.update(
Number(marketingId),
values
);
if (isResponseSuccess(updateMarketingRes)) {
closeModalHandler(false);
successModal.openModal();
refreshMarketing();
}
if (isResponseError(updateMarketingRes)) {
toast.error(updateMarketingRes?.message as string);
}
setIsLoading(false);
};
const memoSalesOrder = formik.values.sales_order;
// ================== HANDLER ==================
const nextButtonHandler = () => {
setSelectedMarketingProduct(null);
setStep(step + 1);
};
const prevButtonHandler = () => {
setStep(step - 1);
};
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
},
[]
);
const handleChangeSalesPerson = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('sales_person_id', (val as OptionType)?.value);
formik.setFieldValue('sales_person', val as OptionType);
},
[]
);
const deleteClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
if (!marketingId) {
toast.error(`Tidak ada data yang valid untuk di hapus.`);
deleteModal.closeModal();
return;
}
setIsLoading(true);
const res = await MarketingApi.delete(Number(marketingId));
deleteModal.closeModal();
toast.success(res?.message as string);
setIsLoading(false);
refreshMarketing();
closeModalHandler();
router.replace('/marketing');
};
// ================== SALES ORDER HANDLER ==================
const handleDeleteSO = useCallback(
(id: number) => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
'sales_order',
currentProducts.filter((p) => p.id != id)
);
},
[memoSalesOrder]
);
const handleDeleteAllSO = useCallback(() => {
formik.setFieldValue('sales_order', []);
}, [memoSalesOrder]);
const handleEditSO = useCallback(
(id: number) => {
const currentProducts = formik.values.sales_order;
const selectedProduct = currentProducts.find((p) => p.id == id);
setSelectedMarketingProduct(selectedProduct ?? null);
setStep(2);
},
[memoSalesOrder]
);
const handleAddSOClick = useCallback(() => {
setSelectedMarketingProduct(null);
if (step === 3) {
setStep(2);
} else {
prevButtonHandler();
}
}, [step]);
const handleAddSubmitSO = useCallback(
async (values: SalesOrderProductFormValues, id?: number) => {
const currentProducts = formik.values.sales_order;
const newValues = {
...values,
id: values.id ?? Date.now(),
};
let updatedProducts = [];
if (id) {
// Overwrite
updatedProducts = currentProducts.map((item) =>
item.id === id ? newValues : item
);
} else {
// Add new item
updatedProducts = [...currentProducts, newValues];
}
formik.setFieldValue('sales_order', updatedProducts);
setSelectedMarketingProduct(null);
nextButtonHandler();
},
[memoSalesOrder, nextButtonHandler]
);
const isNextButtonDisabled = useMemo(() => {
if (step === 1) {
return Boolean(
!formik.values.customer_id ||
!formik.values.sales_person_id ||
!formik.values.so_date ||
!formik.values.notes
);
}
return true;
}, [step, formik.values]);
// ================== EFFECT ==================
useEffect(() => {
if (modalAction === 'add' || modalAction === 'edit') {
setCurrentModalAction(modalAction);
formModal.openModal();
if (modalAction === 'add') {
formik.resetForm();
setStep(1);
setSelectedMarketingProduct(null);
setSelectedDeliveryProduct(null);
setFormErrorMessage('');
setFormErrorList([]);
}
}
}, [modalAction]);
const closeModalHandler = (shouldPushToRoute: boolean = true) => {
refreshMarketing();
if (shouldPushToRoute) {
formik.resetForm();
textareaRef.current?.setAttribute('value', '');
successModal.closeModal();
router.push('/marketing');
}
setStep(1);
setFormErrorMessage('');
formModal.closeModal();
};
const grandTotal = useMemo(() => {
return memoSalesOrder.reduce(
(total, product) =>
total + parseFloat((product.total_price as string) || '0'),
0
);
}, [memoSalesOrder]);
useEffect(() => {
const getFilledInitialValues = async () => {
if (marketingId && isResponseSuccess(marketing)) {
const filledInitialValues = await getFilledMarketingFormInitialValues(
marketing.data
);
formik.setValues(filledInitialValues);
setStep(3);
}
if (isResponseError(marketing)) {
router.push('/marketing');
closeModalHandler();
toast.error(marketing.message);
}
};
getFilledInitialValues();
}, [marketingId, marketing]);
// Reset error message when step changes
useEffect(() => {
setFormErrorList([]);
setFormErrorMessage('');
}, [step]);
useEffect(() => {
if (memoSalesOrder.length === 0) {
setStep(1);
}
}, [memoSalesOrder]);
return (
<>
<Modal
ref={formModal.ref}
position='end'
className={{
modalBox: 'w-full sm:w-fit p-3 rounded-xl bg-transparent shadow-none',
}}
>
<div
onSubmit={(e) => {
e.preventDefault();
}}
className='w-full min-h-full flex flex-col sm:flex-row items-stretch bg-base-100 rounded-xl overflow-y-auto'
>
{step <= 3 && (
<div className='w-full sm:w-[446px]'>
<div className='w-full p-4 flex flex-row items-stretch gap-3 border-b border-base-content/10'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => closeModalHandler()}
className='p-0 text-black hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
<div className='w-px border-none bg-base-content/10' />
<h4 className='text-sm font-medium text-base-content/50'>
{modalAction === 'add' ? 'Add' : 'Edit'} Sales Order
</h4>
</div>
<form
className='w-full p-4 flex flex-col border-b border-base-content/10'
ref={formRef}
onSubmit={handleFormSubmit}
>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Informasi Order
</h4>
<SelectInputRadio
required
label='Pelanggan'
options={customerOptions}
isLoading={isLoadingCustomerOptions}
value={formik.values.customer}
onChange={handleChangeCustomer}
onInputChange={setInputCustomerValue}
onMenuScrollToBottom={loadMoreCustomer}
isError={
formik.touched.customer_id &&
Boolean(formik.errors.customer_id)
}
errorMessage={formik.errors.customer_id}
isClearable
placeholder='Pilih Pelanggan'
isDisabled={
modalAction === 'add_deliver' ||
modalAction === 'edit_deliver' ||
modalAction === 'edit'
}
/>
<DateInput
required
name='so_date'
label='Tanggal'
value={formik.values.so_date}
onChange={formik.handleChange}
isError={
formik.touched.so_date && Boolean(formik.errors.so_date)
}
errorMessage={formik.errors.so_date}
placeholder='Pilih Tanggal'
readOnly={
modalAction == 'add_deliver' ||
modalAction == 'edit_deliver'
}
/>
<SelectInputRadio
required
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={
modalAction === 'add_deliver' ||
modalAction === 'edit_deliver'
}
/>
<DebouncedTextArea
ref={textareaRef}
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={
modalAction === 'add_deliver' ||
modalAction === 'edit_deliver'
}
/>
<NumberInput
name='total_price'
label='Total Penjualan'
value={grandTotal}
disabled={
grandTotal === 0 && formik.values.sales_order.length === 0
}
readOnly
startAdornment={
<span className='font-semibold py-1 text-xs'>Rp</span>
}
/>
<AlertErrorList
className={{
alert: 'w-full mt-4',
}}
formErrorList={formErrorList}
onClose={close}
/>
</form>
<div className='w-full p-4'>
{step === 1 && (
<Button
type='button'
onClick={nextButtonHandler}
disabled={isNextButtonDisabled}
className='w-full rounded-lg text-sm text-base-100'
>
Tambah Produk
</Button>
)}
</div>
</div>
)}
{step === 2 && (
<div className='w-full min-h-full flex flex-col sm:w-[446px] border-l border-base-content/10'>
<div className='w-full p-4 flex flex-row items-center justify-between gap-3 border-b border-base-content/10'>
<div className='w-full flex flex-row items-stretch gap-3'>
{memoSalesOrder.length > 0 && (
<>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
setStep(3);
}}
className='p-0 text-black hover:text-base-content'
>
<Icon
icon='heroicons:chevron-left'
width={20}
height={20}
/>
</Button>
<div className='w-px border-none bg-base-content/10' />
</>
)}
<h4 className='text-sm font-medium text-base-content/50'>
{selectedMarketingProduct?.id ? 'Ubah' : 'Tambah'} Produk
</h4>
</div>
<RequirePermission permissions='lti.marketing.sales_order.delete'>
<Button
type='button'
variant='ghost'
color='none'
onClick={deleteClickHandler}
className='p-0 text-error hover:text-base-content'
>
<Icon icon='heroicons:trash' width={20} height={20} />
</Button>
</RequirePermission>
</div>
<div className='flex flex-1 flex-col'>
<MemoizedSalesOrderProductForm
key={selectedMarketingProduct?.id ?? 'new'}
onSubmitForm={handleAddSubmitSO}
initialValues={selectedMarketingProduct ?? undefined}
exisitingValues={memoSalesOrder}
/>
</div>
</div>
)}
{step === 3 && (
<div className='w-full min-h-full flex flex-col sm:w-[446px] border-l border-base-content/10 relative'>
<div className='w-full p-4 flex flex-row items-center justify-between gap-3 border-b border-base-content/10'>
<h4 className='text-sm font-medium text-base-content/50'>
Informasi Produk
</h4>
<RequirePermission permissions='lti.marketing.sales_order.delete'>
<Button
type='button'
variant='ghost'
color='none'
onClick={deleteClickHandler}
className='p-0 text-error hover:text-base-content'
>
<Icon icon='heroicons:trash' width={20} height={20} />
</Button>
</RequirePermission>
</div>
<div className='flex flex-1 flex-col'>
{memoSalesOrder.length > 0 && (
<div className='px-4 pt-4'>
<MemoizedSalesOrderProductTable
formType={(modalAction || 'add') as 'add' | 'edit'}
data={memoSalesOrder}
onDelete={handleDeleteSO}
onEdit={handleEditSO}
onAddProductClick={handleAddSOClick}
/>
</div>
)}
<div className='p-4 w-full'>
<Button
type='button'
className='justify-center w-full p-3 rounded-lg text-center text-sm text-base-100'
onClick={() => formRef.current?.requestSubmit()}
>
Submit
</Button>
</div>
</div>
</div>
)}
</div>
</Modal>
<ConfirmationModal
ref={successModal.ref}
iconPosition='left'
type='success'
text={`${currentModalAction === 'add' ? 'Data Berhasil Ditambahkan' : 'Data Berhasil Diubah'}`}
subtitleText={`${currentModalAction === 'add' ? 'Data sales order telah berhasil disimpan.' : 'Data sales order telah berhasil diubah.'}`}
primaryButton={{
text: 'Oke',
color: 'primary',
className: 'rounded-lg',
onClick: () => {
closeModalHandler();
},
}}
>
<div className='max-h-[50vh] overflow-y-auto'>
<MemoizedSalesOrderProductTable
formType={'success'}
data={memoSalesOrder}
onDelete={handleDeleteSO}
onEdit={handleEditSO}
onAddProductClick={handleAddSOClick}
/>
</div>
</ConfirmationModal>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default SalesOrderFormModal;