From 1ea9ee30695459fb5c0eac38e564235fb1732a1d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 9 Oct 2025 14:30:05 +0700 Subject: [PATCH] feat(FE-62,63,65): implement MovementForm component for managing inventory movements --- src/app/inventory/movement/add/page.tsx | 11 + .../movement/form/MovementForm.schema.ts | 121 +-- .../inventory/movement/form/MovementForm.tsx | 751 ++++++++++++++++++ src/types/api/inventory/movement.d.ts | 86 +- 4 files changed, 866 insertions(+), 103 deletions(-) create mode 100644 src/app/inventory/movement/add/page.tsx create mode 100644 src/components/pages/inventory/movement/form/MovementForm.tsx diff --git a/src/app/inventory/movement/add/page.tsx b/src/app/inventory/movement/add/page.tsx new file mode 100644 index 00000000..f883de95 --- /dev/null +++ b/src/app/inventory/movement/add/page.tsx @@ -0,0 +1,11 @@ +import MovementForm from '@/components/pages/inventory/movement/form/MovementForm'; + +const AddMovement = () => { + return ( +
+ +
+ ); +}; + +export default AddMovement; diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index cdabe355..11c40fe5 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -1,67 +1,68 @@ import * as Yup from 'yup'; export const MovementFormSchema = Yup.object({ - alasan_transfer: Yup.string() - .required('Alasan Transfer wajib diisi!'), - tanggal_transfer: Yup.date() - .required('Tanggal Transfer wajib diisi!') - .typeError('Tanggal Transfer tidak valid!'), - warehouse_asal: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - warehouse_asal_id: Yup.number() - .required('Gudang Asal wajib diisi!'), - warehouse_tujuan: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - warehouse_tujuan_id: Yup.number() - .required('Gudang Tujuan wajib diisi!'), - alasan: Yup.string() - .required('Alasan wajib diisi!'), - product: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - product_id: Yup.array() - .of(Yup.number()).min(1, 'Pilih minimal 1 produk') - .required('Produk wajib diisi!'), - qty_product: Yup.array() - .of(Yup.number().min(1, 'Kuantitas minimal 1')) - .min(1, 'Pilih minimal 1 produk') - .required('Kuantitas wajib diisi!'), - ekspedisi: Yup.array().of( - Yup.object({ - product: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - product_id: Yup.number() - .required('Produk wajib diisi!'), - qty: Yup.number().min(1, 'Kuantitas minimal 1') - .required('Kuantitas wajib diisi!'), - supplier: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - supplier_id: Yup.number() - .required('Supplier wajib diisi!'), - plat_nomor: Yup.string() - .required('Plat Nomor wajib diisi!'), - no_surat_jalan: Yup.string() - .required('No Surat Jalan wajib diisi!'), - dokumen: Yup.mixed() - .required('Dokumen wajib diisi!'), - biaya_ekspedisi: Yup.number() - .min(0, 'Biaya Ekspedisi minimal 0') - .required('Biaya Ekspedisi wajib diisi!'), - nama_sopir: Yup.string() - .required('Nama Sopir wajib diisi!'), - }) - ).min(1, 'Pilih minimal 1 ekspedisi').required('Ekspedisi wajib diisi!'), + alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'), + tanggal_transfer: Yup.string().required('Tanggal transfer wajib diisi!'), + warehouse_asal: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + warehouse_asal_id: Yup.number() + .required('Gudang asal wajib diisi!') + .typeError('Gudang asal wajib diisi!'), + warehouse_tujuan: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + warehouse_tujuan_id: Yup.number() + .required('Gudang tujuan wajib diisi!') + .typeError('Gudang tujuan wajib diisi!'), + product: Yup.array() + .of( + Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number().required('Produk wajib diisi!'), + qty_product: Yup.number() + .required('Qty wajib diisi!') + .min(1, 'Qty minimal 1!') + .typeError('Qty harus berupa angka!'), + }) + ) + .min(1, 'Minimal harus ada 1 produk!'), + ekspedisi: Yup.array() + .of( + Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number().required('Produk wajib diisi!'), + qty: Yup.number() + .required('Qty wajib diisi!') + .min(1, 'Qty minimal 1!') + .typeError('Qty harus berupa angka!'), + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + supplier_id: Yup.number().required('Supplier wajib diisi!'), + plat_nomor: Yup.string().required('Plat nomor wajib diisi!'), + no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'), + dokumen: Yup.mixed().required('Dokumen wajib diisi!'), + biaya_ekspedisi: Yup.number() + .required('Biaya ekspedisi wajib diisi!') + .min(0, 'Biaya minimal 0!') + .typeError('Biaya harus berupa angka!'), + nama_sopir: Yup.string().required('Nama sopir wajib diisi!'), + }) + ) + .optional() + .default([]), }); export const UpdateMovementFormSchema = MovementFormSchema; -export type MovementFormValues = Yup.InferType; \ No newline at end of file +export type MovementFormValues = Yup.InferType; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx new file mode 100644 index 00000000..0105768d --- /dev/null +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -0,0 +1,751 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { FieldArray, FormikProvider, useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; +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 { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + MovementFormSchema, + MovementFormValues, + UpdateMovementFormSchema, +} from '@/components/pages/inventory/movement/form/MovementForm.schema'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { + Movement, + CreateMovementPayload, + UpdateMovementPayload, +} from '@/types/api/inventory/movement'; +import { + ProductApi, + WarehouseApi, + SupplierApi, +} from '@/services/api/master-data'; +import { MovementApi } from '@/services/api/inventory'; +import { cn } from '@/lib/helper'; + +interface MovementFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Movement; +} + +const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createMovementHandler = useCallback( + async (payload: CreateMovementPayload) => { + const res = await MovementApi.create(payload); + 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) => { + const res = await MovementApi.update(movementId, payload); + if (res?.status === 'error') { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/inventory/movement'); + }, + [router] + ); + + const formikInitialValues = useMemo( + () => ({ + alasan_transfer: initialValues?.alasan_transfer ?? '', + tanggal_transfer: initialValues?.tanggal_transfer ?? '', + warehouse_asal: initialValues?.warehouse_asal + ? { + value: initialValues.warehouse_asal.id, + label: initialValues.warehouse_asal.name, + } + : null, + warehouse_asal_id: initialValues?.warehouse_asal?.id ?? 0, + warehouse_tujuan: initialValues?.warehouse_tujuan + ? { + value: initialValues.warehouse_tujuan.id, + label: initialValues.warehouse_tujuan.name, + } + : null, + warehouse_tujuan_id: initialValues?.warehouse_tujuan?.id ?? 0, + product: + initialValues?.product?.map((p) => ({ + product: { value: p.product.id, label: p.product.name }, + product_id: p.product.id, + qty_product: p.qty_product, + })) ?? [], + ekspedisi: + initialValues?.ekspedisi?.map((e) => ({ + product: { value: e.product_id, label: '' }, // Need to fetch product details + product_id: e.product_id, + qty: e.qty, + supplier: { value: e.supplier.id, label: e.supplier.name }, + supplier_id: e.supplier.id, + plat_nomor: e.plat_nomor, + no_surat_jalan: e.no_surat_jalan, + dokumen: e.dokumen, + biaya_ekspedisi: e.biaya_ekspedisi, + nama_sopir: e.nama_sopir, + })) ?? [], + }), + [initialValues] + ); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, + onSubmit: async (values) => { + setMovementFormErrorMessage(''); + const payload: CreateMovementPayload = { + alasan_transfer: values.alasan_transfer, + tanggal_transfer: values.tanggal_transfer, + warehouse_asal_id: values.warehouse_asal_id, + warehouse_tujuan_id: values.warehouse_tujuan_id, + product: (values.product ?? []).map((p) => ({ + product_id: p.product_id, + qty_product: p.qty_product, + })), + ekspedisi: (values.ekspedisi ?? []).map((e) => ({ + product_id: e.product_id, + qty: e.qty, + supplier_id: e.supplier_id, + plat_nomor: e.plat_nomor, + no_surat_jalan: e.no_surat_jalan, + dokumen: + e.dokumen instanceof File ? e.dokumen : (e.dokumen as string), + biaya_ekspedisi: e.biaya_ekspedisi, + nama_sopir: e.nama_sopir, + })), + }; + + switch (type) { + case 'add': + await createMovementHandler(payload); + break; + case 'edit': + await updateMovementHandler(initialValues?.id as number, payload); + break; + } + }, + }); + + // Warehouse selection + const [warehouseSelectInputValue, setWarehouseSelectInputValue] = + useState(''); + const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; + const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( + warehousesUrl, + WarehouseApi.getAllFetcher + ); + const warehouseOptions = isResponseSuccess(warehouses) + ? warehouses?.data.map((w) => ({ value: w.id, label: w.name })) + : []; + + // Product selection + const [productSelectInputValue, setProductSelectInputValue] = useState(''); + const productsUrl = `${ProductApi.basePath}?${new URLSearchParams({ search: productSelectInputValue }).toString()}`; + const { data: products, isLoading: isLoadingProducts } = useSWR( + productsUrl, + ProductApi.getAllFetcher + ); + const productOptions = isResponseSuccess(products) + ? products?.data.map((p) => ({ value: p.id, label: p.name })) + : []; + + // Supplier selection + const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); + const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`; + const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( + suppliersUrl, + SupplierApi.getAllFetcher + ); + const supplierOptions = isResponseSuccess(suppliers) + ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) + : []; + + const deleteMovementClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + await MovementApi.delete(initialValues?.id as number); + deleteModal.closeModal(); + toast.success('Successfully delete Movement!'); + setIsDeleteLoading(false); + router.push('/inventory/movement'); + }; + + const { setValues: formikSetValues } = formik; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ +

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

+
+ + +
+ {/* Top card - Movement details */} +
+
+
+ + +
+
+
+ + {/* Warehouse cards */} +
+
+
+ { + formik.setFieldValue('warehouse_asal', val); + formik.setFieldValue( + 'warehouse_asal_id', + (val as OptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.warehouse_asal_id && + Boolean(formik.errors.warehouse_asal_id) + } + errorMessage={formik.errors.warehouse_asal_id as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+ +
+
+ { + formik.setFieldValue('warehouse_tujuan', val); + formik.setFieldValue( + 'warehouse_tujuan_id', + (val as OptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.warehouse_tujuan_id && + Boolean(formik.errors.warehouse_tujuan_id) + } + errorMessage={formik.errors.warehouse_tujuan_id as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+
+ + {/* Products table */} +
+
+

Produk

+ + {({ push, remove }) => ( + <> + {typeof formik.errors.product === 'string' && ( +
+ {formik.errors.product} +
+ )} + + + + + + + + + + {formik.values.product?.map((_, index) => ( + + + + + + )) ?? []} + +
ProdukQtyAksi
+ { + formik.setFieldValue( + `product.${index}.product`, + val + ); + formik.setFieldValue( + `product.${index}.product_id`, + (val as OptionType)?.value + ); + }} + options={productOptions} + onInputChange={setProductSelectInputValue} + isLoading={isLoadingProducts} + isDisabled={type === 'detail'} + isClearable + /> + + + formik.setFieldValue( + `product.${index}.qty_product`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + {type !== 'detail' && ( + + )} +
+ {type !== 'detail' && ( + + )} + + )} +
+
+
+ + {/* Ekspedisi table */} +
+
+

Ekspedisi

+ + {({ push, remove }) => ( + <> + {typeof formik.errors.ekspedisi === 'string' && ( +
+ {formik.errors.ekspedisi} +
+ )} + + + + + + + + + + + + + + + + {formik.values.ekspedisi?.map((ekspedisi, index) => ( + + + + + + + + + + + + )) ?? []} + +
ProdukQtySupplierPlat NomorNo Surat JalanDokumenBiaya EkspedisiNama SopirAksi
+ { + formik.setFieldValue( + `ekspedisi.${index}.product`, + val + ); + formik.setFieldValue( + `ekspedisi.${index}.product_id`, + (val as OptionType)?.value + ); + }} + options={productOptions} + onInputChange={setProductSelectInputValue} + isLoading={isLoadingProducts} + isDisabled={type === 'detail'} + isClearable + /> + + + formik.setFieldValue( + `ekspedisi.${index}.qty`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + { + formik.setFieldValue( + `ekspedisi.${index}.supplier`, + val + ); + formik.setFieldValue( + `ekspedisi.${index}.supplier_id`, + (val as OptionType)?.value + ); + }} + options={supplierOptions} + onInputChange={setSupplierSelectInputValue} + isLoading={isLoadingSuppliers} + isDisabled={type === 'detail'} + isClearable + /> + + + formik.setFieldValue( + `ekspedisi.${index}.plat_nomor`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + + formik.setFieldValue( + `ekspedisi.${index}.no_surat_jalan`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + { + const file = e.target.files?.[0]; + if (file) { + formik.setFieldValue( + `ekspedisi.${index}.dokumen`, + file + ); + } + }} + readOnly={type === 'detail'} + /> + + + formik.setFieldValue( + `ekspedisi.${index}.biaya_ekspedisi`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + + formik.setFieldValue( + `ekspedisi.${index}.nama_sopir`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + {type !== 'detail' && ( + + )} +
+ {type !== 'detail' && ( + + )} + + )} +
+
+
+ + {/* Action buttons */} +
+ {type !== 'add' && ( +
+ + {type !== 'edit' && ( + + )} +
+ )} + {type !== 'detail' && ( +
+ + +
+ )} +
+ + {movementFormErrorMessage && ( +
+ + {movementFormErrorMessage} +
+ )} +
+
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default MovementForm; diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index fe2996ba..0d58b8e1 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -1,51 +1,51 @@ -import {BaseMetadata} from '@/types/api/api-general'; -import {Product} from "@/types/api/master-data/product"; -import {Supplier} from "@/types/api/master-data/supplier"; -import {Warehouse} from "@/types/api/master-data/warehouse"; +import { BaseMetadata } from '@/types/api/api-general'; +import { Product } from '@/types/api/master-data/product'; +import { Supplier } from '@/types/api/master-data/supplier'; +import { Warehouse } from '@/types/api/master-data/warehouse'; export type BaseMovement = { - id: number; - alasan_transfer: string; - tanggal_transfer: string; - warehouse_asal: Warehouse; - warehouse_tujuan: Warehouse; - product: Array<{ - product: Product; - qty_product: number; - }>; - ekspedisi: Array<{ - product_id: number; - qty: number; - supplier: Supplier; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string; - biaya_ekspedisi: number; - nama_sopir: string; - }>; - name: string; + id: number; + alasan_transfer: string; + tanggal_transfer: string; + warehouse_asal: Warehouse; + warehouse_tujuan: Warehouse; + product: { + product: Product; + qty_product: number; + }[]; + ekspedisi: { + product_id: number; + qty: number; + supplier: Supplier; + plat_nomor: string; + no_surat_jalan: string; + dokumen: string; + biaya_ekspedisi: number; + nama_sopir: string; + }[]; }; export type Movement = BaseMetadata & BaseMovement; export type CreateMovementPayload = { - alasan: string; - warehouse_asal_id: number; - warehouse_tujuan_id: number; - product: Array<{ - product_id: number; - qty_product: number; - }>; - ekspedisi: Array<{ - product_id: number; - qty: number; - supplier_id: number; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string; - biaya_ekspedisi: number; - nama_sopir: string; - }>; -} + alasan_transfer: string; + tanggal_transfer: string; + warehouse_asal_id: number; + warehouse_tujuan_id: number; + product: { + product_id: number; + qty_product: number; + }[]; + ekspedisi: { + product_id: number; + qty: number; + supplier_id: number; + plat_nomor: string; + no_surat_jalan: string; + dokumen: string | File; + biaya_ekspedisi: number; + nama_sopir: string; + }[]; +}; -export type UpdateMovementPayload = CreateMovementPayload; \ No newline at end of file +export type UpdateMovementPayload = CreateMovementPayload;