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 && ( + + )} + + {/* Daftar opsi radio */} +
+ {options.map((option) => ( + + ))} +
+ + {/* Label bawah */} + {!isError && bottomLabel && ( +

{bottomLabel}

+ )} + + {/* Pesan error */} + {isError && errorMessage && ( +

{errorMessage}

+ )} +
+ ); +}; + +export default RadioInput; diff --git a/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx b/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx new file mode 100644 index 00000000..45cfd3f3 --- /dev/null +++ b/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx @@ -0,0 +1,263 @@ +'use client'; + +import Button from '@/components/Button'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import Table from '@/components/Table'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn } from '@/lib/helper'; +import { inventoryAdjustmentApi } from '@/services/api/inventory'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; +import { Icon } from '@iconify/react'; +import { + ColumnDef, + ColumnSort, + SortingState, +} from '@tanstack/react-table'; +import { useCallback, useEffect, useState } from 'react'; +import useSWR from 'swr'; + +const InventoryAdjustmentTable = () => { + 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 ( + <> +
+
+
+
+ + + {/* */} +
+ +
+ +
+
+ + + 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..bde85b50 --- /dev/null +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema.ts @@ -0,0 +1,42 @@ +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') + .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..e614c9aa --- /dev/null +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx @@ -0,0 +1,441 @@ +'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: initialValues?.transaction_type ?? 'increase', + 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); + if (disabled) { + formik.setFieldValue('product_id', 0); + formik.setFieldValue('product', null); + } + }; + + 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 ( + <> +
+
+ + +

+ {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 */} + + + {/* 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'} + /> + + {/* 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='Pilih salah satu tipe transaksi' + disabled={type === 'detail'} + /> + + {/* Text Area Input Reason */} +