feat(FE-208): create PurchaseRequestForm component and add AddPurchaseRequest page

This commit is contained in:
rstubryan
2025-10-29 21:12:42 +07:00
parent 7ba7b884a4
commit b2c38cd06f
3 changed files with 627 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
import PurchaseRequestForm from '@/components/pages/purchase/form/PurchaseRequestForm';
const AddPurchaseRequest = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<PurchaseRequestForm />
</div>
);
};
export default AddPurchaseRequest;
@@ -0,0 +1,546 @@
'use client';
import { useMemo, useState } from 'react';
import { useFormik } from 'formik';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { FormHeader } from '@/components/helper/form/FormHeader';
import { FormActions } from '@/components/helper/form/FormActions';
import {
PurchaseRequestFormSchema,
PurchaseRequestFormValues,
getPurchaseRequestFormInitialValues,
UpdatePurchaseRequestFormSchema,
} from './PurchaseRequestForm.schema';
import { SupplierApi } from '@/services/api/master-data';
import { WarehouseApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { usePurchaseRequestFormHandlers } from './usePurchaseRequestFormHandlers';
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 [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
[]
);
const {
deleteModal,
purchaseRequestFormErrorMessage,
isDeleteLoading,
createPurchaseRequestHandler,
updatePurchaseRequestHandler,
deletePurchaseRequestClickHandler,
confirmationModalDeleteClickHandler,
} = usePurchaseRequestFormHandlers(initialValues?.id);
// ===== API DATA FETCHING =====
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
SupplierApi.basePath,
SupplierApi.getAllFetcher
);
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
WarehouseApi.basePath,
WarehouseApi.getAllFetcher
);
// ===== DATA PROCESSING =====
const supplierOptions = useMemo(() => {
if (!isResponseSuccess(suppliers)) return [];
return (
suppliers?.data.map((supplier) => ({
value: supplier.id,
label: supplier.name,
})) || []
);
}, [suppliers]);
const warehouseOptions = useMemo(() => {
if (!isResponseSuccess(warehouses)) return [];
return (
warehouses?.data.map((warehouse) => ({
value: warehouse.id,
label: warehouse.name,
})) || []
);
}, [warehouses]);
const formikInitialValues = useMemo<PurchaseRequestFormValues>(
() => getPurchaseRequestFormInitialValues(initialValues),
[initialValues]
);
const formik = useFormik<PurchaseRequestFormValues>({
initialValues: formikInitialValues,
validationSchema:
type === 'edit'
? UpdatePurchaseRequestFormSchema
: PurchaseRequestFormSchema,
validateOnChange: true,
validateOnBlur: true,
onSubmit: async (values) => {
const payload: CreatePurchaseRequestPayload = {
supplier_id: values.supplier_id,
warehouse_ids:
(values.warehouse_ids?.filter(
(id) => id !== undefined && id !== null
) as number[]) || [],
credit_term: values.credit_term || 0,
notes: values.notes || '',
purchase_items: (values.purchase_items || []).map((item) => ({
product_id: item.product_id,
product_warehouse_id: item.product_warehouse_id,
total_qty:
typeof item.total_qty === 'number'
? item.total_qty
: parseFloat(String(item.total_qty)) || 0,
price:
typeof item.price === 'number'
? item.price
: parseFloat(String(item.price)) || 0,
})),
};
switch (type) {
case 'add':
await createPurchaseRequestHandler(payload);
break;
case 'edit':
await updatePurchaseRequestHandler(
initialValues?.id as number,
payload
);
break;
}
},
});
// ===== EVENT HANDLERS =====
const supplierChangeHandler = (val: string) => {
const supplierId = parseInt(val) || 0;
formik.setFieldValue('supplier_id', supplierId);
const selectedSupplier = supplierOptions.find(
(option) => option.value === supplierId
);
if (selectedSupplier) {
formik.setFieldValue('supplier', selectedSupplier);
} else {
formik.setFieldValue('supplier', null);
}
};
const warehouseChangeHandler = (val: string) => {
const warehouseId = parseInt(val) || 0;
const currentWarehouseIds = formik.values.warehouse_ids || [];
if (warehouseId > 0 && !currentWarehouseIds.includes(warehouseId)) {
const newWarehouseIds = [...currentWarehouseIds, warehouseId].filter(
(id) => id !== undefined && id !== null
);
formik.setFieldValue('warehouse_ids', newWarehouseIds);
const selectedWarehouse = warehouseOptions.find(
(option) => option.value === warehouseId
);
if (selectedWarehouse) {
const currentWarehouses = formik.values.warehouse || [];
formik.setFieldValue('warehouse', [
...currentWarehouses,
selectedWarehouse,
]);
}
}
};
// Purchase Items Handlers
const addPurchaseItem = () => {
const newPurchaseItems = [
...(formik.values.purchase_items || []),
{
product_id: 0,
product_warehouse_id: null,
total_qty: 0,
price: 0,
},
];
formik.setFieldValue('purchase_items', newPurchaseItems);
};
const removePurchaseItem = (idx: number) => {
const updatedPurchaseItems = formik.values.purchase_items?.filter(
(_, i) => i !== idx
);
formik.setFieldValue('purchase_items', updatedPurchaseItems);
};
const removeSelectedPurchaseItems = () => {
const updatedPurchaseItems = formik.values.purchase_items?.filter(
(_, idx) => !selectedPurchaseItems.includes(idx)
);
formik.setFieldValue('purchase_items', updatedPurchaseItems);
setSelectedPurchaseItems([]);
};
const handlePurchaseItemChange = (
idx: number,
field: string,
value: string | number
) => {
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
formik.setFieldValue(`purchase_items.${idx}.${field}`, numValue);
};
return (
<>
<section className='w-full'>
<FormHeader type={type} title='Purchase Request' backUrl='/purchase' />
<form
onSubmit={formik.handleSubmit}
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={
type === 'detail'
? 'flex flex-col gap-6'
: 'grid grid-cols-1 md:grid-cols-2 gap-6'
}
>
<TextInput
required
label='Supplier ID'
name='supplier_id'
value={formik.values.supplier_id}
onChange={(e) => supplierChangeHandler(e.target.value)}
onBlur={formik.handleBlur}
isError={
formik.touched.supplier_id &&
Boolean(formik.errors.supplier_id)
}
errorMessage={formik.errors.supplier_id as string}
readOnly={type === 'detail'}
type='number'
placeholder='Masukkan Supplier ID'
/>
<TextInput
label='Warehouse IDs'
name='warehouse_ids_input'
onChange={(e) => warehouseChangeHandler(e.target.value)}
onBlur={formik.handleBlur}
readOnly={type === 'detail'}
type='number'
placeholder='Tambahkan Warehouse ID'
/>
<TextInput
required
label='Credit Term (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'}
type='number'
placeholder='Masukkan Credit Term'
/>
<div className={type === 'detail' ? 'col-span-1' : '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'}
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>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={
formik.values.purchase_items?.length ===
selectedPurchaseItems.length &&
formik.values.purchase_items?.length > 0
}
onChange={(e) => {
if (e.target.checked) {
setSelectedPurchaseItems(
formik.values.purchase_items?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedPurchaseItems([]);
}
}}
/>
</th>
)}
<th>
Product ID
<span className='text-error'>*</span>
</th>
<th>Product Warehouse ID</th>
<th>
Total Qty
<span className='text-error'>*</span>
</th>
<th>
Price
<span className='text-error'>*</span>
</th>
{type !== 'detail' && <th>Action</th>}
</tr>
</thead>
<tbody>
{formik.values.purchase_items?.map((item, idx) => (
<tr key={`purchase-item-${idx}`}>
{type !== 'detail' && (
<td className='!align-middle'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={selectedPurchaseItems.includes(idx)}
onChange={(e) => {
if (e.target.checked) {
setSelectedPurchaseItems([
...selectedPurchaseItems,
idx,
]);
} else {
setSelectedPurchaseItems(
selectedPurchaseItems.filter((i) => i !== idx)
);
}
}}
/>
</td>
)}
<td>
<TextInput
required
name={`purchase_items.${idx}.product_id`}
value={item.product_id}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'product_id',
e.target.value
)
}
onBlur={formik.handleBlur}
type='number'
placeholder='Product ID'
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-24',
}}
/>
</td>
<td>
<TextInput
name={`purchase_items.${idx}.product_warehouse_id`}
value={item.product_warehouse_id || ''}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'product_warehouse_id',
e.target.value
)
}
onBlur={formik.handleBlur}
type='number'
placeholder='Product Warehouse ID'
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-24',
}}
/>
</td>
<td>
<TextInput
required
name={`purchase_items.${idx}.total_qty`}
value={item.total_qty}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'total_qty',
e.target.value
)
}
onBlur={formik.handleBlur}
type='number'
placeholder='Total Qty'
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-24',
}}
/>
</td>
<td>
<TextInput
required
name={`purchase_items.${idx}.price`}
value={item.price}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'price',
e.target.value
)
}
onBlur={formik.handleBlur}
type='number'
placeholder='Price'
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-24',
}}
/>
</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 */}
<FormActions<PurchaseRequestFormValues>
type={type}
formik={formik}
editUrl={
initialValues
? `/purchase/detail/edit/?purchaseId=${initialValues.id}`
: undefined
}
onDelete={deletePurchaseRequestClickHandler}
/>
{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;
@@ -0,0 +1,70 @@
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { useModal } from '@/components/Modal';
import { PurchaseApi } from '@/services/api/purchasing';
import {
CreatePurchaseRequestPayload,
UpdatePurchaseRequestPayload,
} from '@/types/api/purchase/purchasing';
import { isResponseError } from '@/lib/api-helper';
export const usePurchaseRequestFormHandlers = (initialValuesId?: number) => {
const router = useRouter();
const deleteModal = useModal();
const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] =
useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createPurchaseRequestHandler = useCallback(
async (payload: CreatePurchaseRequestPayload) => {
const res = await PurchaseApi.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: UpdatePurchaseRequestPayload) => {
const res = await PurchaseApi.update(purchaseRequestId, payload);
if (res?.status === 'error') {
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 (!initialValuesId) return;
setIsDeleteLoading(true);
await PurchaseApi.delete(initialValuesId);
deleteModal.closeModal();
toast.success('Successfully delete Purchase Request!');
setIsDeleteLoading(false);
router.push('/purchase');
}, [deleteModal, initialValuesId, router]);
return {
deleteModal,
purchaseRequestFormErrorMessage,
isDeleteLoading,
createPurchaseRequestHandler,
updatePurchaseRequestHandler,
deletePurchaseRequestClickHandler,
confirmationModalDeleteClickHandler,
};
};