diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..250df482 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "singleQuote": true, + "jsxSingleQuote": true, + "endOfLine": "lf", + "arrowParens": "always", + "bracketSpacing": true, + "embeddedLanguageFormatting": "auto", + "htmlWhitespaceSensitivity": "css", + "printWidth": 80, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "semi": true, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/src/app/inventory/adjustment/add/page.tsx b/src/app/inventory/adjustment/add/page.tsx new file mode 100644 index 00000000..3bd64573 --- /dev/null +++ b/src/app/inventory/adjustment/add/page.tsx @@ -0,0 +1,11 @@ +import InventoryAdjustmentForm from "@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm"; + +const CreateInventoryAdjustment = () => { + return ( + + + + ); +} + +export default CreateInventoryAdjustment; \ No newline at end of file diff --git a/src/app/inventory/adjustment/detail/page.tsx b/src/app/inventory/adjustment/detail/page.tsx new file mode 100644 index 00000000..5e96c86a --- /dev/null +++ b/src/app/inventory/adjustment/detail/page.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import InventoryAdjustmentForm from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm'; +import type { InventoryAdjustment } from '@/types/api/inventory/adjustment'; + +const DetailInventoryAdjustment = () => { + const router = useRouter(); + const [inventoryAdjustment, setInventoryAdjustment] = useState(null); + + // Ambil data dari router state + useEffect(() => { + console.log("Router State"); + console.log(window.history.state); + const state = window.history.state?.usr as + | { inventoryAdjustment?: InventoryAdjustment } + | undefined; + + if (state?.inventoryAdjustment) { + // jika object dikirim via router.push(state) + setInventoryAdjustment(state.inventoryAdjustment); + } + }, [router]); + + const finalData = inventoryAdjustment; + + console.log("Final Data"); + console.log(finalData); + + if (!finalData) { + return ( + + + + ); + } + + return ( + + + + ); +}; + +export default DetailInventoryAdjustment; diff --git a/src/app/inventory/adjustment/page.tsx b/src/app/inventory/adjustment/page.tsx new file mode 100644 index 00000000..518fd0bf --- /dev/null +++ b/src/app/inventory/adjustment/page.tsx @@ -0,0 +1,11 @@ +import InventoryAdjustmentTable from '@/components/pages/inventory/adjustment/InventoryAdjustmentTable'; + +const InventoryAdjustment = () => { + return ( + + + + ); +}; + +export default InventoryAdjustment; diff --git a/src/components/input/RadioInput.tsx b/src/components/input/RadioInput.tsx new file mode 100644 index 00000000..71a731aa --- /dev/null +++ b/src/components/input/RadioInput.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { ChangeEventHandler, ReactNode } from 'react'; +import { cn } from '@/lib/helper'; + +export interface RadioOption { + label: string; + value: string; +} + +export interface RadioInputProps { + label?: string; + bottomLabel?: string; + name: string; + value?: string; + options: RadioOption[]; + variant?: string; + className?: { + wrapper?: string; + label?: string; + radioWrapper?: string; + radio?: string; + }; + isError?: boolean; + isValid?: boolean; + errorMessage?: string; + required?: boolean; + disabled?: boolean; + startAdornment?: ReactNode; + endAdornment?: ReactNode; + onChange?: ChangeEventHandler; + onBlur?: (e: React.FocusEvent) => void; +} + +const RadioInput = ({ + label, + bottomLabel, + name, + value, + options, + variant = 'radio-primary', + className, + isError, + errorMessage, + required = false, + disabled = false, + onChange, + onBlur, +}: RadioInputProps) => { + return ( + + {/* Label atas */} + {label && ( + + {label} + {required && ( + + * + + )} + + )} + + {/* Daftar opsi radio */} + + {options.map((option) => ( + + + {option.label} + + ))} + + + {/* Label bawah */} + {!isError && bottomLabel && ( + {bottomLabel} + )} + + {/* Pesan error */} + {isError && errorMessage && ( + {errorMessage} + )} + + ); +}; + +export default RadioInput; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 930b5ed5..43a3f622 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,28 +1,38 @@ 'use client'; -import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; -import Select, { OptionProps, GroupBase, InputActionMeta } from 'react-select'; +import { + ComponentType, + ReactNode, + useEffect, + useMemo, + useState, +} from 'react'; +import Select, { + OptionProps, + GroupBase, + InputActionMeta, + MultiValue, + SingleValue, +} from 'react-select'; +import CreatableSelect from 'react-select/creatable'; import makeAnimated from 'react-select/animated'; import { useDebounce } from 'use-debounce'; - import { cn } from '@/lib/helper'; export interface OptionType { value: string | number; label: string; - className?: string; // for multi select - labelClassName?: string; // for multi select + className?: string; + labelClassName?: string; } export type OptionComponent = ComponentType< OptionProps> >; -interface SelectInputProps { +interface SelectInputBaseProps { label?: ReactNode; bottomLabel?: ReactNode; - value?: T | T[]; - onChange?: (val: T | T[] | null) => void; options: T[]; optionComponent?: OptionComponent; isDisabled?: boolean; @@ -46,52 +56,78 @@ interface SelectInputProps { onInputChange?: (search: string) => void; } +interface SelectInputProps extends SelectInputBaseProps { + createables?: boolean; + value?: T | T[] | null; + onChange?: (val: T | T[] | null) => void; +} + const animatedComponents = makeAnimated(); -const SelectInput = ({ - label, - bottomLabel, - value, - onChange, - options, - optionComponent, - isDisabled, - isLoading, - isClearable, - isRtl, - isSearchable = true, - isMulti, - placeholder, - required, - className, - isError, - errorMessage, - isAnimated = true, - openMenu, - delay = 300, - onInputChange, -}: SelectInputProps) => { - const [internalInputValue, setInternalInputValue] = useState(''); +const SelectInput = (props: SelectInputProps) => { + const { + label, + bottomLabel, + value, + onChange, + options, + optionComponent, + isDisabled, + isLoading, + isClearable, + isRtl, + isSearchable = true, + isMulti, + placeholder, + required, + className, + isError, + errorMessage, + isAnimated = true, + openMenu, + delay = 300, + createables = false, + onInputChange, + } = props; - const [debouncedInputValue] = useDebounce(internalInputValue, delay ?? 300); + const [internalInputValue, setInternalInputValue] = useState(''); + const [debouncedInputValue] = useDebounce(internalInputValue, delay); const components = useMemo(() => { const base = isAnimated ? animatedComponents : {}; - - return { - ...base, - IndicatorSeparator: () => null, - }; + return { ...base, IndicatorSeparator: () => null }; }, [isAnimated]); - const internalInputChangeHandler = (value: string, meta: InputActionMeta) => { - if (meta.action === 'input-change') setInternalInputValue(value); + const internalInputChangeHandler = ( + val: string, + meta: InputActionMeta + ) => { + if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'menu-close') setInternalInputValue(''); }; useEffect(() => { onInputChange?.(debouncedInputValue); - }, [debouncedInputValue]); + }, [onInputChange, debouncedInputValue]); + + const SelectComponent = createables ? CreatableSelect : Select; + + /** 🎯 handleChange tanpa any */ + const handleChange = ( + val: MultiValue | SingleValue + ): void => { + if (!val) { + onChange?.(null); + return; + } + + if (isMulti) { + onChange?.(val as T[]); + } else { + onChange?.(val as T); + } + }; + return ( ({ {label} {required && ( - <> - {' '} - - * - - > + + * + )} )} - onChange?.(val as T)} + > + instanceId="select" + value={value ?? (isMulti ? [] : null)} + onChange={handleChange} options={options} menuIsOpen={openMenu} inputValue={internalInputValue} @@ -136,14 +167,13 @@ const SelectInput = ({ isRtl={isRtl} isSearchable={isSearchable} placeholder={placeholder} - className={cn('w-full', className)} + className={cn('w-full', className?.select)} classNames={{ control: ({ isFocused, isDisabled }) => cn( 'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!', { - 'border-red-500! focus-within:border-red-500 focus-within:ring-2 focus-within:ring-red-200': - isError, + 'border-red-500! ring-2 ring-red-200': isError, 'border-indigo-500 ring-2 ring-indigo-200': isFocused, 'border-gray-300': !isError && !isFocused, 'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled, @@ -156,8 +186,6 @@ const SelectInput = ({ cn({ 'text-gray-900': !isError, 'text-error!': isError }), input: () => cn('text-gray-900'), indicatorsContainer: () => cn('flex items-center gap-1 pr-2'), - indicatorSeparator: () => cn('mx-1 h-4 w-px bg-gray-200'), - clearIndicator: () => cn('p-1 rounded-md hover:bg-gray-100'), dropdownIndicator: ({ isFocused }) => cn('p-1 rounded-md hover:bg-gray-100', { 'text-gray-900': isFocused, @@ -165,55 +193,41 @@ const SelectInput = ({ 'text-error!': isError, }), menu: () => - cn( - 'border border-gray-200 rounded-lg bg-white shadow-lg rounded-lg!' - ), + cn('border border-gray-200 rounded-lg bg-white shadow-lg!'), menuList: () => cn('p-2! max-h-60 overflow-auto'), - groupHeading: () => - cn('ml-2 mt-2 mb-1 text-xs font-medium text-gray-500'), - option: ({ isFocused, isSelected, isDisabled }) => - cn('mt-1 px-3 py-2 rounded-md cursor-pointer! select-none', { - 'text-gray-300': isDisabled, + option: ({ isFocused, isSelected }) => + cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', { 'bg-indigo-600 text-white': isFocused, - 'text-gray-700': !isDisabled && !isFocused, - 'active:bg-indigo-50': !isDisabled, 'bg-blue-500!': isSelected, + 'text-gray-700': !isFocused && !isSelected, }), - noOptionsMessage: () => cn('px-3 py-2 text-gray-500'), - loadingMessage: () => cn('px-3 py-2 text-gray-500'), multiValue: ({ getValue, index }) => { - const selectedValues = getValue(); - + const selectedValues = getValue() as T[]; return cn( - 'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1 rounded-md!', + 'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1!', selectedValues[index]?.className ); }, multiValueLabel: ({ getValue, index }) => { - const selectedValues = getValue(); - + const selectedValues = getValue() as T[]; return cn('text-indigo-700', selectedValues[index]?.labelClassName); }, - multiValueRemove: () => - cn('p-1 rounded-sm! hover:bg-indigo-100 hover:text-indigo-800'), }} components={{ ...components, ...(optionComponent ? { Option: optionComponent } : {}), }} - // make the menu float above modals/etc. menuPortalTarget={ typeof document !== 'undefined' ? document.body : undefined } styles={{ - // Tailwind can't set inline z-index on a portal; use styles here: menuPortal: (base) => ({ ...base, zIndex: 9999 }), }} /> - {isError && {errorMessage}} + {isError && {errorMessage}} {!isError && bottomLabel && ( - {bottomLabel} + {bottomLabel} )} ); diff --git a/src/components/input/TextArea.tsx b/src/components/input/TextArea.tsx index b4a6c9f5..e9517277 100644 --- a/src/components/input/TextArea.tsx +++ b/src/components/input/TextArea.tsx @@ -31,7 +31,7 @@ export interface TextAreaProps { endAdornment?: ReactNode; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; - cols?: number; + rows?: number; } const TextArea = ({ @@ -52,7 +52,7 @@ const TextArea = ({ onBlur, readOnly = false, isLoading = false, - cols = 3 + rows = 3 }: TextAreaProps) => { return ( { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + productCategorySort: '', + productSort: '', + warehouseSort: '', + stockSort: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + productCategorySort: 'sort_product_category', + productSort: 'sort_product', + warehouseSort: 'sort_warehouse', + stockSort: 'sort_stock', + }, + }); + + // Fetch Data + const { + data: inventoryAdjustments, + isLoading, + } = useSWR( + `${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, + inventoryAdjustmentApi.getAllFetcher + ); + + // State + const [sorting, setSorting] = useState([]); + + // Columns + const inventoryAdjustmentsColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + id: 'product_name', + header: 'Nama Produk', + accessorFn: (row) => row.product_warehouse?.product?.name ?? '-', + }, + { + id: 'warehouse_name', + header: 'Gudang', + accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-', + }, + { + id: 'created_at', + header: 'Tanggal', + accessorFn: (row) => + new Date(row.created_at).toLocaleDateString('id-ID', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + }, + { + id: 'before_quantity', + header: 'Stok Sebelum', + accessorFn: (row) => formatNumber(String(row.before_quantity)), + }, + { + id: 'after_quantity', + header: 'Stok Sesudah', + accessorFn: (row) => formatNumber(String(row.after_quantity)), + }, + { + id: 'quantity', + header: 'Kuantitas', + accessorFn: (row) => formatNumber(String(row.quantity)), + }, + { + id: 'transaction_type', + header: 'Tipe Transaksi', + accessorFn: (row) => { + if (row.transaction_type === 'INCREASE') return 'Peningkatan'; + if (row.transaction_type === 'DECREASE') return 'Penurunan'; + return '-'; + }, + cell: (props) => { + const type = props.row.original.transaction_type; + const label = + type === 'INCREASE' + ? 'Peningkatan' + : type === 'DECREASE' + ? 'Penurunan' + : '-'; + + return ( + + {label} + + ); + }, + }, + { + id: 'created_by', + header: 'Oleh', + accessorFn: (row) => row.created_user?.name ?? '-', + }, + ]; + + // Handler + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + }; + + const updateSortingFilter = useCallback( + ( + sortName: Exclude, + sortFilter: ColumnSort | undefined + ) => { + if (!sortFilter) { + updateFilter(sortName, ''); + } else { + updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); + } + }, + [updateFilter] + ); + + // Effect + useEffect(() => { + const productCategorySortFilter = sorting.find( + (sortItem) => sortItem.id === 'productCategory' + ); + const productSortFilter = sorting.find( + (sortItem) => sortItem.id === 'product' + ); + const warehouseSortFilter = sorting.find( + (sortItem) => sortItem.id === 'warehouse' + ); + const stockSortFilter = sorting.find((sortItem) => sortItem.id === 'stock'); + + updateSortingFilter('productCategorySort', productCategorySortFilter); + updateSortingFilter('productSort', productSortFilter); + updateSortingFilter('warehouseSort', warehouseSortFilter); + updateSortingFilter('stockSort', stockSortFilter); + }, [sorting, updateSortingFilter]); + + // Utils Function + const formatNumber = (value: string) => { + const numericValue = value.replace(/[^0-9.]/g, ''); + const [integer, decimal] = numericValue.split('.'); + const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return decimal ? `${formattedInteger}.${decimal}` : formattedInteger; + }; + + // Render + return ( + <> + + + + + + + Tambah + + + {/* */} + + + + + + + + + data={ + isResponseSuccess(inventoryAdjustments) + ? inventoryAdjustments?.data + : [] + } + columns={inventoryAdjustmentsColumns} + pageSize={tableFilterState.pageSize} + page={ + isResponseSuccess(inventoryAdjustments) + ? inventoryAdjustments?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(inventoryAdjustments) + ? inventoryAdjustments?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(inventoryAdjustments) && + inventoryAdjustments?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> + + + > + ); +}; + +export default InventoryAdjustmentTable; diff --git a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema.ts b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema.ts new file mode 100644 index 00000000..d7a0ae0a --- /dev/null +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema.ts @@ -0,0 +1,43 @@ +import * as Yup from 'yup'; + +export const InventoryAdjustmentFormSchema = Yup.object({ + product_category: Yup.object({ + value: Yup.number().required('ID Kategori Produk wajib diisi!'), + label: Yup.string().required('Nama Kategori Produk wajib diisi!'), + }) + .nullable(), + + product_category_id: Yup.number().nullable(), + + product: Yup.object({ + value: Yup.number().required('ID Produk wajib diisi!'), + label: Yup.string().required('Nama Produk wajib diisi!'), + }) + .nullable(), + + product_id: Yup.number().nullable(), + + warehouse: Yup.object({ + value: Yup.number().required('ID Gudang wajib diisi!'), + label: Yup.string().required('Nama Gudang wajib diisi!'), + }) + .nullable(), + + warehouse_id: Yup.number().nullable(), + + transaction_type: Yup.string() + .oneOf(['increase', 'decrease'], 'Tipe transaksi tidak valid') + .nullable() + .required('Tipe transaksi wajib diisi'), + + quantity: Yup.number() + .typeError('Kuantitas harus berupa angka') + .min(1, 'Minimal kuantitas adalah 1') + .required('Kuantitas wajib diisi'), + + note: Yup.string().required('Catatan wajib diisi!'), +}); + +export type InventoryAdjustmentFormValues = Yup.InferType< + typeof InventoryAdjustmentFormSchema +>; diff --git a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx new file mode 100644 index 00000000..1bb1692d --- /dev/null +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx @@ -0,0 +1,447 @@ +'use client'; + +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { inventoryAdjustmentApi } from '@/services/api/inventory'; +import { + CreateInventoryAdjustmentPayload, + InventoryAdjustment, +} from '@/types/api/inventory/adjustment'; +import { useFormik } from 'formik'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import { + InventoryAdjustmentFormSchema, + InventoryAdjustmentFormValues, +} from './InventoryAdjustmentForm.schema'; +import useSWR from 'swr'; +import { + ProductApi, + ProductCategoryApi, + WarehouseApi, +} from '@/services/api/master-data'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import TextInput from '@/components/input/TextInput'; +import RadioInput from '@/components/input/RadioInput'; +import TextArea from '@/components/input/TextArea'; + +interface InventoryAdjustmentFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: InventoryAdjustment; +} + +const InventoryAdjustmentForm = ({ + type = 'add', + initialValues, +}: InventoryAdjustmentFormProps) => { + // State + const router = useRouter(); + const [ + InventoryAdjustmentFormErrorMessage, + setInventoryAdjustmentFormErrorMessage, + ] = useState(''); + const [selectedProductCategories, setSelectedProductCategories] = + useState(''); + const [disabledProduct, setDisabledProduct] = useState(true); + const [optionsProduct, setOptionsProduct] = useState([]); + const [quantityLabel, setQuantityLabel] = useState('Tambah Stok'); + + // Submit Handler + const createInventoryAdjustmentHandler = useCallback( + async (payload: CreateInventoryAdjustmentPayload) => { + const createInventoryAdjustmentRes = await inventoryAdjustmentApi.create( + payload + ); + + if (isResponseError(createInventoryAdjustmentRes)) { + setInventoryAdjustmentFormErrorMessage( + createInventoryAdjustmentRes.message + ); + return; + } + + toast.success(createInventoryAdjustmentRes?.message as string); + router.push('/inventory/adjustment'); + }, + [router] + ); + + const formikInitialValues = useMemo>(() => { + return { + product_category_id: initialValues?.product_category?.id ?? 0, + product_id: initialValues?.product?.id ?? 0, + warehouse_id: initialValues?.warehouse?.id ?? 0, + product_category: undefined, + product: undefined, + warehouse: undefined, + quantity: initialValues?.quantity ?? 0, + transaction_type: undefined, + note: initialValues?.note ?? '', + }; + }, [initialValues]); + + // Formik + const formik = useFormik({ + enableReinitialize: true, + initialValues: formikInitialValues as InventoryAdjustmentFormValues, + validationSchema: InventoryAdjustmentFormSchema, + onSubmit: async (values) => { + setInventoryAdjustmentFormErrorMessage(''); + const payload: CreateInventoryAdjustmentPayload = { + product_id: values.product_id as number, + warehouse_id: values.warehouse_id as number, + quantity: values.quantity as number, + transaction_type: values.transaction_type as string, + note: values.note, + }; + + switch (type) { + case 'add': + await createInventoryAdjustmentHandler(payload); + break; + } + }, + }); + + // Fetch Data + const productCategoriesUrl = `${ + ProductCategoryApi.basePath + }?${new URLSearchParams({ + search: '', + }).toString()}`; + const { data: productCategories, isLoading: isLoadingProductCategories } = + useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher); + + const productUrl = `${ProductApi.basePath}?${new URLSearchParams({ + search: '', + product_category_id: selectedProductCategories, + }).toString()}`; + const { data: products, isLoading: isLoadingProducts } = useSWR( + productUrl, + ProductApi.getAllFetcher + ); + + const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ + search: '', + }).toString()}`; + const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( + warehouseUrl, + WarehouseApi.getAllFetcher + ); + + // Map Data to Options + const optionsProductCategory = isResponseSuccess(productCategories) + ? productCategories?.data.map((productCategory) => ({ + value: productCategory.id, + label: productCategory.name, + })) + : []; + const optionsWarehouse = isResponseSuccess(warehouses) + ? warehouses?.data.map((warehouse) => ({ + value: warehouse.id, + label: warehouse.name, + })) + : []; + + // Options Handler + const productCategoryChangeHandler = ( + val: OptionType | OptionType[] | null + ) => { + formik.setFieldTouched('product_category_id', true); + formik.setFieldValue('product_category_id', (val as OptionType)?.value); + + formik.setFieldValue('product_category', val); + + setSelectedProductCategories((val as OptionType)?.value as string); + const disabled = (val as OptionType)?.value == null; + setDisabledProduct(disabled); + formik.setFieldValue('product_id', 0); + formik.setFieldValue('product', null); + formik.setFieldTouched('product', false); + formik.setFieldTouched('product_id', false); + }; + + const productChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldValue('product', val); + + formik.setFieldTouched('product_id', true); + formik.setFieldValue('product_id', (val as OptionType)?.value); + }; + + const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldValue('warehouse', val); + + formik.setFieldTouched('warehouse_id', true); + formik.setFieldValue('warehouse_id', (val as OptionType)?.value); + }; + + const resetHandler = () => { + formik.resetForm(); + setQuantityLabel('Tambah Stok'); + productCategoryChangeHandler(null); + productChangeHandler(null); + warehouseChangeHandler(null); + }; + + + const { setValues: formikSetValues } = formik; + + // Effect + useEffect(() => { + if (initialValues?.product_warehouse?.product?.id) { + setSelectedProductCategories( + String(initialValues.product_warehouse.product.id) + ); + setDisabledProduct(false); + formik.setFieldValue( + 'product_id', + initialValues.product_warehouse.product.id + ); + formik.setFieldValue('product', { + value: initialValues.product_warehouse.product.id, + label: initialValues.product_warehouse.product.name, + }); + formik.setFieldValue( + 'warehouse_id', + initialValues.product_warehouse.warehouse.id + ); + formik.setFieldValue('warehouse', { + value: initialValues.product_warehouse.warehouse.id, + label: initialValues.product_warehouse.warehouse.name, + }); + formik.setFieldValue( + 'quantity', + initialValues.product_warehouse.quantity + ); + formik.setFieldValue( + 'transaction_type', + initialValues.transaction_type.toLowerCase() + ); + formik.setFieldValue('note', initialValues.note); + } + if (initialValues?.transaction_type) { + const type = initialValues.transaction_type.toLowerCase(); + setQuantityLabel(type === 'increase' ? 'Tambah Stok' : 'Kurangi Stok'); + } + }, [formik, initialValues, setQuantityLabel, setDisabledProduct, setSelectedProductCategories]); + useEffect(() => { + formikSetValues(formikInitialValues as InventoryAdjustmentFormValues); + }, [formikSetValues, formikInitialValues]); + useEffect(() => { + if (isResponseSuccess(products)) { + const options = products.data.map((p) => ({ + value: p.id, + label: p.name, + })); + setOptionsProduct(options); + } + }, [products]); + + // Utils Function + const formatNumber = (value: string) => { + const numericValue = value.replace(/[^0-9.]/g, ''); + const [integer, decimal] = numericValue.split('.'); + const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return decimal ? `${formattedInteger}.${decimal}` : formattedInteger; + }; + + // Render + return ( + <> + + + + + Kembali + + + + {type === 'add' && 'Tambah Penyesuaian Persediaan'} + {type === 'detail' && 'Detail Penyesuaian Persediaan'} + + + + + + {/* Text Input Before Quantity */} + {type === 'detail' && initialValues && ( + <> + + + > + )} + + {/* Select Input Product Category */} + + + {/* Select Input Product */} + + + {/* Select Input Warehouse */} + + + {/* Radio Button Flag Stock */} + { + formik.handleChange(e); + setQuantityLabel( + e.target.value === 'increase' ? 'Tambah Stok' : 'Kurangi Stok' + ); + }} + onBlur={formik.handleBlur} + isError={ + formik.touched.transaction_type && + Boolean(formik.errors.transaction_type) + } + errorMessage={formik.errors.transaction_type as string} + variant='radio-primary' + required + bottomLabel={formik.values.transaction_type == undefined ? 'Pilih salah satu tipe transaksi' : undefined} + disabled={type === 'detail'} + /> + + {/* Number Input Stock */} + { + const rawValue = e.target.value.replace(/,/g, ''); + const numericValue = parseFloat(rawValue); + if (!isNaN(numericValue)) { + formik.setFieldValue('quantity', numericValue); + } else { + formik.setFieldValue('quantity', 0); + } + }} + onBlur={formik.handleBlur} + isError={ + formik.touched.quantity && Boolean(formik.errors.quantity) + } + errorMessage={formik.errors.quantity as string} + readOnly={type === 'detail'} + /> + + + + {/* Text Area Input Reason */} + + + + {type !== 'detail' && ( + + + Reset + + + Submit + + + )} + + {InventoryAdjustmentFormErrorMessage && ( + + + {InventoryAdjustmentFormErrorMessage} + + )} + + + > + ); +}; + +export default InventoryAdjustmentForm; diff --git a/src/components/pages/master-data/customer/form/CustomerForm.tsx b/src/components/pages/master-data/customer/form/CustomerForm.tsx index ee824866..533e0c38 100644 --- a/src/components/pages/master-data/customer/form/CustomerForm.tsx +++ b/src/components/pages/master-data/customer/form/CustomerForm.tsx @@ -41,7 +41,6 @@ const CustomerForm = ({ const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState(''); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [picSelectInputValue, setPicSelectInputValue] = useState(''); - const [typeSelectInputValue, setTypeSelectInputValue] = useState(''); // Fetch Data const picUrl = `${UserApi.basePath}?${new URLSearchParams({ @@ -252,7 +251,6 @@ const CustomerForm = ({ } onChange={typeChangeHandler} options={typeOptions} - onInputChange={setTypeSelectInputValue} isError={formik.touched.type && Boolean(formik.errors.type)} errorMessage={formik.errors.type as string} isDisabled={formType === 'detail'} @@ -309,7 +307,6 @@ const CustomerForm = ({ isError={formik.touched.address && Boolean(formik.errors.address)} errorMessage={formik.errors.address} readOnly={formType === 'detail'} - cols={8} /> diff --git a/src/components/pages/master-data/supplier/form/SupplierForm.schema.ts b/src/components/pages/master-data/supplier/form/SupplierForm.schema.ts index 69f127a3..44d15478 100644 --- a/src/components/pages/master-data/supplier/form/SupplierForm.schema.ts +++ b/src/components/pages/master-data/supplier/form/SupplierForm.schema.ts @@ -2,7 +2,10 @@ import * as Yup from 'yup'; export const SupplierFormSchema = Yup.object({ name: Yup.string().required('Nama wajib diisi!'), - alias: Yup.string().required('Alias wajib diisi!'), + alias: Yup.string() + .matches(/^[A-Za-z0-9]+$/, 'Alias hanya boleh berisi huruf dan angka tanpa spasi atau simbol!') + .max(5, 'Alias maksimal 5 karakter!') + .required('Alias wajib diisi!'), pic: Yup.string().required('PIC wajib diisi!'), type: Yup.object({ value: Yup.string().required(), diff --git a/src/components/pages/master-data/supplier/form/SupplierForm.tsx b/src/components/pages/master-data/supplier/form/SupplierForm.tsx index fb4630ae..74c4da27 100644 --- a/src/components/pages/master-data/supplier/form/SupplierForm.tsx +++ b/src/components/pages/master-data/supplier/form/SupplierForm.tsx @@ -21,7 +21,6 @@ import SelectInput, { OptionType } from '@/components/input/SelectInput'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; -import TagInput from '@/components/input/TagInput'; import TextArea from '@/components/input/TextArea'; import { cn } from '@/lib/helper'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; @@ -42,9 +41,7 @@ const SupplierForm = ({ // Setup State const [supplierFormErrorMessage, setSupplierFormErrorMessage] = useState(''); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const [typeSelectInputValue, setTypeSelectInputValue] = useState(''); - const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); - const [hatcheryTagInputValue, setHatcheryTagInputValue] = useState(''); + const [hatcheryOptionsValues, setHatcheryOptionValues] = useState([]); // -- Options data mapping const typeOptions = TYPE_OPTIONS; @@ -108,8 +105,6 @@ const SupplierForm = ({ }; // Memo - console.log('Memo'); - console.log(initialValues); const formikInitialValues = useMemo(() => { return { name: initialValues?.name ?? '', @@ -125,7 +120,7 @@ const SupplierForm = ({ account_number: initialValues?.account_number ?? '', due_date: initialValues?.due_date ?? 1, }; - }, [initialValues]); + }, [initialValues, typeOptions, categoryOptions]); // Formik const formik = useFormik({ @@ -172,8 +167,22 @@ const SupplierForm = ({ // Initialize Formik useEffect(() => { formikSetValues(formikInitialValues); - setHatcheryTagInputValue(formikInitialValues.hatchery); - }, [formikSetValues, formikInitialValues, hatcheryTagInputValue]); + if(formType != 'add'){ + const hatcheryArrays = formikInitialValues.hatchery.split(','); + const hatcheryCreatedOptions = hatcheryArrays.map((item) => ({ + value: item, + label: item, + })); + setHatcheryOptionValues(hatcheryCreatedOptions); + } + }, [formikSetValues, formikInitialValues, setHatcheryOptionValues]); + useEffect(() => { + const commaSeparatedValues = hatcheryOptionsValues.map((item) => item.value).join(','); + formikSetValues({ + ...formik.values, + hatchery: commaSeparatedValues, + }) + }, [hatcheryOptionsValues, formikSetValues]); // Option Handler const typeChangeHandler = (val: OptionType | OptionType[] | null) => { @@ -260,7 +269,6 @@ const SupplierForm = ({ } onChange={typeChangeHandler} options={typeOptions} - onInputChange={setTypeSelectInputValue} isError={formik.touched.type && Boolean(formik.errors.type)} errorMessage={formik.errors.type as string} isDisabled={formType === 'detail'} @@ -278,7 +286,6 @@ const SupplierForm = ({ } onChange={categoryChangeHandler} options={categoryOptions} - onInputChange={setCategorySelectInputValue} isError={ formik.touched.category && Boolean(formik.errors.category) } @@ -287,17 +294,25 @@ const SupplierForm = ({ isClearable isSearchable={true} /> - formik.setFieldValue('hatchery', value)} - isError={ - formik.touched.hatchery && Boolean(formik.errors.hatchery) - } - errorMessage={formik.errors.hatchery} - readOnly={formType === 'detail'} + value={hatcheryOptionsValues} + onChange={(val) => { + console.log(val); // pastikan val = array of { value, label } + setHatcheryOptionValues(val as OptionType[]); + }} + isError={formik.touched.hatchery && Boolean(formik.errors.hatchery)} + errorMessage={formik.errors.hatchery as string} + isDisabled={formType === 'detail'} + isClearable + isSearchable={true} + options={[]} /> + ('/inventory/movements'); \ No newline at end of file + Movement, + CreateMovementPayload, + UpdateMovementPayload +>('/inventory/movements'); + +export const inventoryAdjustmentApi = new BaseApiService< + InventoryAdjustment, + CreateInventoryAdjustmentPayload, + UpdateInventoryAdjustmentPayload +>('/inventory/adjustments'); diff --git a/src/types/api/inventory/adjustment.d.ts b/src/types/api/inventory/adjustment.d.ts new file mode 100644 index 00000000..270e527f --- /dev/null +++ b/src/types/api/inventory/adjustment.d.ts @@ -0,0 +1,34 @@ +import { Product } from '@/types/api/master-data/product'; +import { Warehouse } from '../master-data/warehouse'; +import { BaseMetadata } from '../base-metadata'; + +export type BaseInventoryAdjustment = { + id: number; + transaction_type: string; + quantity: number; + before_quantity: number; + after_quantity: number; + note: string; + product_warehouse_id: number; + product_warehouse: { + id: number; + quantity: number; + product_id: number; + warehouse_id: number; + product: Product; + warehouse: Warehouse; + }; +}; + +export type InventoryAdjustment = BaseMetadata & BaseInventoryAdjustment; + +export type CreateInventoryAdjustmentPayload = { + product_id: number; + warehouse_id: number; + transaction_type: string; + quantity: number; + note: string; +}; + +export type UpdateInventoryAdjustmentPayload = + Partial;
{bottomLabel}
{errorMessage}