feat(FE-208,212): add PurchaseRequisitionsStaffApprovalForm and schema for staff approval process

This commit is contained in:
rstubryan
2025-11-11 11:26:46 +07:00
parent 5fc01a9afa
commit f7b2e3c6f2
2 changed files with 676 additions and 0 deletions
@@ -0,0 +1,113 @@
import * as Yup from 'yup';
import { Purchase } from '@/types/api/purchase/purchase';
type PurchaseRequisitionsStaffApprovalFormSchemaType = {
notes: string | null;
items: {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
price: number | string;
total_price: number | string;
}[];
};
export type PurchaseStaffApprovalItemSchema = {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
price: number | string;
total_price: number | string;
};
const PurchaseStaffApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseStaffApprovalItemSchema> =
Yup.object({
purchase_item: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
purchase_item_id: Yup.number()
.required('Purchase item is required!')
.test('is-valid-purchase-item', 'Purchase item must be selected!', function (value) {
if (!this.parent.purchase_item) return true;
return Boolean(value && value > 0);
})
.typeError('Purchase item must be selected!'),
price: Yup.mixed<string | number>()
.required('Harga wajib diisi!')
.test(
'is-valid-price',
'Harga harus berupa angka lebih dari atau sama dengan 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0;
}
),
total_price: Yup.mixed<string | number>()
.required('Total harga wajib diisi!')
.test(
'is-valid-total-price',
'Total harga harus berupa angka lebih dari atau sama dengan 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0;
}
),
});
export const PurchaseRequisitionsStaffApprovalFormSchema: Yup.ObjectSchema<PurchaseRequisitionsStaffApprovalFormSchemaType> =
Yup.object({
notes: Yup.string().nullable().default(null),
items: Yup.array()
.of(PurchaseStaffApprovalItemObjectSchema)
.min(1, 'Minimal harus ada 1 item pembelian!')
.required('Item pembelian wajib diisi!')
.typeError('Item pembelian wajib diisi!'),
});
export const PurchaseRequisitionsStaffApprovalFormInitialValues: PurchaseRequisitionsStaffApprovalFormSchemaType =
{
notes: '',
items: [
{
purchase_item_id: 0,
price: '',
total_price: '',
},
],
};
export const PurchaseRequisitionsStaffApprovalFormDefaultValues = (
purchase?: Purchase
): PurchaseRequisitionsStaffApprovalFormSchemaType => {
return {
notes: purchase?.notes ?? null,
items: purchase?.items
? purchase.items.map((item) => ({
purchase_item_id: item.id,
price: '',
total_price: '',
}))
: [
{
purchase_item_id: 0,
price: '',
total_price: '',
},
],
};
};
export type PurchaseRequisitionsStaffApprovalFormValues = Yup.InferType<
typeof PurchaseRequisitionsStaffApprovalFormSchema
>;
@@ -0,0 +1,563 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import {
PurchaseRequisitionsStaffApprovalFormDefaultValues,
PurchaseRequisitionsStaffApprovalFormInitialValues,
PurchaseRequisitionsStaffApprovalFormSchema,
} from './PurchaseOrderForm.schema';
import { isResponseError } from '@/lib/api-helper';
import { StaffApprovalApi } from '@/services/api/purchase';
import {
CreateStaffApprovalRequisitionsPayload,
Purchase,
} from '@/types/api/purchase/purchase';
import Card from '@/components/Card';
interface PurchaseOrderStaffApprovalFormProps {
type?: 'add' | 'edit';
initialValues?: Purchase;
}
const PurchaseOrderStaffApprovalForm = ({
type = 'add',
initialValues,
}: PurchaseOrderStaffApprovalFormProps) => {
const searchParams = useSearchParams();
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
useState('');
// ===== TYPE DEFINITIONS =====
interface PurchaseItemOptionType extends OptionType {
id: number;
quantity: number;
product: {
name: string;
type?: string;
uom: {
name: string;
};
};
warehouse: {
name: string;
};
}
// ===== UTILITY FUNCTIONS =====
const getPurchaseItemError = (
idx: number,
field: 'purchase_item_id' | 'price' | 'total_price'
): { isError: boolean; errorMessage: string } => {
const touchedItem = formik.touched.items?.[idx];
const errorItem = formik.errors.items?.[idx] as
| Record<string, string>
| undefined;
if (!touchedItem) {
return { isError: false, errorMessage: '' };
}
const isTouched = (touchedItem as Record<string, boolean>)?.[field];
const errorMessage = errorItem?.[field] || '';
return {
isError: Boolean(isTouched && errorMessage),
errorMessage: isTouched && errorMessage ? errorMessage : '',
};
};
// ===== SUBMISSION HANDLERS =====
const createStaffApprovalHandler = useCallback(
async (payload: CreateStaffApprovalRequisitionsPayload) => {
const purchaseRequisitionId = searchParams.get('purchaseId')
? parseInt(searchParams.get('purchaseId')!)
: initialValues?.id || 1;
if (!purchaseRequisitionId) {
setPurchaseOrderFormErrorMessage('Purchase Requisition ID is required');
return;
}
const res = await StaffApprovalApi.createStaffApproval(
purchaseRequisitionId,
payload
);
if (isResponseError(res)) {
setPurchaseOrderFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
},
[initialValues?.id, searchParams]
);
const updateStaffApprovalHandler = useCallback(
async (
purchaseId: number,
payload: CreateStaffApprovalRequisitionsPayload
) => {
const res = await StaffApprovalApi.createStaffApproval(
purchaseId,
payload
);
if (isResponseError(res)) {
setPurchaseOrderFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
window.location.href = '/purchase';
},
[]
);
// ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo(() => {
return initialValues
? PurchaseRequisitionsStaffApprovalFormDefaultValues(initialValues)
: PurchaseRequisitionsStaffApprovalFormInitialValues;
}, [initialValues]);
const formik = useFormik({
initialValues: formikInitialValues,
validationSchema: PurchaseRequisitionsStaffApprovalFormSchema,
validateOnChange: true,
validateOnBlur: true,
onSubmit: async (values) => {
const payload: CreateStaffApprovalRequisitionsPayload = {
notes: values.notes || '',
items: (values.items || []).map((item) => ({
purchase_item_id:
typeof item.purchase_item_id === 'string'
? parseInt(item.purchase_item_id) || 0
: item.purchase_item_id || 0,
price:
typeof item.price === 'string'
? parseFloat(item.price) || 0
: item.price || 0,
total_price:
typeof item.total_price === 'string'
? parseFloat(item.total_price) || 0
: item.total_price || 0,
})),
};
switch (type) {
case 'add':
await createStaffApprovalHandler(payload);
break;
case 'edit':
await updateStaffApprovalHandler(
initialValues?.id as number,
payload
);
break;
}
},
});
// ===== API DATA FETCHING =====
const purchaseItems = useMemo(() => {
if (initialValues?.items) {
return initialValues.items.map((item) => ({
value: item.id,
label: `${item.product.name} (${item.quantity} ${item.product.uom.name})`,
id: item.id,
quantity: item.quantity,
product: {
name: item.product.name,
product_category: item.product.product_category,
uom: {
name: item.product.uom.name,
},
},
warehouse: {
name: item.warehouse?.name || '',
},
}));
}
return [
{
value: 1,
label: 'SEALYTE SPARK 1 x 87 gr (14 SACHET)',
id: 1,
quantity: 14,
product: {
name: 'SEALYTE SPARK 1 x 87 gr',
product_category: 'Bahan Baku',
uom: {
name: 'SACHET',
},
},
warehouse: {
name: 'GUDANG CIANGSANA 1 (ARCA P15)',
},
},
{
value: 2,
label: 'CID-2000 @ 5 KG (2 KILOGRAM)',
id: 2,
quantity: 2,
product: {
name: 'CID-2000 @ 5 KG',
product_category: 'Bahan Baku',
uom: {
name: 'Kilogram',
},
},
warehouse: {
name: 'GUDANG CIANGSANA 2 (ARCA P15)',
},
},
{
value: 3,
label: 'VITAMIN AYAM (10 DOSIS)',
id: 3,
quantity: 10,
product: {
name: 'VITAMIN AYAM',
product_category: 'Bahan Baku',
uom: {
name: 'DOSIS',
},
},
warehouse: {
name: 'GUDANG CIANGSANA 3 (ARCA P15)',
},
},
];
}, [initialValues?.items, searchParams]);
const getPurchaseItemOptions = useCallback(() => {
return purchaseItems;
}, [purchaseItems]);
// ===== FIELD CHANGE HANDLERS =====
const purchaseItemChangeHandler = (
idx: number,
val: OptionType | OptionType[] | null
) => {
const purchaseItem = val as PurchaseItemOptionType | null;
formik.setFieldTouched(`items.${idx}.purchase_item`, true);
formik.setFieldValue(`items.${idx}.purchase_item`, purchaseItem);
formik.setFieldTouched(`items.${idx}.purchase_item_id`, true);
formik.setFieldValue(
`items.${idx}.purchase_item_id`,
(purchaseItem as OptionType)?.value || 0
);
};
// ===== PURCHASE ITEM OPERATIONS =====
const handlePurchaseItemChange = (
idx: number,
field: 'price' | 'total_price',
value: string | number
) => {
if (field === 'price' || field === 'total_price') {
const numValue =
typeof value === 'string' ? parseFloat(value) || 0 : value;
formik.setFieldValue(`items.${idx}.${field}`, numValue);
if (field === 'price') {
const selectedItem = purchaseItems.find(
(p) => p.value === formik.values.items?.[idx]?.purchase_item_id
);
if (selectedItem && selectedItem.quantity && numValue > 0) {
const calculatedTotal = numValue * selectedItem.quantity;
formik.setFieldValue(`items.${idx}.total_price`, calculatedTotal);
}
}
if (field === 'total_price') {
const selectedItem = purchaseItems.find(
(p) => p.value === formik.values.items?.[idx]?.purchase_item_id
);
if (
selectedItem &&
selectedItem.quantity &&
selectedItem.quantity > 0 &&
numValue > 0
) {
const calculatedPrice = numValue / selectedItem.quantity;
formik.setFieldValue(`items.${idx}.price`, calculatedPrice);
}
}
}
};
return (
<>
<section className='w-full'>
<form
onSubmit={formik.handleSubmit}
className='w-full mt-8 flex flex-col gap-6'
>
<Card
title='Konfirmasi Approve Pembelian'
className={{
wrapper: 'w-full mb-4 shadow',
title: 'mb-4',
}}
>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>
Item
<span className='text-error'>*</span>
</th>
<th>Gudang</th>
<th>Produk</th>
<th>Jenis Produk</th>
<th>Jumlah</th>
<th>Satuan</th>
<th>
Harga Satuan
<span className='text-error'>*</span>
</th>
<th>
Total (Rp.)
<span className='text-error'>*</span>
</th>
</tr>
</thead>
<tbody>
{formik.values.items?.map((item, idx) => {
const selectedPurchaseItem = purchaseItems.find(
(p) => p.value === item.purchase_item_id
);
return (
<tr key={`purchase-item-${idx}`}>
<td>
<SelectInput
required
isClearable={true}
value={item.purchase_item}
key={`purchase-item-${idx}`}
onChange={(val) =>
purchaseItemChangeHandler(idx, val)
}
options={getPurchaseItemOptions()}
isError={
getPurchaseItemError(idx, 'purchase_item_id')
.isError
}
errorMessage={
getPurchaseItemError(idx, 'purchase_item_id')
.errorMessage
}
placeholder='Pilih Item...'
className={{
wrapper: 'min-w-48',
}}
/>
</td>
<td>
<TextInput
name={`items.${idx}.warehouse`}
type='text'
value={selectedPurchaseItem?.warehouse?.name || ''}
readOnly={true}
placeholder='Pilih item terlebih dahulu'
className={{
wrapper: 'min-w-32',
}}
disabled={true}
/>
</td>
<td>
<TextInput
name={`items.${idx}.product_name`}
type='text'
value={selectedPurchaseItem?.product?.name || ''}
readOnly={true}
placeholder='Pilih item terlebih dahulu'
className={{
wrapper: 'min-w-48',
}}
disabled={true}
/>
</td>
<td>
<TextInput
name={`items.${idx}.product_category`}
type='text'
value={
typeof selectedPurchaseItem?.product
?.product_category === 'string'
? selectedPurchaseItem.product.product_category
: selectedPurchaseItem?.product
?.product_category?.name || ''
}
readOnly={true}
placeholder='Pilih item terlebih dahulu'
className={{
wrapper: 'min-w-32',
}}
disabled={true}
/>
</td>
<td>
<TextInput
name={`items.${idx}.quantity`}
type='text'
value={
selectedPurchaseItem?.quantity
? selectedPurchaseItem.quantity.toLocaleString(
'id-ID'
)
: ''
}
readOnly={true}
placeholder='Pilih item terlebih dahulu'
className={{
wrapper: 'min-w-24',
}}
disabled={true}
/>
</td>
<td>
<TextInput
name={`items.${idx}.uom`}
type='text'
value={
selectedPurchaseItem?.product?.uom?.name || ''
}
readOnly={true}
placeholder='Pilih item terlebih dahulu'
className={{
wrapper: 'min-w-24',
}}
disabled={true}
/>
</td>
<td>
<NumberInput
required
name={`items.${idx}.price`}
value={item.price || ''}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'price',
e.target.value
)
}
onBlur={formik.handleBlur}
placeholder='Masukkan harga satuan'
allowNegative={false}
decimalScale={2}
thousandSeparator=','
decimalSeparator='.'
inputPrefix={'Rp'}
isError={getPurchaseItemError(idx, 'price').isError}
errorMessage={
getPurchaseItemError(idx, 'price').errorMessage
}
className={{
wrapper: 'min-w-40',
}}
/>
</td>
<td>
<NumberInput
required
name={`items.${idx}.total_price`}
value={item.total_price || ''}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'total_price',
e.target.value
)
}
onBlur={formik.handleBlur}
placeholder='Masukkan total harga'
allowNegative={false}
decimalScale={2}
thousandSeparator=','
decimalSeparator='.'
inputPrefix={'Rp'}
isError={
getPurchaseItemError(idx, 'total_price').isError
}
errorMessage={
getPurchaseItemError(idx, 'total_price')
.errorMessage
}
className={{
wrapper: 'min-w-40',
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<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}
placeholder='Masukkan catatan'
/>
</div>
{/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap mt-5'>
<div className='flex flex-row justify-end gap-2 w-full'>
<Link href='/purchase'>
<Button color='warning' className='px-4'>
Cancel
</Button>
</Link>
<Button
type='submit'
color='primary'
className='px-4'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
</div>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
</Card>
</form>
</section>
</>
);
};
export default PurchaseOrderStaffApprovalForm;