diff --git a/package-lock.json b/package-lock.json index 33b7c640..2fa0e38b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -31,7 +30,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1639,13 +1637,6 @@ "@types/react": "*" } }, - "node_modules/@types/inputmask": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz", - "integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1681,6 +1672,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1750,6 +1742,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2267,6 +2260,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2800,7 +2794,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/daisyui": { "version": "5.3.10", @@ -3228,6 +3223,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3401,6 +3397,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4203,12 +4200,6 @@ "node": ">=0.8.19" } }, - "node_modules/inputmask": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz", - "integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==", - "license": "MIT" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5745,6 +5736,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5754,6 +5746,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6552,6 +6545,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6719,6 +6713,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 10fe9598..1d181a40 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -34,7 +33,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index 6926ce4e..8ff39e3d 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -1,24 +1,46 @@ 'use client'; -import { useState } from 'react'; +import { ChangeEventHandler, useState } from 'react'; import useSWR from 'swr'; -import { SortingState } from '@tanstack/react-table'; +import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import Table from '@/components/Table'; -import { useModal } from '@/components/Modal'; -import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { Icon } from '@iconify/react'; import { Movement } from '@/types/api/inventory/movement'; import { MovementApi } from '@/services/api/inventory'; import { cn } from '@/lib/helper'; +import { Product } from '@/types/api/master-data/product'; +import { Warehouse } from '@/types/api/master-data/warehouse'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ROWS_OPTIONS } from '@/config/constant'; -import { TableToolbar } from '@/components/table/TableToolbar'; -import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; -import { OptionType } from '@/components/input/SelectInput'; +import { OptionType, useSelect } from '@/components/input/SelectInput'; +import Button from '@/components/Button'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import SelectInput from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; -import { TableRowOptions } from '@/components/table/TableRowOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; +}) => ( + + + +); const MovementTable = () => { const { @@ -28,30 +50,47 @@ const MovementTable = () => { setPageSize, toQueryString: getTableFilterQueryString, } = useTableFilter({ - initial: { search: '' }, - paramMap: { page: 'page', pageSize: 'limit' }, + initial: { + search: '', + product: '', + warehouse: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + product: 'product_id', + warehouse: 'warehouse_id', + }, }); const [sorting, setSorting] = useState([]); - const [selectedMovement, setSelectedMovement] = useState< - Movement | undefined - >(undefined); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const deleteModal = useModal(); const { - data: movements, - isLoading, - mutate: refreshMovements, - } = useSWR( + setInputValue: setProductInputValue, + options: productOptions, + isLoadingOptions: isLoadingProductOptions, + } = useSelect('/products', 'id', 'name'); + + const { + setInputValue: setWarehouseInputValue, + options: warehouseOptions, + isLoadingOptions: isLoadingWarehouseOptions, + } = useSelect('/warehouses', 'id', 'name'); + + const [selectedProduct, setSelectedProduct] = useState( + null + ); + const [selectedWarehouse, setSelectedWarehouse] = useState( + null + ); + + const { data: movements, isLoading } = useSWR( `${MovementApi.basePath}${getTableFilterQueryString()}`, MovementApi.getAllFetcher ); - const searchChangeHandler = (e: React.ChangeEvent) => { + const searchChangeHandler: ChangeEventHandler = (e) => { updateFilter('search', e.target.value); - setPage(1); }; const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { @@ -60,167 +99,179 @@ const MovementTable = () => { setPage(1); }; - const confirmationModalDeleteClickHandler = async () => { - setIsDeleteLoading(true); - try { - await MovementApi.delete(selectedMovement?.id as number); - refreshMovements(); - deleteModal.closeModal(); - } finally { - setIsDeleteLoading(false); - } + const productChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedProduct(val as OptionType); + updateFilter('product', val ? ((val as OptionType).value as string) : ''); }; + const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedWarehouse(val as OptionType); + updateFilter('warehouse', val ? ((val as OptionType).value as string) : ''); + }; + + const movementColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorFn: (row) => row.source_warehouse?.name, + header: 'Gudang Asal', + }, + { + accessorFn: (row) => row.destination_warehouse?.name, + header: 'Gudang Tujuan', + }, + { + accessorKey: 'transfer_reason', + header: 'Catatan', + }, + { + accessorKey: 'transfer_date', + header: 'Tanggal', + cell: (props) => + new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'), + }, + { + accessorFn: (row) => { + const totalCost = row.deliveries?.reduce( + (sum, d) => sum + (d.shipping_cost_total || 0), + 0 + ); + return totalCost?.toLocaleString('id-ID'); + }, + header: 'Biaya Pengiriman', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + return ( -
-
- +
+
+
+
+ +
+ + +
+ +
+ + + + + +
+
+ + + data={isResponseSuccess(movements) ? movements?.data : []} + columns={movementColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(movements) ? movements?.meta?.page : 0} + totalItems={ + isResponseSuccess(movements) ? movements?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(movements) && movements?.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', }} - search={{ - value: tableFilterState.search, - onChange: searchChangeHandler, - placeholder: 'Cari Movement', - }} - /> -
- - - data={isResponseSuccess(movements) ? movements?.data : []} - columns={[ - { - header: '#', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, - }, - { - accessorFn: (row) => row.source_warehouse?.name, - header: 'Gudang Asal', - }, - { - accessorFn: (row) => row.destination_warehouse?.name, - header: 'Gudang Tujuan', - }, - { - accessorKey: 'transfer_reason', - header: 'Catatan', - }, - { - accessorKey: 'transfer_date', - header: 'Tanggal', - cell: (props) => - new Date(props.row.original.transfer_date).toLocaleDateString( - 'id-ID' - ), - }, - { - accessorFn: (row) => { - const totalCost = row.deliveries?.reduce( - (sum, d) => sum + (d.shipping_cost_total || 0), - 0 - ); - return totalCost?.toLocaleString('id-ID'); - }, - header: 'Biaya Pengiriman', - }, - { - header: 'Aksi', - cell: (props) => { - const currentPageSize = - props.table.getPaginationRowModel().rows.length; - const currentPageRows = - props.table.getPaginationRowModel().flatRows; - const currentRowRelativeIndex = - currentPageRows.findIndex((r) => r.id === props.row.id) + 1; - - const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; - - const deleteClickHandler = () => { - setSelectedMovement(props.row.original); - deleteModal.openModal(); - }; - - return ( - <> - {currentPageSize > 2 && ( - - - - )} - - {currentPageSize <= 2 && ( - - - - )} - - ); - }, - }, - ]} - pageSize={tableFilterState.pageSize} - page={isResponseSuccess(movements) ? movements?.meta?.page : 0} - totalItems={ - isResponseSuccess(movements) ? movements?.meta?.total_results : 0 - } - onPageChange={setPage} - isLoading={isLoading} - sorting={sorting} - setSorting={setSorting} - className={{ - containerClassName: cn({ - 'mb-20': - isResponseSuccess(movements) && movements?.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', - }} - /> - - -
+ ); }; diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index ed8fb479..195873b7 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -1,34 +1,82 @@ import * as Yup from 'yup'; import { Movement } from '@/types/api/inventory/movement'; +type MovementFormSchemaType = { + transfer_reason: string; + transfer_date: string; + source_warehouse?: { + value: number; + label: string; + area?: string; + location?: string; + } | null; + source_warehouse_id: number; + destination_warehouse?: { + value: number; + label: string; + area?: string; + location?: string; + } | null; + destination_warehouse_id: number; + products: { + product?: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number | string; + }[]; + deliveries: { + delivery_cost?: number | string; + delivery_cost_per_item?: number | string; + document?: File | string | null; + document_path?: string | null; + driver_name: string; + vehicle_plate: string; + supplier?: { + value: number; + label: string; + } | null; + supplier_id: number; + products: { + product?: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number | string; + }[]; + }[]; +}; + export type ProductSchema = { - product: { + product?: { value: number; label: string; } | null; product_id: number; - product_qty: number; + product_qty: number | string; }; export type DeliverySchema = { - delivery_cost?: number | undefined; - delivery_cost_per_item?: number | undefined; + delivery_cost?: number | string; + delivery_cost_per_item?: number | string; document?: File | string | null; document_path?: string | null; driver_name: string; vehicle_plate: string; - supplier: { + supplier?: { value: number; label: string; } | null; supplier_id: number; products: { - product: { + product?: { value: number; label: string; } | null; product_id: number; - product_qty: number; + product_qty: number | string; }[]; }; @@ -102,7 +150,7 @@ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ .required('Produk wajib diisi!'), }); -export const MovementFormSchema = Yup.object({ +export const MovementFormSchema: Yup.ObjectSchema = Yup.object({ transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), source_warehouse: Yup.object({ @@ -133,8 +181,6 @@ export const MovementFormSchema = Yup.object({ .required('Pengiriman wajib diisi!'), }); -export const UpdateMovementFormSchema = MovementFormSchema; - export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 883572e0..28148706 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -8,26 +8,27 @@ import { Icon } from '@iconify/react'; 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 { FormHeader } from '@/components/helper/form/FormHeader'; -import { FormActions } from '@/components/helper/form/FormActions'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; import { CreateMovementPayload, Movement, } from '@/types/api/inventory/movement'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { useRouter } from 'next/navigation'; import { MovementFormSchema, MovementFormValues, - UpdateMovementFormSchema, getMovementFormInitialValues, ProductSchema, DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; -import { useMovementFormHandlers } from './useMovementFormHandlers'; import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; +import { MovementApi } from '@/services/api/inventory'; import FileInput from '@/components/input/FileInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import Badge from '@/components/Badge'; @@ -38,24 +39,38 @@ interface MovementFormProps { } const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + const router = useRouter(); + // ===== STATE MANAGEMENT ===== - const [, setMovementFormErrorMessage] = useState(''); + const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); const [ productWarehouseSelectInputValue, setProductWarehouseSelectInputValue, ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); - const [warehouseSelectInputValue, setWarehouseSelectInputValue] = - useState(''); - const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); // ===== FORM HANDLERS ===== - const { - movementFormErrorMessage, - createMovementHandler, - updateMovementHandler, - } = useMovementFormHandlers(initialValues?.id); + const createMovementHandler = useCallback( + async (payload: CreateMovementPayload, documents: File[] = []) => { + const formData = new FormData(); + formData.append('data', JSON.stringify(payload)); + documents.forEach((file, index) => { + formData.append(`documents[${index}]`, file); + }); + + const res = await MovementApi.create( + formData as unknown as CreateMovementPayload + ); + if (isResponseError(res)) { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/inventory/movement'); + }, + [router] + ); // ===== INTERFACES ===== interface WarehouseOptionType extends OptionType { @@ -77,18 +92,25 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ProductWarehouseApi.getAllFetcher ); + // ===== USE SELECT HOOKS ===== + const { + inputValue: warehouseSelectInputValue, + setInputValue: setWarehouseSelectInputValue, + isLoadingOptions: isLoadingWarehouses, + } = useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); + + const { + setInputValue: setSupplierSelectInputValue, + options: supplierOptions, + isLoadingOptions: isLoadingSuppliers, + } = useSelect(SupplierApi.basePath, 'id', 'name', 'search'); + const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; - const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( + const { data: warehouses } = useSWR( warehousesUrl, WarehouseApi.getAllFetcher ); - const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`; - const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( - suppliersUrl, - SupplierApi.getAllFetcher - ); - // ===== DATA PROCESSING ===== const warehouseStockMap = useMemo(() => { if (!isResponseSuccess(allProductWarehouses)) return new Map(); @@ -114,8 +136,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return stockMap; }, [allProductWarehouses]); - const warehouseOptions = isResponseSuccess(warehouses) - ? warehouses?.data.map((w) => { + const warehouseOptions = useMemo(() => { + if (!isResponseSuccess(warehouses)) return []; + + return ( + warehouses?.data.map((w) => { warehouseStockMap.get(w.id); return { value: w.id, @@ -126,12 +151,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ? w.location?.name : undefined, }; - }) - : []; - - const supplierOptions = isResponseSuccess(suppliers) - ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) - : []; + }) || [] + ); + }, [warehouses, warehouseStockMap]); // ===== FORM INITIALIZATION ===== const formikInitialValues = useMemo( @@ -141,8 +163,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const formik = useFormik({ initialValues: formikInitialValues, - validationSchema: - type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, + validationSchema: MovementFormSchema, validateOnChange: true, validateOnBlur: true, validateOnMount: false, @@ -150,7 +171,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { onSubmit: async (values) => { setMovementFormErrorMessage(''); const documents: File[] = []; - const deliveriesPayload = values.deliveries.map((d, idx) => { + const deliveriesPayload = values.deliveries.map((d) => { let documentIndex = 0; if (d.document && d.document instanceof File) { @@ -159,8 +180,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } return { - delivery_cost: d.delivery_cost ?? 0, - delivery_cost_per_item: d.delivery_cost_per_item ?? 0, + delivery_cost: parseInt((d.delivery_cost || '').toString()) || 0, + delivery_cost_per_item: + parseInt((d.delivery_cost_per_item || '').toString()) || 0, document_index: documentIndex, document_path: d.document_path, driver_name: d.driver_name, @@ -168,7 +190,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { supplier_id: d.supplier_id, products: d.products.map((p) => ({ product_id: p.product_id, - product_qty: p.product_qty, + product_qty: parseInt(p.product_qty.toString()) || 0, })), }; }); @@ -180,7 +202,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { destination_warehouse_id: values.destination_warehouse_id, products: values.products.map((p) => ({ product_id: p.product_id, - product_qty: p.product_qty, + product_qty: parseInt(p.product_qty.toString()) || 0, })), deliveries: deliveriesPayload, }; @@ -189,13 +211,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { case 'add': await createMovementHandler(payload, documents); break; - case 'edit': - await updateMovementHandler( - initialValues?.id as number, - payload, - documents - ); - break; } }, }); @@ -297,7 +312,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { product: null, product_id: 0, - product_qty: 0, + product_qty: '', }, ]; formik.setFieldValue('products', newProducts); @@ -332,8 +347,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formik.setFieldValue('deliveries', [ ...(formik.values.deliveries || []), { - delivery_cost: undefined, - delivery_cost_per_item: undefined, + delivery_cost: '', + delivery_cost_per_item: '', document: null, driver_name: '', vehicle_plate: '', @@ -343,7 +358,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { product: null, product_id: 0, - product_qty: 0, + product_qty: '', }, ], }, @@ -385,7 +400,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const delivery = formik.values.deliveries?.[idx]; if (delivery) { const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), 0 ); if (productQty > 0 && value > 0) { @@ -409,7 +424,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const delivery = formik.values.deliveries?.[idx]; if (delivery) { const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), 0 ); if (productQty > 0 && value > 0) { @@ -683,36 +698,38 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { useEffect(() => { formik.values.deliveries?.forEach((delivery, idx) => { const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), 0 ); - if ( - delivery.delivery_cost && - delivery.delivery_cost > 0 && - productQty > 0 - ) { - const perItem = delivery.delivery_cost / productQty; - if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { + const deliveryCost = + parseInt((delivery.delivery_cost || '').toString()) || 0; + const deliveryCostPerItem = + parseInt((delivery.delivery_cost_per_item || '').toString()) || 0; + + if (deliveryCost > 0 && productQty > 0) { + const perItem = deliveryCost / productQty; + if (Math.abs(deliveryCostPerItem - perItem) > 0.01) { formik.setFieldValue( `deliveries.${idx}.delivery_cost_per_item`, perItem ); } - } else if ( - delivery.delivery_cost_per_item && - delivery.delivery_cost_per_item > 0 && - productQty > 0 - ) { - const totalCost = delivery.delivery_cost_per_item * productQty; - if (Math.abs((delivery.delivery_cost || 0) - totalCost) > 0.01) { + } else if (deliveryCostPerItem > 0 && productQty > 0) { + const totalCost = deliveryCostPerItem * productQty; + if (Math.abs(deliveryCost - totalCost) > 0.01) { formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); } } }); }, [ formik.values.deliveries - ?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0)) + ?.map((d) => + d.products.reduce( + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), + 0 + ) + ) .join(','), ]); @@ -730,11 +747,21 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return ( <>
- +
+ +

+ {type === 'add' && 'Tambah Movement'} + {type === 'edit' && 'Edit Movement'} + {type === 'detail' && 'Detail Movement'} +

+
{ required label='Alasan Transfer' name='transfer_reason' + placeholder='Masukkan alasan transfer...' value={formik.values.transfer_reason} onChange={formik.handleChange} onBlur={formik.handleBlur} @@ -785,6 +813,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { formik.setFieldTouched('source_warehouse', true); @@ -852,6 +881,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { formik.setFieldTouched('destination_warehouse', true); @@ -1038,8 +1068,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } placeholder={ !formik.values.source_warehouse_id - ? 'Pilih gudang asal terlebih dahulu' - : 'Pilih produk' + ? 'Pilih gudang asal terlebih dahulu...' + : 'Pilih produk...' } isClearable {...isRepeaterInputError( @@ -1057,6 +1087,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { { formik.setFieldTouched( @@ -1317,6 +1349,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { { formik.setFieldTouched( @@ -1386,6 +1420,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { { { {
{/* Action buttons */} - - type={type} - formik={formik} - disableSubmit={hasInvalidQty || hasExceededStock} - /> +
+ {type !== 'detail' && ( +
+ + + +
+ )} +
{movementFormErrorMessage && (
diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts deleted file mode 100644 index 0ad31e38..00000000 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { toast } from 'react-hot-toast'; -import { useModal } from '@/components/Modal'; -import { MovementApi } from '@/services/api/inventory'; -import { - CreateMovementPayload, - UpdateMovementPayload, -} from '@/types/api/inventory/movement'; -import { isResponseError } from '@/lib/api-helper'; - -export const useMovementFormHandlers = (initialValuesId?: number) => { - const router = useRouter(); - const deleteModal = useModal(); - const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const createMovementHandler = useCallback( - async (payload: CreateMovementPayload, documents: File[] = []) => { - const formData = new FormData(); - formData.append('data', JSON.stringify(payload)); - documents.forEach((file, index) => { - formData.append(`documents[${index}]`, file); - }); - - const res = await MovementApi.create( - formData as unknown as CreateMovementPayload - ); - if (isResponseError(res)) { - setMovementFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.push('/inventory/movement'); - }, - [router] - ); - - const updateMovementHandler = useCallback( - async ( - movementId: number, - payload: UpdateMovementPayload, - documents: File[] = [] - ) => { - let finalPayload: UpdateMovementPayload | FormData; - - if (documents.length > 0) { - const formData = new FormData(); - formData.append('data', JSON.stringify(payload)); - documents.forEach((file, index) => { - formData.append(`documents[${index}]`, file); - }); - - finalPayload = formData as unknown as UpdateMovementPayload; - } else { - finalPayload = payload; - } - - const res = await MovementApi.update(movementId, finalPayload); - if (res?.status === 'error') { - setMovementFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.refresh(); - router.push('/inventory/movement'); - }, - [router] - ); - - const deleteMovementClickHandler = useCallback(() => { - deleteModal.openModal(); - }, [deleteModal]); - - const confirmationModalDeleteClickHandler = useCallback(async () => { - if (!initialValuesId) return; - - setIsDeleteLoading(true); - await MovementApi.delete(initialValuesId); - deleteModal.closeModal(); - toast.success('Successfully delete Movement!'); - setIsDeleteLoading(false); - router.push('/inventory/movement'); - }, [deleteModal, initialValuesId, router]); - - return { - deleteModal, - movementFormErrorMessage, - isDeleteLoading, - createMovementHandler, - updateMovementHandler, - deleteMovementClickHandler, - confirmationModalDeleteClickHandler, - }; -}; diff --git a/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts index d28f7d7b..d97e755a 100644 --- a/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts +++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts @@ -1,14 +1,18 @@ import * as Yup from 'yup'; -export const ProductCategoryFormSchema = Yup.object({ - code: Yup.string() - .required('Kode wajib diisi!') - .max(3, 'Kode kategori produk melebihi 3 karakter!'), - name: Yup.string().required('Nama wajib diisi!'), -}); +type ProductCategoryFormSchemaType = { + code: string; + name: string; +}; + +export const ProductCategoryFormSchema: Yup.ObjectSchema = + Yup.object({ + code: Yup.string() + .required('Kode wajib diisi!') + .max(3, 'Kode kategori produk melebihi 3 karakter!'), + name: Yup.string().required('Nama wajib diisi!'), + }); export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema; -export type ProductCategoryFormValues = Yup.InferType< - typeof ProductCategoryFormSchema ->; +export type ProductCategoryFormValues = Yup.InferType; \ No newline at end of file diff --git a/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx b/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx index a11a9992..7331cbb5 100644 --- a/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx +++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx @@ -71,12 +71,13 @@ const ProductCategoryForm = ({ [router] ); - const formikInitialValues = useMemo(() => { - return { + const formikInitialValues = useMemo( + () => ({ code: initialValues?.code ?? '', name: initialValues?.name ?? '', - }; - }, [initialValues]); + }), + [initialValues] + ); const formik = useFormik({ initialValues: formikInitialValues, @@ -118,7 +119,7 @@ const ProductCategoryForm = ({ await ProductCategoryApi.delete(initialValues?.id as number); deleteModal.closeModal(); - toast.success('Successfully delete Product Category!'); + toast.success('Berhasil menghapus data Kategori Produk!'); setIsDeleteLoading(false); router.push('/master-data/product-category'); }; @@ -129,7 +130,7 @@ const ProductCategoryForm = ({ return ( <> -
+
@@ -157,7 +158,7 @@ const ProductCategoryForm = ({ required label='Kode' name='code' - placeholder='Masukkan kode kategori produk' + placeholder='Masukkan kode...' value={formik.values.code} onChange={formik.handleChange} onBlur={formik.handleBlur} @@ -169,7 +170,7 @@ const ProductCategoryForm = ({ required label='Nama' name='name' - placeholder='Masukkan nama kategori produk' + placeholder='Masukkan nama...' value={formik.values.name} onChange={formik.handleChange} onBlur={formik.handleBlur} @@ -256,7 +257,7 @@ const ProductCategoryForm = ({ = + Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), + brand: Yup.string().required('Merek wajib diisi!'), + sku: Yup.string().required('SKU wajib diisi!'), + + uom: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable().required('Satuan wajib diisi!'), + + uom_id: Yup.number() + .required('Satuan wajib diisi!') + .typeError('Satuan wajib diisi!'), + + product_category: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable().required('Kategori produk wajib diisi!'), + + product_category_id: Yup.number() + .required('Kategori produk wajib diisi!') + .typeError('Kategori produk wajib diisi!'), + + product_price: Yup.number() + .required('Harga produk wajib diisi!') + .typeError('Harga produk wajib diisi!') + .min(0, 'Harga produk tidak boleh kurang dari 0!'), + + selling_price: Yup.number() + .required('Harga jual wajib diisi!') + .typeError('Harga jual wajib diisi!') + .min(0, 'Harga jual tidak boleh kurang dari 0!'), + + tax: Yup.number() + .required('Pajak wajib diisi!') + .typeError('Pajak wajib diisi!') + .min(0, 'Pajak tidak boleh kurang dari 0!') + .max(100, 'Pajak tidak boleh lebih dari 100%!'), + + expiry_period: Yup.number() + .required('Periode kadaluarsa wajib diisi!') + .typeError('Periode kadaluarsa wajib diisi!') + .min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'), + + supplier_ids: Yup.array() + .of(Yup.number().required().typeError('Supplier tidak valid!')) + .min(1, 'Minimal harus ada 1 supplier!') + .required('Supplier wajib diisi!'), + + flags: Yup.array() + .of(Yup.string().required()) + .min(1, 'Minimal harus ada 1 flag!') + .required('Flag wajib diisi!'), + }); export const UpdateProductFormSchema = ProductFormSchema; diff --git a/src/components/pages/master-data/product/form/ProductForm.tsx b/src/components/pages/master-data/product/form/ProductForm.tsx index d08ffe27..20527398 100644 --- a/src/components/pages/master-data/product/form/ProductForm.tsx +++ b/src/components/pages/master-data/product/form/ProductForm.tsx @@ -9,7 +9,11 @@ import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; -import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import NumberInput from '@/components/input/NumberInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; @@ -79,20 +83,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { sku: initialValues?.sku ?? '', uom: initialValues?.uom ? { value: initialValues.uom.id, label: initialValues.uom.name } - : null, + : undefined, uom_id: initialValues?.uom?.id ?? 0, product_category: initialValues?.product_category ? { value: initialValues.product_category.id, label: initialValues.product_category.name, } - : null, + : undefined, product_category_id: initialValues?.product_category?.id ?? 0, - product_price: initialValues?.product_price ?? 0, - selling_price: initialValues?.selling_price ?? 0, - tax: initialValues?.tax ?? 0, - expiry_period: initialValues?.expiry_period ?? 0, - supplier: null, // not used for payload, just for UI + product_price: initialValues?.product_price ?? '', + selling_price: initialValues?.selling_price ?? '', + tax: initialValues?.tax ?? '', + expiry_period: initialValues?.expiry_period ?? '', supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [], flags: initialValues?.flags ?? [], }), @@ -111,14 +114,14 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { sku: values.sku, uom_id: values.uom_id, product_category_id: values.product_category_id, - product_price: values.product_price, - selling_price: values.selling_price, - tax: values.tax, - expiry_period: values.expiry_period, - supplier_ids: (values.supplier_ids ?? []).filter( + product_price: parseInt(values.product_price.toString()) || 0, + selling_price: parseInt(values.selling_price.toString()) || 0, + tax: parseInt(values.tax.toString()) || 0, + expiry_period: parseInt(values.expiry_period.toString()) || 0, + supplier_ids: values.supplier_ids.filter( (id): id is number => typeof id === 'number' ), - flags: (values.flags ?? []).filter( + flags: values.flags.filter( (f): f is string => typeof f === 'string' ), }; @@ -136,15 +139,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { const { setValues: formikSetValues } = formik; // UOM - const [uomSelectInputValue, setUomSelectInputValue] = useState(''); - const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`; - const { data: uoms, isLoading: isLoadingUoms } = useSWR( - uomsUrl, - UomApi.getAllFetcher - ); - const uomOptions = isResponseSuccess(uoms) - ? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name })) - : []; + const { + setInputValue: setUomSelectInputValue, + options: uomOptions, + isLoadingOptions: isLoadingUoms, + } = useSelect(UomApi.basePath, 'id', 'name'); const uomChangeHandler = (val: OptionType | OptionType[] | null) => { formik.setFieldTouched('uom', true); formik.setFieldValue('uom', val); @@ -153,15 +152,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { }; // Product Category - const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); - const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`; - const { data: categories, isLoading: isLoadingCategories } = useSWR( - categoriesUrl, - ProductCategoryApi.getAllFetcher - ); - const categoryOptions = isResponseSuccess(categories) - ? categories?.data.map((cat) => ({ value: cat.id, label: cat.name })) - : []; + const { + setInputValue: setCategorySelectInputValue, + options: categoryOptions, + isLoadingOptions: isLoadingCategories, + } = useSelect(ProductCategoryApi.basePath, 'id', 'name'); const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { formik.setFieldTouched('product_category', true); formik.setFieldValue('product_category', val); @@ -169,7 +164,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { formik.setFieldValue('product_category_id', (val as OptionType)?.value); }; - // Supplier (multi select) + // Supplier (multi select) - using SWR to filter by category const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( @@ -209,7 +204,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { return ( <> -
+