Files
lti-web-client/src/components/pages/purchase/form/request/PurchaseRequestForm.tsx
T

961 lines
34 KiB
TypeScript

'use client';
import { useCallback, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import useSWR from 'swr';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput';
import CheckboxInput from '@/components/input/CheckboxInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useModal } from '@/components/Modal';
import {
PurchaseRequestFormSchema,
PurchaseRequestFormValues,
getPurchaseRequestFormInitialValues,
UpdatePurchaseRequestFormSchema,
} from './PurchaseRequestForm.schema';
import {
SupplierApi,
AreaApi,
LocationApi,
WarehouseApi,
ProductApi,
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Product } from '@/types/api/master-data/product';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { PurchaseRequestApi } from '@/services/api/purchase';
import Card from '@/components/Card';
import {
CreatePurchaseRequestPayload,
Purchase,
} from '@/types/api/purchase/purchase';
interface PurchaseRequestFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Purchase;
}
const PurchaseRequestForm = ({
type = 'add',
initialValues,
}: PurchaseRequestFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
[]
);
const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] =
useState('');
// ===== TYPE DEFINITIONS =====
interface ProductOptionType {
value: number;
label: string;
}
// ===== UTILITY FUNCTIONS =====
const isRepeaterInputError = (
idx: number,
field: 'warehouse_id' | 'product_id' | 'qty'
): { isError: boolean; errorMessage: string } => {
if (!formik.touched.items || !Array.isArray(formik.touched.items)) {
return {
isError: false,
errorMessage: '',
};
}
const touchedField = (
formik.touched.items[idx] as Partial<{
warehouse_id: boolean;
product_id: boolean;
qty: boolean;
}>
)?.[field];
const errorItem = formik.errors.items?.[idx] as
| Record<string, string>
| undefined;
return {
isError: Boolean(touchedField && Boolean(errorItem?.[field])),
errorMessage: touchedField && errorItem?.[field] ? errorItem[field] : '',
};
};
// ===== SUBMISSION HANDLERS =====
const createPurchaseRequestHandler = useCallback(
async (payload: CreatePurchaseRequestPayload) => {
const res = await PurchaseRequestApi.create(payload);
if (isResponseError(res)) {
setPurchaseRequestFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/purchase');
},
[router]
);
const updatePurchaseRequestHandler = useCallback(
async (
purchaseRequestId: number,
payload: CreatePurchaseRequestPayload
) => {
const res = await PurchaseRequestApi.update(purchaseRequestId, payload);
if (isResponseError(res)) {
setPurchaseRequestFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/purchase');
},
[router]
);
const deletePurchaseRequestClickHandler = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
const confirmationModalDeleteClickHandler = useCallback(async () => {
if (!initialValues?.id) return;
setIsDeleteLoading(true);
await PurchaseRequestApi.delete(initialValues.id);
deleteModal.closeModal();
toast.success('Successfully delete Purchase Request!');
setIsDeleteLoading(false);
router.push('/purchase');
}, [deleteModal, initialValues?.id, router]);
// ===== SELECT INPUT DATA =====
const {
setInputValue: setSupplierSelectInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSuppliers,
rawData: supplierRawData,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setAreaSelectInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreas,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
// ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<PurchaseRequestFormValues>(
() => getPurchaseRequestFormInitialValues(initialValues),
[initialValues]
);
const formik = useFormik<PurchaseRequestFormValues>({
initialValues: formikInitialValues,
validationSchema:
type === 'edit'
? UpdatePurchaseRequestFormSchema
: PurchaseRequestFormSchema,
validateOnChange: true,
validateOnBlur: true,
validateOnMount: false,
enableReinitialize: true,
onSubmit: async (values) => {
const payload: CreatePurchaseRequestPayload = {
supplier_id:
typeof values.supplier_id === 'string'
? parseInt(values.supplier_id) || 0
: values.supplier_id || 0,
credit_term:
typeof values.credit_term === 'string'
? parseInt(values.credit_term) || 0
: values.credit_term || 0,
notes: values.notes || '',
items: (values.items || []).map((item) => ({
warehouse_id: Number(item.warehouse_id) || 0,
product_id: Number(item.product_id) || 0,
qty: Number(item.qty) || 0,
})),
};
switch (type) {
case 'add':
await createPurchaseRequestHandler(payload);
break;
case 'edit':
await updatePurchaseRequestHandler(
initialValues?.id as number,
payload
);
break;
}
},
});
// ===== API DATA FETCHING =====
const { data: productsResponse, isLoading: isLoadingProducts } = useSWR(
`${ProductApi.basePath}`,
ProductApi.getAllFetcher
);
const productOptions = useMemo(() => {
if (!isResponseSuccess(productsResponse)) return [];
return (
productsResponse?.data.map((product: Product) => ({
value: product.id,
label: product.name,
})) || []
);
}, [productsResponse]);
const productData = useMemo(() => {
if (!isResponseSuccess(productsResponse)) return {};
const data: Record<number, Product> = {};
productsResponse?.data?.forEach((product: Product) => {
data[product.id] = product;
});
return data;
}, [productsResponse]);
const locationsUrl = useMemo(() => {
const params = new URLSearchParams({
search: locationSelectInputValue,
...(formik.values.area_id && formik.values.area_id > 0
? { area_id: formik.values.area_id.toString() }
: {}),
});
return `${LocationApi.basePath}?${params.toString()}`;
}, [locationSelectInputValue, formik.values.area_id]);
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = useMemo(() => {
if (!isResponseSuccess(locations)) return [];
return (
locations?.data.map((location) => ({
value: location.id,
label: location.name,
})) || []
);
}, [locations]);
const warehousesUrl = useMemo(() => {
const params = new URLSearchParams({ search: warehouseSelectInputValue });
if (formik.values.area_id && formik.values.area_id > 0) {
params.append('area_id', formik.values.area_id.toString());
}
if (formik.values.location_id && formik.values.location_id > 0) {
params.append('location_id', formik.values.location_id.toString());
}
return `${WarehouseApi.basePath}?${params.toString()}`;
}, [
warehouseSelectInputValue,
formik.values.area_id,
formik.values.location_id,
]);
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
const warehouseOptions = useMemo(() => {
if (!isResponseSuccess(warehouses)) return [];
return (
warehouses?.data.map((w) => ({
value: w.id,
label: w.name,
area: w.area?.name,
location:
'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG')
? w.location?.name
: undefined,
})) || []
);
}, [warehouses]);
const addPurchaseItem = () => {
const newItems = [
...(formik.values.items || []),
{
warehouse: null,
warehouse_id: 0,
product: null,
product_id: 0,
qty: 0,
},
];
formik.setFieldValue('items', newItems);
};
const removePurchaseItem = (idx: number) => {
const updatedPurchaseItems = formik.values.items?.filter(
(_, i) => i !== idx
);
formik.setFieldValue('items', updatedPurchaseItems);
};
const removeSelectedPurchaseItems = () => {
const updatedPurchaseItems = formik.values.items?.filter(
(_, idx) => !selectedPurchaseItems.includes(idx)
);
formik.setFieldValue('items', updatedPurchaseItems);
setSelectedPurchaseItems([]);
};
// ===== PURCHASE ITEM OPERATIONS =====
const handlePurchaseItemChange = (
idx: number,
field: 'qty',
value: string | number
) => {
if (field === 'qty') {
const numValue =
typeof value === 'string' ? parseFloat(value) || 0 : value;
formik.setFieldTouched(`items.${idx}.qty`, true);
formik.setFieldValue(`items.${idx}.qty`, numValue);
}
};
return (
<>
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href='/purchase'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Purchase Request'}
{type === 'edit' && 'Edit Purchase Request'}
{type === 'detail' && 'Detail Purchase Request'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
{/* Basic Info Card */}
<Card
title='Informasi Purchase Request'
className={{
wrapper: 'w-full mb-4 shadow',
body: 'flex flex-col gap-6',
}}
>
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
<SelectInput
required
label='Vendor'
placeholder='Pilih Vendor...'
value={formik.values.supplier}
onChange={(val) => {
const supplier = val as OptionType | null;
const supplierId = supplier?.value
? typeof supplier.value === 'string'
? parseInt(supplier.value)
: supplier.value
: 0;
formik.setFieldTouched('supplier_id', true);
formik.setFieldValue('supplier_id', supplierId);
formik.setFieldTouched('supplier', true);
formik.setFieldValue('supplier', supplier);
if (supplierId > 0 && isResponseSuccess(supplierRawData)) {
const supplierData = supplierRawData.data.find(
(s: Supplier) => s.id === supplierId
);
if (supplierData?.due_date) {
formik.setFieldTouched('credit_term', true);
formik.setFieldValue(
'credit_term',
supplierData.due_date.toString()
);
}
if (formik.values.items) {
formik.values.items.forEach((_, idx) => {
formik.setFieldValue(`items.${idx}.product`, null);
formik.setFieldValue(`items.${idx}.product_id`, 0);
formik.setFieldValue(`items.${idx}.qty`, 0);
});
}
} else {
formik.setFieldTouched('credit_term', false);
formik.setFieldValue('credit_term', '');
if (formik.values.items) {
formik.values.items.forEach((_, idx) => {
formik.setFieldValue(`items.${idx}.product`, null);
formik.setFieldValue(`items.${idx}.product_id`, 0);
formik.setFieldValue(`items.${idx}.qty`, 0);
});
}
}
}}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
isError={
formik.touched.supplier_id &&
Boolean(formik.errors.supplier_id)
}
errorMessage={formik.errors.supplier_id as string}
isDisabled={type === 'detail'}
isClearable
/>
<NumberInput
required={!!formik.values.supplier_id}
label='Jatuh tempo (hari)'
name='credit_term'
value={formik.values.credit_term || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.credit_term &&
Boolean(formik.errors.credit_term)
}
errorMessage={formik.errors.credit_term as string}
readOnly={type === 'detail' || !formik.values.supplier_id}
disabled={type === 'detail' || !formik.values.supplier_id}
allowNegative={false}
decimalScale={0}
placeholder={
!formik.values.supplier_id
? 'Pilih Vendor terlebih dahulu'
: 'Masukkan jumlah hari jatuh tempo'
}
/>
<SelectInput
required
label='Area'
placeholder='Pilih Area...'
value={formik.values.area}
onChange={(val) => {
const area = val as OptionType | null;
formik.setFieldTouched('area_id', true);
formik.setFieldValue(
'area_id',
(area as OptionType)?.value || 0
);
formik.setFieldTouched('area', true);
formik.setFieldValue('area', area);
formik.setFieldTouched('location', false);
formik.setFieldValue('location', undefined);
formik.setFieldTouched('location_id', false);
formik.setFieldValue('location_id', 0);
setLocationSelectInputValue('');
if (formik.values.items) {
formik.values.items.forEach((_, idx) => {
formik.setFieldValue(`items.${idx}.product`, null);
formik.setFieldValue(`items.${idx}.product_id`, 0);
});
}
}}
options={areaOptions}
onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas}
isError={
formik.touched.area_id && Boolean(formik.errors.area_id)
}
errorMessage={formik.errors.area_id as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
required
label='Lokasi'
placeholder={
!formik.values.area_id
? 'Pilih Area terlebih dahulu'
: 'Pilih Lokasi...'
}
value={formik.values.area_id ? formik.values.location : null}
onChange={(val) => {
const location = val as OptionType | null;
formik.setFieldTouched('location_id', true);
formik.setFieldValue(
'location_id',
(location as OptionType)?.value || 0
);
formik.setFieldTouched('location', true);
formik.setFieldValue('location', location);
if (formik.values.items) {
formik.values.items.forEach((_, idx) => {
formik.setFieldValue(`items.${idx}.product`, null);
formik.setFieldValue(`items.${idx}.product_id`, 0);
});
}
}}
options={locationOptions}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
isError={
formik.touched.location_id &&
Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id as string}
isDisabled={type === 'detail' || !formik.values.area_id}
isClearable={type !== 'detail' && !!formik.values.area_id}
key={`location-${formik.values.area_id}`}
/>
<div className={'col-span-2'}>
<TextInput
label='Notes'
name='notes'
value={formik.values.notes || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes as string}
readOnly={type === 'detail'}
disabled={type === 'detail'}
placeholder='Masukkan catatan'
/>
</div>
</div>
</Card>
{/* Purchase Items Table */}
<Card
title='Item Pembelian'
className={{
wrapper: 'w-full mb-4 shadow',
title: 'mb-4',
}}
>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
{type !== 'detail' && (
<th>
<CheckboxInput
name='select-all-items'
checked={
formik.values.items?.length ===
selectedPurchaseItems.length &&
formik.values.items?.length > 0
}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedPurchaseItems(
formik.values.items?.map((_, idx) => idx) ?? []
);
} else {
setSelectedPurchaseItems([]);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</th>
)}
<th>
Gudang
<span className='text-error'>*</span>
</th>
<th>
Item
<span className='text-error'>*</span>
</th>
<th>
Jumlah
<span className='text-error'>*</span>
</th>
<th>Estimasi Harga</th>
<th>Satuan</th>
{type !== 'detail' && <th>Action</th>}
</tr>
</thead>
<tbody>
{formik.values.items?.map((item, idx) => (
<tr key={`purchase-item-${idx}`}>
{type !== 'detail' && (
<td className='!align-middle'>
<CheckboxInput
name={`purchase-item-${idx}`}
checked={selectedPurchaseItems.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedPurchaseItems([
...selectedPurchaseItems,
idx,
]);
} else {
setSelectedPurchaseItems(
selectedPurchaseItems.filter((i) => i !== idx)
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</td>
)}
<td>
<SelectInput
required
placeholder={
!formik.values.area_id
? 'Pilih Area terlebih dahulu'
: formik.values.location_id
? 'Pilih Gudang...'
: 'Pilih Area dan Lokasi terlebih dahulu'
}
value={item.warehouse}
onChange={(val) => {
const warehouse = val as OptionType | null;
const warehouseId =
(warehouse as OptionType)?.value || 0;
formik.setFieldTouched(
`items.${idx}.warehouse`,
true
);
formik.setFieldValue(
`items.${idx}.warehouse`,
warehouse
);
formik.setFieldTouched(
`items.${idx}.warehouse_id`,
true
);
formik.setFieldValue(
`items.${idx}.warehouse_id`,
warehouseId
);
formik.setFieldValue(`items.${idx}.product`, null);
formik.setFieldValue(`items.${idx}.product_id`, 0);
}}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses}
isError={
isRepeaterInputError(idx, 'warehouse_id').isError
}
errorMessage={
isRepeaterInputError(idx, 'warehouse_id')
.errorMessage
}
isDisabled={
type === 'detail' ||
!formik.values.area_id ||
!formik.values.location_id
}
isClearable={
type !== 'detail' &&
!!formik.values.area_id &&
!!formik.values.location_id
}
key={`warehouse-${formik.values.area_id}-${formik.values.location_id}`}
/>
</td>
<td>
<SelectInput
required
value={item.product ?? undefined}
onChange={(val) => {
const product = val as ProductOptionType | null;
const productId =
(product as ProductOptionType)?.value || 0;
formik.setFieldTouched(
`items.${idx}.product`,
true
);
formik.setFieldValue(
`items.${idx}.product`,
product
);
formik.setFieldTouched(
`items.${idx}.product_id`,
true
);
formik.setFieldValue(
`items.${idx}.product_id`,
productId
);
}}
options={productOptions}
isLoading={isLoadingProducts}
isError={
isRepeaterInputError(idx, 'product_id').isError
}
errorMessage={
isRepeaterInputError(idx, 'product_id').errorMessage
}
isDisabled={type === 'detail'}
isClearable={type !== 'detail'}
placeholder='Pilih Produk'
className={{
wrapper: 'min-w-32',
}}
/>
</td>
<td>
<NumberInput
required
name={`items.${idx}.qty`}
value={item.qty || ''}
onChange={(e) =>
handlePurchaseItemChange(idx, 'qty', e.target.value)
}
onBlur={formik.handleBlur}
placeholder='Masukkan kuantitas'
readOnly={type === 'detail'}
allowNegative={false}
decimalScale={0}
isError={isRepeaterInputError(idx, 'qty').isError}
errorMessage={
isRepeaterInputError(idx, 'qty').errorMessage
}
className={{
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
}}
/>
</td>
<td>
<TextInput
required
name={`items.${idx}.price`}
value={
item.product_id && productData[item.product_id]
? (
productData[item.product_id].product_price *
(parseFloat(item.qty?.toString() || '0') || 0)
).toLocaleString('en-US')
: ''
}
onChange={() => {}}
onBlur={formik.handleBlur}
type='text'
className={{
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
}}
disabled={true}
readOnly={true}
inputPrefix={'Rp'}
placeholder={
item.product_id
? 'Loading...'
: 'Pilih produk terlebih dahulu'
}
bottomLabel={
item.product_id && productData[item.product_id]
? `Harga per unit: Rp ${productData[
item.product_id
].product_price.toLocaleString('en-US')}`
: ''
}
/>
</td>
<td>
<TextInput
required
name={`items.${idx}.uom`}
value={
item.product_id && productData[item.product_id]
? productData[item.product_id].uom.name
: ''
}
onBlur={formik.handleBlur}
type='text'
readOnly={true}
disabled={true}
className={{
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
}}
placeholder={
item.product_id
? 'Loading...'
: 'Pilih produk terlebih dahulu'
}
/>
</td>
{type !== 'detail' && (
<td>
<div className='flex justify-center'>
<Button
type='button'
color='error'
onClick={() => removePurchaseItem(idx)}
>
<Icon
icon='mdi:trash-can'
width={24}
height={24}
/>
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{type !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'>
{selectedPurchaseItems.length > 0 && (
<Button
type='button'
color='error'
onClick={removeSelectedPurchaseItems}
disabled={selectedPurchaseItems.length === 0}
className='w-fit'
>
<Icon icon='mdi:trash-can' width={24} height={24} />
Hapus Terpilih ({selectedPurchaseItems.length})
</Button>
)}
<Button
type='button'
color='success'
onClick={addPurchaseItem}
className='w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Item
</Button>
</div>
)}
</Card>
{/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'detail' && (
<div className='flex flex-row justify-end gap-2 w-full'>
<Button type='reset' color='warning' className='px-4'>
Reset
</Button>
<Button
type='submit'
color='primary'
className='px-4'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
)}
{type === 'detail' && (
<div className='flex flex-row justify-start gap-2'>
<Button
href={`/purchase/detail/edit/?purchaseId=${initialValues?.id}`}
color='warning'
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
<Button
type='button'
color='error'
onClick={deletePurchaseRequestClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
)}
</div>
{purchaseRequestFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseRequestFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data Purchase Request ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default PurchaseRequestForm;