diff --git a/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts b/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts new file mode 100644 index 00000000..88d5a0b8 --- /dev/null +++ b/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts @@ -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 = + 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() + .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() + .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 = + 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 +>; diff --git a/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx new file mode 100644 index 00000000..8f303b56 --- /dev/null +++ b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx @@ -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 + | undefined; + + if (!touchedItem) { + return { isError: false, errorMessage: '' }; + } + + const isTouched = (touchedItem as Record)?.[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 ( + <> +
+
+ +
+ + + + + + + + + + + + + + + {formik.values.items?.map((item, idx) => { + const selectedPurchaseItem = purchaseItems.find( + (p) => p.value === item.purchase_item_id + ); + return ( + + + + + + + + + + + ); + })} + +
+ Item + * + GudangProdukJenis ProdukJumlahSatuan + Harga Satuan + * + + Total (Rp.) + * +
+ + 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', + }} + /> + + + + + + + + + + + + + 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', + }} + /> + + + 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', + }} + /> +
+
+
+ +
+ + {/* Action buttons */} +
+
+ + + + + +
+
+ + {purchaseOrderFormErrorMessage && ( +
+ + {purchaseOrderFormErrorMessage} +
+ )} +
+
+
+ + ); +}; + +export default PurchaseOrderStaffApprovalForm;