diff --git a/src/app/purchase/add/page.tsx b/src/app/purchase/add/page.tsx new file mode 100644 index 00000000..c69d3b10 --- /dev/null +++ b/src/app/purchase/add/page.tsx @@ -0,0 +1,11 @@ +import PurchaseRequestForm from '@/components/pages/purchase/form/PurchaseRequestForm'; + +const AddPurchaseRequest = () => { + return ( +
+ +
+ ); +}; + +export default AddPurchaseRequest; diff --git a/src/components/pages/purchase/form/PurchaseRequestForm.tsx b/src/components/pages/purchase/form/PurchaseRequestForm.tsx new file mode 100644 index 00000000..03da06c6 --- /dev/null +++ b/src/components/pages/purchase/form/PurchaseRequestForm.tsx @@ -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( + [] + ); + + 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( + () => getPurchaseRequestFormInitialValues(initialValues), + [initialValues] + ); + + const formik = useFormik({ + 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 ( + <> +
+ +
+ {/* Basic Info Card */} + +
+ 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' + /> + + warehouseChangeHandler(e.target.value)} + onBlur={formik.handleBlur} + readOnly={type === 'detail'} + type='number' + placeholder='Tambahkan Warehouse ID' + /> + + + +
+ +
+
+
+ + {/* Purchase Items Table */} + +
+ + + + {type !== 'detail' && ( + + )} + + + + + {type !== 'detail' && } + + + + {formik.values.purchase_items?.map((item, idx) => ( + + {type !== 'detail' && ( + + )} + + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedPurchaseItems( + formik.values.purchase_items?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedPurchaseItems([]); + } + }} + /> + + Product ID + * + Product Warehouse ID + Total Qty + * + + Price + * + Action
+ { + if (e.target.checked) { + setSelectedPurchaseItems([ + ...selectedPurchaseItems, + idx, + ]); + } else { + setSelectedPurchaseItems( + selectedPurchaseItems.filter((i) => i !== idx) + ); + } + }} + /> + + + handlePurchaseItemChange( + idx, + 'product_id', + e.target.value + ) + } + onBlur={formik.handleBlur} + type='number' + placeholder='Product ID' + readOnly={type === 'detail'} + className={{ + wrapper: 'min-w-24', + }} + /> + + + 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', + }} + /> + + + handlePurchaseItemChange( + idx, + 'total_qty', + e.target.value + ) + } + onBlur={formik.handleBlur} + type='number' + placeholder='Total Qty' + readOnly={type === 'detail'} + className={{ + wrapper: 'min-w-24', + }} + /> + + + handlePurchaseItemChange( + idx, + 'price', + e.target.value + ) + } + onBlur={formik.handleBlur} + type='number' + placeholder='Price' + readOnly={type === 'detail'} + className={{ + wrapper: 'min-w-24', + }} + /> + +
+ +
+
+
+ {type !== 'detail' && ( +
+ {selectedPurchaseItems.length > 0 && ( + + )} + +
+ )} +
+ + {/* Action buttons */} + + type={type} + formik={formik} + editUrl={ + initialValues + ? `/purchase/detail/edit/?purchaseId=${initialValues.id}` + : undefined + } + onDelete={deletePurchaseRequestClickHandler} + /> + {purchaseRequestFormErrorMessage && ( +
+ + {purchaseRequestFormErrorMessage} +
+ )} + +
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default PurchaseRequestForm; diff --git a/src/components/pages/purchase/form/usePurchaseRequestFormHandlers.ts b/src/components/pages/purchase/form/usePurchaseRequestFormHandlers.ts new file mode 100644 index 00000000..0594c0f8 --- /dev/null +++ b/src/components/pages/purchase/form/usePurchaseRequestFormHandlers.ts @@ -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, + }; +}; \ No newline at end of file