From 56a9fc2349f8aa20ba56aa6c56e3ef433a516e3b Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 10:51:06 +0700 Subject: [PATCH 01/16] refactor(FE-62,65): simplify error handling in MovementForm by consolidating error checks --- .../inventory/movement/form/MovementForm.tsx | 90 ++++++++----------- 1 file changed, 37 insertions(+), 53 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index e5c90f4a..5d69f03e 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -38,24 +38,6 @@ interface MovementFormProps { initialValues?: Movement; } -function getEkspedisiFieldError( - formik: FormikProps, - idx: number, - field: keyof EkspedisiSchema -) { - const errorObj = formik.errors.ekspedisi?.[idx]; - const touched = formik.touched.ekspedisi?.[idx]?.[field]; - const isError = - touched && - typeof errorObj === 'object' && - !!(errorObj as Record)?.[field]; - const errorMessage = - typeof errorObj === 'object' - ? (errorObj as Record)?.[field] - : undefined; - return { isError, errorMessage }; -} - const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [, setMovementFormErrorMessage] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); @@ -212,16 +194,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { !formik.touched[arrayName] || !Array.isArray(formik.touched[arrayName]) ) { - return false; + return { + isError: false, + errorMessage: undefined, + }; } const touchedField = formik.touched[arrayName]?.[idx]?.[column as string]; const errorField = formik.errors[arrayName]?.[idx] as Record< string, - unknown + string >; - return touchedField && Boolean(errorField?.[column as string]); + return { + isError: touchedField && Boolean(errorField?.[column as string]), + errorMessage: touchedField ? errorField?.[column as string] : undefined, + }; }; interface WarehouseOptionType extends OptionType { @@ -571,11 +559,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isLoading={isLoadingProducts} isDisabled={type === 'detail'} isClearable - isError={isRepeaterInputError( - 'product', - 'product', - idx - )} + {...isRepeaterInputError('product', 'product', idx)} /> @@ -586,7 +570,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={product.qty_product ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( + {...isRepeaterInputError( 'product', 'qty_product', idx @@ -734,13 +718,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formik.setFieldValue(`ekspedisi.${idx}.qty`, ''); }} options={getFilteredProductOptions()} - isDisabled={type === 'detail'} - isClearable - isError={isRepeaterInputError( + {...isRepeaterInputError( 'ekspedisi', 'product', idx )} + isDisabled={type === 'detail'} + isClearable /> @@ -751,7 +735,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.qty ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError(formik, idx, 'qty')} + {...isRepeaterInputError('ekspedisi', 'qty', idx)} readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -777,7 +761,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isLoading={isLoadingSuppliers} isDisabled={type === 'detail'} isClearable - isError={isRepeaterInputError( + {...isRepeaterInputError( 'ekspedisi', 'supplier', idx @@ -791,10 +775,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.plat_nomor ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'plat_nomor' + {...isRepeaterInputError( + 'ekspedisi', + 'plat_nomor', + idx )} readOnly={type === 'detail'} className={{ @@ -809,10 +793,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.no_surat_jalan ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'no_surat_jalan' + {...isRepeaterInputError( + 'ekspedisi', + 'no_surat_jalan', + idx )} readOnly={type === 'detail'} className={{ @@ -848,7 +832,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); } }} - isError={isRepeaterInputError( + {...isRepeaterInputError( 'ekspedisi', 'dokumen', idx @@ -867,10 +851,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.biaya_ekspedisi ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'biaya_ekspedisi' + {...isRepeaterInputError( + 'ekspedisi', + 'biaya_ekspedisi', + idx )} readOnly={type === 'detail'} className={{ @@ -883,10 +867,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { disabled onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'biaya_ekspedisi_per_item' + {...isRepeaterInputError( + 'ekspedisi', + 'biaya_ekspedisi_per_item', + idx )} name={`ekspedisi.${idx}.biaya_ekspedisi_per_item`} value={ @@ -910,10 +894,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.nama_sopir ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'nama_sopir' + {...isRepeaterInputError( + 'ekspedisi', + 'nama_sopir', + idx )} readOnly={type === 'detail'} className={{ From 3c4333021fb88ffb04130e4742ef272e71a84ee8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 10:54:38 +0700 Subject: [PATCH 02/16] feat(FE-62,65): enhance MovementForm and FormActions to improve form validation and reset behavior --- src/components/helper/form/FormActions.tsx | 12 ++++++++++-- .../pages/inventory/movement/form/MovementForm.tsx | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 7ced46cd..54600d00 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -64,7 +64,10 @@ export const FormActions = ({ type='reset' color='warning' className='px-4' - onClick={formik.handleReset} + onClick={() => { + formik.handleReset(); + formik.validateForm(); + }} > Reset @@ -73,7 +76,12 @@ export const FormActions = ({ color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={disableSubmit || !formik.isValid || formik.isSubmitting} + disabled={ + disableSubmit || + !formik.isValid || + !formik.dirty || + formik.isSubmitting + } > Submit diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 5d69f03e..f196b242 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -64,6 +64,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, validateOnChange: true, validateOnBlur: true, + validateOnMount: true, onSubmit: async (values) => { setMovementFormErrorMessage(''); const payload: CreateMovementPayload = { From dcd5d2692fdb12baa9eb9a43a53f788ce70ec951 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 10:54:38 +0700 Subject: [PATCH 03/16] feat(FE-62,65): enhance MovementForm and FormActions to improve form validation and reset behavior --- src/components/helper/form/FormActions.tsx | 12 ++++++++++-- .../pages/inventory/movement/form/MovementForm.tsx | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 7ced46cd..54600d00 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -64,7 +64,10 @@ export const FormActions = ({ type='reset' color='warning' className='px-4' - onClick={formik.handleReset} + onClick={() => { + formik.handleReset(); + formik.validateForm(); + }} > Reset @@ -73,7 +76,12 @@ export const FormActions = ({ color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={disableSubmit || !formik.isValid || formik.isSubmitting} + disabled={ + disableSubmit || + !formik.isValid || + !formik.dirty || + formik.isSubmitting + } > Submit diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 5d69f03e..dd927665 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { FormikProps, useFormik } from 'formik'; +import { useFormik } from 'formik'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; @@ -64,6 +64,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, validateOnChange: true, validateOnBlur: true, + validateOnMount: true, onSubmit: async (values) => { setMovementFormErrorMessage(''); const payload: CreateMovementPayload = { From df73ee1fdfc19bdcf935445b5f6d799c108de191 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 12:00:17 +0700 Subject: [PATCH 04/16] feat(FE-62,63,65): refactor MovementForm and related types for improved clarity and consistency --- src/components/helper/form/FormActions.tsx | 7 +- .../movement/form/MovementForm.schema.ts | 164 +++--- .../inventory/movement/form/MovementForm.tsx | 476 ++++++++---------- src/types/api/inventory/movement.d.ts | 61 +-- 4 files changed, 335 insertions(+), 373 deletions(-) diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 54600d00..92c2a92c 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -76,12 +76,7 @@ export const FormActions = ({ color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={ - disableSubmit || - !formik.isValid || - !formik.dirty || - formik.isSubmitting - } + disabled={disableSubmit || !formik.isValid || formik.isSubmitting} > Submit diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 453ca40b..cb0d228d 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -7,27 +7,28 @@ export type ProductSchema = { label: string; } | null; product_id: number; - qty_product: number; + product_qty: number; }; -export type EkspedisiSchema = { - product: { - value: number; - label: string; - } | null; - product_id: number; - qty: number; +export type DeliverySchema = { + delivery_cost: number; + delivery_cost_per_item?: number | undefined; + document: string | File; + driver_name: string; + vehicle_plate: string; supplier: { value: number; label: string; } | null; supplier_id: number; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string | File; - biaya_ekspedisi: number; - biaya_ekspedisi_per_item?: number | undefined; - nama_sopir: string; + products: { + product: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number; + }[]; }; const ProductObjectSchema: Yup.ObjectSchema = Yup.object({ @@ -36,40 +37,34 @@ const ProductObjectSchema: Yup.ObjectSchema = Yup.object({ label: Yup.string().required(), }).nullable(), product_id: Yup.number().required('Produk wajib diisi!'), - qty_product: Yup.number() + product_qty: Yup.number() .required('Qty wajib diisi!') .min(1, 'Qty minimal 1!') .typeError('Qty harus berupa angka!'), }); -const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ +const DeliveryProductObjectSchema = 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() + product_qty: Yup.number() .required('Qty wajib diisi!') .min(1, 'Qty minimal 1!') - .typeError('Qty harus berupa angka!') - .test('max-product-qty', 'Qty melebihi stok produk!', function (value) { - const { product_id } = this.parent; - const products = (this.options.context?.product ?? []) as { - product_id: number; - qty_product: number; - }[]; - const product = products.find((p) => p.product_id === product_id); - if (!product) return true; - return (value ?? 0) <= Number(product.qty_product); - }), - 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() + .typeError('Qty harus berupa angka!'), +}); + +const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ + delivery_cost: Yup.number() + .required('Biaya pengiriman wajib diisi!') + .min(0, 'Biaya minimal 0!') + .typeError('Biaya harus berupa angka!'), + delivery_cost_per_item: Yup.number() + .transform((value) => (isNaN(value) ? undefined : value)) + .min(0, 'Biaya per item minimal 0!') + .typeError('Biaya per item harus berupa angka!'), + document: Yup.mixed() .required('Dokumen wajib diisi!') .test( 'fileType', @@ -86,44 +81,44 @@ const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ typeof value === 'string' || (value instanceof File && value.size <= 2 * 1024 * 1024) ), - biaya_ekspedisi: Yup.number() - .required('Biaya ekspedisi wajib diisi!') - .min(0, 'Biaya minimal 0!') - .typeError('Biaya harus berupa angka!'), - biaya_ekspedisi_per_item: Yup.number() - .transform((value) => (isNaN(value) ? undefined : value)) - .min(0, 'Biaya per item minimal 0!') - .typeError('Biaya per item harus berupa angka!') - .optional() - .default(undefined), - nama_sopir: Yup.string().required('Nama sopir wajib diisi!'), + driver_name: Yup.string().required('Nama sopir wajib diisi!'), + vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + supplier_id: Yup.number().required('Supplier wajib diisi!'), + products: Yup.array() + .of(DeliveryProductObjectSchema) + .min(1, 'Minimal harus ada 1 produk!') + .required('Produk wajib diisi!'), }); export const MovementFormSchema = Yup.object({ - alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'), - tanggal_transfer: Yup.string().required('Tanggal transfer wajib diisi!'), - warehouse_asal: Yup.object({ + transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), + transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), + source_warehouse: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - warehouse_asal_id: Yup.number() + source_warehouse_id: Yup.number() .required('Gudang asal wajib diisi!') .typeError('Gudang asal wajib diisi!'), - warehouse_tujuan: Yup.object({ + destination_warehouse: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - warehouse_tujuan_id: Yup.number() + destination_warehouse_id: Yup.number() .required('Gudang tujuan wajib diisi!') .typeError('Gudang tujuan wajib diisi!'), - product: Yup.array() + products: Yup.array() .of(ProductObjectSchema) .min(1, 'Minimal harus ada 1 produk!') .required('Produk wajib diisi!'), - ekspedisi: Yup.array() - .of(EkspedisiObjectSchema) - .min(1, 'Minimal harus ada 1 ekspedisi!') - .required('Ekspedisi wajib diisi!'), + deliveries: Yup.array() + .of(DeliveryObjectSchema) + .min(1, 'Minimal harus ada 1 pengiriman!') + .required('Pengiriman wajib diisi!'), }); export const UpdateMovementFormSchema = MovementFormSchema; @@ -133,40 +128,41 @@ export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( initialValues?: Movement ): MovementFormValues => ({ - alasan_transfer: initialValues?.alasan_transfer ?? '', - tanggal_transfer: initialValues?.tanggal_transfer ?? '', - warehouse_asal: initialValues?.warehouse_asal + transfer_reason: initialValues?.transfer_reason ?? '', + transfer_date: initialValues?.transfer_date ?? '', + source_warehouse: initialValues?.source_warehouse ? { - value: initialValues.warehouse_asal.id, - label: initialValues.warehouse_asal.name, + value: initialValues.source_warehouse.id, + label: initialValues.source_warehouse.name, } : null, - warehouse_asal_id: initialValues?.warehouse_asal?.id ?? 0, - warehouse_tujuan: initialValues?.warehouse_tujuan + source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, + destination_warehouse: initialValues?.destination_warehouse ? { - value: initialValues.warehouse_tujuan.id, - label: initialValues.warehouse_tujuan.name, + value: initialValues.destination_warehouse.id, + label: initialValues.destination_warehouse.name, } : null, - warehouse_tujuan_id: initialValues?.warehouse_tujuan?.id ?? 0, - product: - initialValues?.product?.map((p) => ({ + destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, + products: + initialValues?.products?.map((p) => ({ product: { value: p.product.id, label: p.product.name }, product_id: p.product.id, - qty_product: p.qty_product, + product_qty: p.product_qty, })) ?? [], - ekspedisi: - initialValues?.ekspedisi?.map((e) => ({ - product: { value: e.product_id, label: '' }, - 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, - biaya_ekspedisi_per_item: e.biaya_ekspedisi, - nama_sopir: e.nama_sopir, + deliveries: + initialValues?.deliveries?.map((d) => ({ + delivery_cost: d.delivery_cost, + delivery_cost_per_item: d.delivery_cost_per_item, + document: d.document, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier: { value: d.supplier.id, label: d.supplier.name }, + supplier_id: d.supplier.id, + products: d.products.map((p) => ({ + product: { value: p.product.id, label: p.product.name }, + product_id: p.product.id, + product_qty: p.product_qty, + })), })) ?? [], }); diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index dd927665..183fe760 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -22,7 +22,7 @@ import { UpdateMovementFormSchema, getMovementFormInitialValues, ProductSchema, - EkspedisiSchema, + DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; import { @@ -41,7 +41,7 @@ interface MovementFormProps { const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [, setMovementFormErrorMessage] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); - const [selectedEkspedisi, setSelectedEkspedisi] = useState([]); + const [selectedDeliveries, setSelectedDeliveries] = useState([]); const { deleteModal, @@ -64,31 +64,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, validateOnChange: true, validateOnBlur: true, - validateOnMount: true, + validateOnMount: false, + enableReinitialize: true, 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) => ({ + transfer_reason: values.transfer_reason, + transfer_date: values.transfer_date, + source_warehouse_id: values.source_warehouse_id, + destination_warehouse_id: values.destination_warehouse_id, + products: values.products.map((p) => ({ product_id: p.product_id, - qty_product: p.qty_product, + product_qty: p.product_qty, })), - 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, - biaya_ekspedisi_per_item: e.qty - ? e.biaya_ekspedisi / e.qty - : e.biaya_ekspedisi, - nama_sopir: e.nama_sopir, + deliveries: values.deliveries.map((d) => ({ + delivery_cost: d.delivery_cost, + delivery_cost_per_item: d.delivery_cost_per_item ?? 0, + document: d.document instanceof File ? d.document : d.document, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier_id: d.supplier_id, + products: d.products.map((p) => ({ + product_id: p.product_id, + product_qty: p.product_qty, + })), })), }; @@ -105,65 +104,67 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const addProduct = () => { const newProducts = [ - ...(formik.values.product || []), + ...(formik.values.products || []), { product: null, product_id: 0, - qty_product: 0, + product_qty: 0, }, ]; - formik.setFieldValue('product', newProducts); + formik.setFieldValue('products', newProducts); }; const removeProduct = useCallback( (i: number) => { const updatedProducts = - formik.values.product?.reduce((acc: ProductSchema[], item, index) => { + formik.values.products?.reduce((acc: ProductSchema[], item, index) => { if (index !== i) { acc.push(item); } return acc; }, []) ?? []; - formik.setFieldValue('product', updatedProducts); + formik.setFieldValue('products', updatedProducts); }, [formik] ); const bulkRemoveProduct = useCallback(() => { const updatedProducts = - formik.values.product?.filter( + formik.values.products?.filter( (_, idx) => !selectedProducts.includes(idx) ) ?? []; - formik.setFieldValue('product', updatedProducts); + formik.setFieldValue('products', updatedProducts); setSelectedProducts([]); }, [formik, selectedProducts]); - const addEkspedisi = () => { - const newEkspedisi = [ - ...(formik.values.ekspedisi || []), + const addDelivery = () => { + formik.setFieldValue('deliveries', [ + ...(formik.values.deliveries || []), { - product: null, - product_id: 0, - qty: 0, + delivery_cost: 0, + delivery_cost_per_item: 0, + document: '', + driver_name: '', + vehicle_plate: '', supplier: null, supplier_id: 0, - plat_nomor: '', - no_surat_jalan: '', - dokumen: '', - biaya_ekspedisi: 0, - biaya_ekspedisi_per_item: 0, - nama_sopir: '', + products: [ + { + product: null, + product_id: 0, + product_qty: 0, + }, + ], }, - ]; - formik.setFieldValue('ekspedisi', newEkspedisi); + ]); }; - const removeEkspedisi = useCallback( + const removeDelivery = useCallback( (i: number) => { - const updatedEkspedisi = - formik.values.ekspedisi?.reduce( - (acc: EkspedisiSchema[], item, index) => { + const updatedDeliveries = + formik.values.deliveries?.reduce( + (acc: DeliverySchema[], item, index) => { if (index !== i) { acc.push(item); } @@ -172,23 +173,23 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [] ) ?? []; - formik.setFieldValue('ekspedisi', updatedEkspedisi); + formik.setFieldValue('deliveries', updatedDeliveries); }, [formik] ); - const bulkRemoveEkspedisi = useCallback(() => { - const updatedEkspedisi = - formik.values.ekspedisi?.filter( - (_, idx) => !selectedEkspedisi.includes(idx) + const bulkRemoveDelivery = useCallback(() => { + const updatedDeliveries = + formik.values.deliveries?.filter( + (_, idx) => !selectedDeliveries.includes(idx) ) ?? []; - formik.setFieldValue('ekspedisi', updatedEkspedisi); - setSelectedEkspedisi([]); - }, [formik, selectedEkspedisi]); + formik.setFieldValue('deliveries', updatedDeliveries); + setSelectedDeliveries([]); + }, [formik, selectedDeliveries]); - const isRepeaterInputError = ( + const isRepeaterInputError = ( arrayName: T, - column: T extends 'product' ? keyof ProductSchema : keyof EkspedisiSchema, + column: T extends 'products' ? keyof ProductSchema : keyof DeliverySchema, idx: number ) => { if ( @@ -260,57 +261,80 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) : []; - const { setValues: formikSetValues } = formik; - useEffect(() => { - formikSetValues(formikInitialValues); - }, [formikSetValues, formikInitialValues]); - - useEffect(() => { - formik.values.ekspedisi?.forEach((eks, idx) => { - if (eks.qty && eks.biaya_ekspedisi) { - const perItem = eks.biaya_ekspedisi / eks.qty; + formik.values.deliveries?.forEach((delivery, idx) => { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + if (productQty && delivery.delivery_cost) { + const perItem = delivery.delivery_cost / productQty; formik.setFieldValue( - `ekspedisi.${idx}.biaya_ekspedisi_per_item`, + `deliveries.${idx}.delivery_cost_per_item`, perItem ); } }); - }, [formik.values.ekspedisi]); + }, [formik.values.deliveries]); const getFilteredProductOptions = useCallback(() => { return ( - formik.values.product + formik.values.products ?.filter((p) => p.product) .map((p) => ({ value: p.product_id, label: (p.product as OptionType)?.label, })) ?? [] ); - }, [formik.values.product]); + }, [formik.values.products]); - const validateEkspedisiQty = (ekspedisiIdx: number, qty: number) => { - const productId = formik.values.ekspedisi?.[ekspedisiIdx]?.product_id; - if (!productId) return true; - const relatedProduct = formik.values.product?.find( - (p) => p.product_id === productId - ); - if (!relatedProduct) return true; - const totalQtyUsed = - formik.values.ekspedisi?.reduce((total, eks, i) => { - if (eks.product_id === productId && i !== ekspedisiIdx) { - return total + (Number(eks.qty) || 0); - } - return total; - }, 0) || 0; - return totalQtyUsed + qty <= Number(relatedProduct.qty_product); - }; + const validateDeliveryQty = useCallback( + (deliveryIdx: number, deliveryProductIdx: number, qty: number) => { + const delivery = formik.values.deliveries?.[deliveryIdx]; + if (!delivery) return true; - const invalidQtyRows = - formik.values.ekspedisi?.map((eks, idx) => { - const qty = Number(eks.qty) || 0; - return !validateEkspedisiQty(idx, qty); - }) ?? []; + const deliveryProduct = delivery.products[deliveryProductIdx]; + if (!deliveryProduct) return true; + + const productId = deliveryProduct.product_id; + if (!productId) return true; + + const relatedProduct = formik.values.products?.find( + (p) => p.product_id === productId + ); + if (!relatedProduct) return true; + + const totalQtyUsed = + formik.values.deliveries?.reduce((total, d) => { + const productQty = d.products.reduce((sum, p, pIdx) => { + if ( + p.product_id === productId && + !(d === delivery && pIdx === deliveryProductIdx) + ) { + return sum + (Number(p.product_qty) || 0); + } + return sum; + }, 0); + return total + productQty; + }, 0) || 0; + + return totalQtyUsed + qty <= Number(relatedProduct.product_qty); + }, + [formik.values.deliveries, formik.values.products] + ); + + const invalidQtyRows = useMemo( + () => + formik.values.deliveries?.flatMap((delivery, deliveryIdx) => + delivery.products.map((product, productIdx) => { + const qty = Number(product.product_qty) || 0; + return !validateDeliveryQty(deliveryIdx, productIdx, qty); + }) + ) ?? [], + [formik.values.deliveries, formik.values.products, validateDeliveryQty] + ); + + const hasInvalidQty = invalidQtyRows.some(Boolean); return ( <> @@ -332,30 +356,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { @@ -370,11 +394,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { - formik.setFieldValue('warehouse_asal', val); + formik.setFieldValue('source_warehouse', val); formik.setFieldValue( - 'warehouse_asal_id', + 'source_warehouse_id', (val as WarehouseOptionType)?.value ); }} @@ -382,10 +406,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { onInputChange={setWarehouseSelectInputValue} isLoading={isLoadingWarehouses} isError={ - formik.touched.warehouse_asal_id && - Boolean(formik.errors.warehouse_asal_id) + formik.touched.source_warehouse_id && + Boolean(formik.errors.source_warehouse_id) } - errorMessage={formik.errors.warehouse_asal_id as string} + errorMessage={formik.errors.source_warehouse_id as string} isDisabled={type === 'detail'} isClearable /> @@ -394,9 +418,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{ /> { { - formik.setFieldValue('warehouse_tujuan', val); + formik.setFieldValue('destination_warehouse', val); formik.setFieldValue( - 'warehouse_tujuan_id', + 'destination_warehouse_id', (val as WarehouseOptionType)?.value ); }} @@ -440,10 +464,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { onInputChange={setWarehouseSelectInputValue} isLoading={isLoadingWarehouses} isError={ - formik.touched.warehouse_tujuan_id && - Boolean(formik.errors.warehouse_tujuan_id) + formik.touched.destination_warehouse_id && + Boolean(formik.errors.destination_warehouse_id) + } + errorMessage={ + formik.errors.destination_warehouse_id as string } - errorMessage={formik.errors.warehouse_tujuan_id as string} isDisabled={type === 'detail'} isClearable /> @@ -452,10 +478,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{ /> { type='checkbox' className='checkbox' checked={ - formik.values.product?.length === + formik.values.products?.length === selectedProducts.length && - formik.values.product?.length > 0 + formik.values.products?.length > 0 } onChange={(e) => { if (e.target.checked) { setSelectedProducts( - formik.values.product?.map((_, idx) => idx) ?? - [] + formik.values.products?.map( + (_, idx) => idx + ) ?? [] ); } else { setSelectedProducts([]); @@ -518,7 +549,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { - {formik.values.product?.map((product, idx) => ( + {formik.values.products?.map((product, idx) => ( {type !== 'detail' && ( @@ -547,11 +578,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={product.product ?? undefined} onChange={(val) => { formik.setFieldValue( - `product.${idx}.product`, + `products.${idx}.product`, val ); formik.setFieldValue( - `product.${idx}.product_id`, + `products.${idx}.product_id`, (val as OptionType)?.value ); }} @@ -560,20 +591,24 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isLoading={isLoadingProducts} isDisabled={type === 'detail'} isClearable - {...isRepeaterInputError('product', 'product', idx)} + {...isRepeaterInputError( + 'products', + 'product', + idx + )} /> {
- {/* Ekspedisi table */} + {/* Deliveries table */}
-

Ekspedisi

+

Pengiriman

@@ -647,19 +682,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.ekspedisi?.length === - selectedEkspedisi.length && - formik.values.ekspedisi?.length > 0 + formik.values.deliveries?.length === + selectedDeliveries.length && + formik.values.deliveries?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedEkspedisi( - formik.values.ekspedisi?.map( + setSelectedDeliveries( + formik.values.deliveries?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedEkspedisi([]); + setSelectedDeliveries([]); } }} /> @@ -669,34 +704,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { - - - + + {type !== 'detail' && } - {formik.values.ekspedisi?.map((ekspedisi, idx) => ( - + {formik.values.deliveries?.map((delivery, idx) => ( + {type !== 'detail' && ( - {type !== 'detail' && ( @@ -911,7 +880,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { )} )} @@ -968,7 +936,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { : undefined } onDelete={deleteMovementClickHandler} - disableSubmit={invalidQtyRows.some(Boolean)} + disableSubmit={hasInvalidQty} /> {movementFormErrorMessage && ( diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index d7f2776a..11da41a5 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -5,47 +5,50 @@ 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: { + transfer_reason: string; + transfer_date: string; + source_warehouse: Warehouse; + destination_warehouse: Warehouse; + products: { product: Product; - qty_product: number; + product_qty: number; }[]; - ekspedisi: { - product_id: number; - qty: number; + deliveries: { + delivery_cost: number; + delivery_cost_per_item: number; + document: string; + driver_name: string; + vehicle_plate: string; supplier: Supplier; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string; - biaya_ekspedisi: number; - nama_sopir: string; + products: { + product: Product; + product_qty: number; + }[]; }[]; }; export type Movement = BaseMetadata & BaseMovement; export type CreateMovementPayload = { - alasan_transfer: string; - tanggal_transfer: string; - warehouse_asal_id: number; - warehouse_tujuan_id: number; - product: { + transfer_reason: string; + transfer_date: string; + source_warehouse_id: number; + destination_warehouse_id: number; + products: { product_id: number; - qty_product: number; + product_qty: number; }[]; - ekspedisi: { - product_id: number; - qty: number; + deliveries: { + delivery_cost: number; + delivery_cost_per_item: number; + document: string | File; + driver_name: string; + vehicle_plate: string; supplier_id: number; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string | File; - biaya_ekspedisi: number; - biaya_ekspedisi_per_item?: number; - nama_sopir: string; + products: { + product_id: number; + product_qty: number; + }[]; }[]; }; From 06dc869b846c396d568a58aa1d9cdb695c8be3d5 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 12:08:52 +0700 Subject: [PATCH 05/16] feat(FE-64): update MovementTable structure for improved data clarity and consistency --- .../inventory/movement/MovementTable.tsx | 244 +++++++++++++----- 1 file changed, 178 insertions(+), 66 deletions(-) diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index b39906d3..a00f7111 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -31,23 +31,23 @@ const dummyMovements: Movement[] = [ { ...baseMetadata, id: 1, - alasan_transfer: 'Restock', - tanggal_transfer: '2024-06-01', - warehouse_asal: { + transfer_reason: 'Restock', + transfer_date: '2024-06-01', + source_warehouse: { ...baseMetadata, id: 1, name: 'Warehouse A', type: 'AREA', area: { id: 1, name: 'Area 1' }, }, - warehouse_tujuan: { + destination_warehouse: { ...baseMetadata, id: 2, name: 'Warehouse B', type: 'AREA', area: { id: 2, name: 'Area 2' }, }, - product: [ + products: [ { product: { ...baseMetadata, @@ -73,13 +73,16 @@ const dummyMovements: Movement[] = [ suppliers: [], flags: [], }, - qty_product: 10, + product_qty: 10, }, ], - ekspedisi: [ + deliveries: [ { - product_id: 1, - qty: 10, + delivery_cost: 50000, + delivery_cost_per_item: 5000, + document: 'doc1.pdf', + driver_name: 'Andi', + vehicle_plate: 'B 1234 CD', supplier: { ...baseMetadata, id: 1, @@ -97,34 +100,58 @@ const dummyMovements: Movement[] = [ balance: 0, due_date: 30, }, - plat_nomor: 'B 1234 CD', - no_surat_jalan: 'SJ-001', - dokumen: 'doc1.pdf', - biaya_ekspedisi: 50000, - nama_sopir: 'Andi', + products: [ + { + product: { + ...baseMetadata, + id: 1, + name: 'Product X', + brand: 'Brand X', + sku: 'SKU-X', + product_price: 10000, + selling_price: 12000, + tax: 10, + expiry_period: 365, + uom: { + ...baseMetadata, + id: 1, + name: 'PCS', + }, + product_category: { + ...baseMetadata, + id: 1, + code: 'CAT-1', + name: 'Category 1', + }, + suppliers: [], + flags: [], + }, + product_qty: 10, + }, + ], }, ], }, { ...baseMetadata, id: 2, - alasan_transfer: 'Mutasi Stok', - tanggal_transfer: '2024-06-02', - warehouse_asal: { + transfer_reason: 'Mutasi Stok', + transfer_date: '2024-06-02', + source_warehouse: { ...baseMetadata, id: 2, name: 'Warehouse B', type: 'AREA', area: { id: 2, name: 'Area 2' }, }, - warehouse_tujuan: { + destination_warehouse: { ...baseMetadata, id: 3, name: 'Warehouse C', type: 'AREA', area: { id: 3, name: 'Area 3' }, }, - product: [ + products: [ { product: { ...baseMetadata, @@ -150,13 +177,16 @@ const dummyMovements: Movement[] = [ suppliers: [], flags: [], }, - qty_product: 5, + product_qty: 5, }, ], - ekspedisi: [ + deliveries: [ { - product_id: 2, - qty: 5, + delivery_cost: 60000, + delivery_cost_per_item: 12000, + document: 'doc2.pdf', + driver_name: 'Budi', + vehicle_plate: 'D 5678 EF', supplier: { ...baseMetadata, id: 2, @@ -174,34 +204,58 @@ const dummyMovements: Movement[] = [ balance: 1000, due_date: 15, }, - plat_nomor: 'D 5678 EF', - no_surat_jalan: 'SJ-002', - dokumen: 'doc2.pdf', - biaya_ekspedisi: 60000, - nama_sopir: 'Budi', + products: [ + { + product: { + ...baseMetadata, + id: 2, + name: 'Product Y', + brand: 'Brand Y', + sku: 'SKU-Y', + product_price: 20000, + selling_price: 25000, + tax: 5, + expiry_period: 180, + uom: { + ...baseMetadata, + id: 2, + name: 'BOX', + }, + product_category: { + ...baseMetadata, + id: 2, + code: 'CAT-2', + name: 'Category 2', + }, + suppliers: [], + flags: [], + }, + product_qty: 5, + }, + ], }, ], }, { ...baseMetadata, id: 3, - alasan_transfer: 'Pengembalian', - tanggal_transfer: '2024-06-03', - warehouse_asal: { + transfer_reason: 'Pengembalian', + transfer_date: '2024-06-03', + source_warehouse: { ...baseMetadata, id: 3, name: 'Warehouse C', type: 'AREA', area: { id: 3, name: 'Area 3' }, }, - warehouse_tujuan: { + destination_warehouse: { ...baseMetadata, id: 1, name: 'Warehouse A', type: 'AREA', area: { id: 1, name: 'Area 1' }, }, - product: [ + products: [ { product: { ...baseMetadata, @@ -227,13 +281,16 @@ const dummyMovements: Movement[] = [ suppliers: [], flags: [], }, - qty_product: 8, + product_qty: 8, }, ], - ekspedisi: [ + deliveries: [ { - product_id: 3, - qty: 8, + delivery_cost: 40000, + delivery_cost_per_item: 5000, + document: 'doc3.pdf', + driver_name: 'Cici', + vehicle_plate: 'F 9101 GH', supplier: { ...baseMetadata, id: 3, @@ -251,34 +308,58 @@ const dummyMovements: Movement[] = [ balance: 500, due_date: 10, }, - plat_nomor: 'F 9101 GH', - no_surat_jalan: 'SJ-003', - dokumen: 'doc3.pdf', - biaya_ekspedisi: 40000, - nama_sopir: 'Cici', + products: [ + { + product: { + ...baseMetadata, + id: 3, + name: 'Product Z', + brand: 'Brand Z', + sku: 'SKU-Z', + product_price: 15000, + selling_price: 18000, + tax: 8, + expiry_period: 90, + uom: { + ...baseMetadata, + id: 3, + name: 'KG', + }, + product_category: { + ...baseMetadata, + id: 3, + code: 'CAT-3', + name: 'Category 3', + }, + suppliers: [], + flags: [], + }, + product_qty: 8, + }, + ], }, ], }, { ...baseMetadata, id: 4, - alasan_transfer: 'Transfer Internal', - tanggal_transfer: '2024-06-04', - warehouse_asal: { + transfer_reason: 'Transfer Internal', + transfer_date: '2024-06-04', + source_warehouse: { ...baseMetadata, id: 4, name: 'Warehouse D', type: 'AREA', area: { id: 4, name: 'Area 4' }, }, - warehouse_tujuan: { + destination_warehouse: { ...baseMetadata, id: 5, name: 'Warehouse E', type: 'AREA', area: { id: 5, name: 'Area 5' }, }, - product: [ + products: [ { product: { ...baseMetadata, @@ -304,13 +385,16 @@ const dummyMovements: Movement[] = [ suppliers: [], flags: [], }, - qty_product: 20, + product_qty: 20, }, ], - ekspedisi: [ + deliveries: [ { - product_id: 4, - qty: 20, + delivery_cost: 30000, + delivery_cost_per_item: 1500, + document: 'doc4.pdf', + driver_name: 'Dedi', + vehicle_plate: 'H 2345 IJ', supplier: { ...baseMetadata, id: 4, @@ -328,11 +412,35 @@ const dummyMovements: Movement[] = [ balance: 200, due_date: 20, }, - plat_nomor: 'H 2345 IJ', - no_surat_jalan: 'SJ-004', - dokumen: 'doc4.pdf', - biaya_ekspedisi: 30000, - nama_sopir: 'Dedi', + products: [ + { + product: { + ...baseMetadata, + id: 4, + name: 'Product A', + brand: 'Brand A', + sku: 'SKU-A', + product_price: 5000, + selling_price: 7000, + tax: 0, + expiry_period: 60, + uom: { + ...baseMetadata, + id: 4, + name: 'LITER', + }, + product_category: { + ...baseMetadata, + id: 4, + code: 'CAT-4', + name: 'Category 4', + }, + suppliers: [], + flags: [], + }, + product_qty: 20, + }, + ], }, ], }, @@ -401,30 +509,34 @@ const MovementTable = () => { cell: (props) => pageSize * (page - 1) + props.row.index + 1, }, { - accessorKey: 'warehouse_asal', + accessorKey: 'source_warehouse', header: 'Gudang Asal', - cell: (props) => props.row.original.warehouse_asal.name, + cell: (props) => props.row.original.source_warehouse.name, }, { - accessorKey: 'warehouse_tujuan', + accessorKey: 'destination_warehouse', header: 'Gudang Tujuan', - cell: (props) => props.row.original.warehouse_tujuan.name, + cell: (props) => props.row.original.destination_warehouse.name, }, { - accessorKey: 'product', + accessorKey: 'products', header: 'Nama Produk', cell: (props) => - props.row.original.product.map((p) => p.product.name), + props.row.original.products + .map((p) => p.product.name) + .join(', '), }, { - accessorKey: 'alasan_transfer', + accessorKey: 'transfer_reason', header: 'Catatan', }, { - accessorKey: 'biaya_ekspedisi', - header: 'Biaya Ekspedisi', + accessorKey: 'delivery_cost', + header: 'Biaya Pengiriman', cell: (props) => - props.row.original.ekspedisi.map((e) => e.biaya_ekspedisi), + props.row.original.deliveries + .reduce((sum, d) => sum + d.delivery_cost, 0) + .toLocaleString('id-ID'), }, { id: 'actions', From aa21088e99cf25be75961872e1d4d6c795b16af8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 12:30:59 +0700 Subject: [PATCH 06/16] feat(FE-62): enhance MovementForm with delivery product input error handling and validation --- .../inventory/movement/form/MovementForm.tsx | 86 ++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 183fe760..1af77cf7 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -214,6 +214,32 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }; }; + const isDeliveryProductInputError = ( + deliveryIdx: number, + productIdx: number, + column: keyof DeliverySchema['products'][number] + ) => { + const touchedDelivery = formik.touched.deliveries?.[deliveryIdx]; + const errorDelivery = formik.errors.deliveries?.[deliveryIdx] as + | { products: Array> } + | undefined; + + if (!touchedDelivery?.products || !errorDelivery?.products) { + return { + isError: false, + errorMessage: undefined, + }; + } + + const touchedField = touchedDelivery.products[productIdx]?.[column]; + const errorField = errorDelivery.products[productIdx]?.[column]; + + return { + isError: Boolean(touchedField && errorField), + errorMessage: touchedField ? errorField : undefined, + }; + }; + interface WarehouseOptionType extends OptionType { area?: string; location?: string; @@ -305,11 +331,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { if (!relatedProduct) return true; const totalQtyUsed = - formik.values.deliveries?.reduce((total, d) => { + formik.values.deliveries?.reduce((total, d, dIdx) => { const productQty = d.products.reduce((sum, p, pIdx) => { if ( p.product_id === productId && - !(d === delivery && pIdx === deliveryProductIdx) + !(dIdx === deliveryIdx && pIdx === deliveryProductIdx) ) { return sum + (Number(p.product_qty) || 0); } @@ -323,6 +349,47 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik.values.deliveries, formik.values.products] ); + const getDeliveryQtyError = useCallback( + (deliveryIdx: number, deliveryProductIdx: number) => { + const delivery = formik.values.deliveries?.[deliveryIdx]; + if (!delivery) return null; + + const deliveryProduct = delivery.products[deliveryProductIdx]; + if (!deliveryProduct || !deliveryProduct.product_id) return null; + + const qty = Number(deliveryProduct.product_qty) || 0; + const productId = deliveryProduct.product_id; + + const relatedProduct = formik.values.products?.find( + (p) => p.product_id === productId + ); + if (!relatedProduct) return null; + + const totalQtyUsed = + formik.values.deliveries?.reduce((total, d, dIdx) => { + const productQty = d.products.reduce((sum, p, pIdx) => { + if ( + p.product_id === productId && + !(dIdx === deliveryIdx && pIdx === deliveryProductIdx) + ) { + return sum + (Number(p.product_qty) || 0); + } + return sum; + }, 0); + return total + productQty; + }, 0) || 0; + + const availableQty = Number(relatedProduct.product_qty) - totalQtyUsed; + + if (totalQtyUsed + qty > Number(relatedProduct.product_qty)) { + return `Qty melebihi stok produk! Tersedia: ${availableQty}, Total digunakan: ${totalQtyUsed + qty}`; + } + + return null; + }, + [formik.values.deliveries, formik.values.products] + ); + const invalidQtyRows = useMemo( () => formik.values.deliveries?.flatMap((delivery, deliveryIdx) => @@ -334,7 +401,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik.values.deliveries, formik.values.products, validateDeliveryQty] ); - const hasInvalidQty = invalidQtyRows.some(Boolean); + const hasInvalidQty = useMemo( + () => invalidQtyRows.some(Boolean), + [invalidQtyRows] + ); return ( <> @@ -762,6 +832,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={delivery.products[0]?.product_qty ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={ + isDeliveryProductInputError(idx, 0, 'product_qty') + .isError || Boolean(getDeliveryQtyError(idx, 0)) + } + errorMessage={ + isDeliveryProductInputError(idx, 0, 'product_qty') + .errorMessage || + getDeliveryQtyError(idx, 0) || + undefined + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', From c6a0c542aad1dcc2c4dc8c5361bfa95a6d9b01cd Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 14:33:49 +0700 Subject: [PATCH 07/16] refactor(FE-62,63,65): refactor Movement and ProductWarehouse APIs, update MovementForm schema, and enhance MovementTable functionality --- .../inventory/movement/MovementTable.tsx | 622 ++++-------------- .../movement/form/MovementForm.schema.ts | 53 +- .../movement/form/useMovementFormHandlers.ts | 80 ++- src/components/table/TableRowOptions.tsx | 11 +- src/services/api/inventory.ts | 13 +- src/types/api/inventory/movement.d.ts | 32 +- .../api/inventory/product-warehouse.d.ts | 22 + 7 files changed, 277 insertions(+), 556 deletions(-) create mode 100644 src/types/api/inventory/product-warehouse.d.ts diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index a00f7111..e0bc9541 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -1,463 +1,56 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState } from 'react'; +import useSWR from 'swr'; import { SortingState } from '@tanstack/react-table'; -import { Icon } from '@iconify/react'; + import Table from '@/components/Table'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; -import { ROWS_OPTIONS } from '@/config/constant'; import { Movement } from '@/types/api/inventory/movement'; +import { MovementApi } from '@/services/api/inventory'; +import { cn } from '@/lib/helper'; +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 { TableRowOptions } from '@/components/table/TableRowOptions'; import { OptionType } from '@/components/input/SelectInput'; -import Button from '@/components/Button'; -import { cn } from '@/lib/helper'; - -// Dummy data -const baseMetadata = { - created_user: { - id: 1, - id_user: 1, - email: 'user@example.com', - name: 'User', - }, - created_at: '2024-06-01T00:00:00Z', - updated_at: '2024-06-01T00:00:00Z', -}; - -const dummyMovements: Movement[] = [ - { - ...baseMetadata, - id: 1, - transfer_reason: 'Restock', - transfer_date: '2024-06-01', - source_warehouse: { - ...baseMetadata, - id: 1, - name: 'Warehouse A', - type: 'AREA', - area: { id: 1, name: 'Area 1' }, - }, - destination_warehouse: { - ...baseMetadata, - id: 2, - name: 'Warehouse B', - type: 'AREA', - area: { id: 2, name: 'Area 2' }, - }, - products: [ - { - product: { - ...baseMetadata, - id: 1, - name: 'Product X', - brand: 'Brand X', - sku: 'SKU-X', - product_price: 10000, - selling_price: 12000, - tax: 10, - expiry_period: 365, - uom: { - ...baseMetadata, - id: 1, - name: 'PCS', - }, - product_category: { - ...baseMetadata, - id: 1, - code: 'CAT-1', - name: 'Category 1', - }, - suppliers: [], - flags: [], - }, - product_qty: 10, - }, - ], - deliveries: [ - { - delivery_cost: 50000, - delivery_cost_per_item: 5000, - document: 'doc1.pdf', - driver_name: 'Andi', - vehicle_plate: 'B 1234 CD', - supplier: { - ...baseMetadata, - id: 1, - name: 'Supplier 1', - alias: 'S1', - category: 'General', - pic: 'PIC 1', - type: 'Type 1', - hatchery: 'Hatchery 1', - phone: '08123456789', - email: 'supplier1@example.com', - address: 'Address 1', - npwp: '1234567890123456', - account_number: '1234567890', - balance: 0, - due_date: 30, - }, - products: [ - { - product: { - ...baseMetadata, - id: 1, - name: 'Product X', - brand: 'Brand X', - sku: 'SKU-X', - product_price: 10000, - selling_price: 12000, - tax: 10, - expiry_period: 365, - uom: { - ...baseMetadata, - id: 1, - name: 'PCS', - }, - product_category: { - ...baseMetadata, - id: 1, - code: 'CAT-1', - name: 'Category 1', - }, - suppliers: [], - flags: [], - }, - product_qty: 10, - }, - ], - }, - ], - }, - { - ...baseMetadata, - id: 2, - transfer_reason: 'Mutasi Stok', - transfer_date: '2024-06-02', - source_warehouse: { - ...baseMetadata, - id: 2, - name: 'Warehouse B', - type: 'AREA', - area: { id: 2, name: 'Area 2' }, - }, - destination_warehouse: { - ...baseMetadata, - id: 3, - name: 'Warehouse C', - type: 'AREA', - area: { id: 3, name: 'Area 3' }, - }, - products: [ - { - product: { - ...baseMetadata, - id: 2, - name: 'Product Y', - brand: 'Brand Y', - sku: 'SKU-Y', - product_price: 20000, - selling_price: 25000, - tax: 5, - expiry_period: 180, - uom: { - ...baseMetadata, - id: 2, - name: 'BOX', - }, - product_category: { - ...baseMetadata, - id: 2, - code: 'CAT-2', - name: 'Category 2', - }, - suppliers: [], - flags: [], - }, - product_qty: 5, - }, - ], - deliveries: [ - { - delivery_cost: 60000, - delivery_cost_per_item: 12000, - document: 'doc2.pdf', - driver_name: 'Budi', - vehicle_plate: 'D 5678 EF', - supplier: { - ...baseMetadata, - id: 2, - name: 'Supplier 2', - alias: 'S2', - category: 'Special', - pic: 'PIC 2', - type: 'Type 2', - hatchery: 'Hatchery 2', - phone: '08123456780', - email: 'supplier2@example.com', - address: 'Address 2', - npwp: '1234567890123457', - account_number: '1234567891', - balance: 1000, - due_date: 15, - }, - products: [ - { - product: { - ...baseMetadata, - id: 2, - name: 'Product Y', - brand: 'Brand Y', - sku: 'SKU-Y', - product_price: 20000, - selling_price: 25000, - tax: 5, - expiry_period: 180, - uom: { - ...baseMetadata, - id: 2, - name: 'BOX', - }, - product_category: { - ...baseMetadata, - id: 2, - code: 'CAT-2', - name: 'Category 2', - }, - suppliers: [], - flags: [], - }, - product_qty: 5, - }, - ], - }, - ], - }, - { - ...baseMetadata, - id: 3, - transfer_reason: 'Pengembalian', - transfer_date: '2024-06-03', - source_warehouse: { - ...baseMetadata, - id: 3, - name: 'Warehouse C', - type: 'AREA', - area: { id: 3, name: 'Area 3' }, - }, - destination_warehouse: { - ...baseMetadata, - id: 1, - name: 'Warehouse A', - type: 'AREA', - area: { id: 1, name: 'Area 1' }, - }, - products: [ - { - product: { - ...baseMetadata, - id: 3, - name: 'Product Z', - brand: 'Brand Z', - sku: 'SKU-Z', - product_price: 15000, - selling_price: 18000, - tax: 8, - expiry_period: 90, - uom: { - ...baseMetadata, - id: 3, - name: 'KG', - }, - product_category: { - ...baseMetadata, - id: 3, - code: 'CAT-3', - name: 'Category 3', - }, - suppliers: [], - flags: [], - }, - product_qty: 8, - }, - ], - deliveries: [ - { - delivery_cost: 40000, - delivery_cost_per_item: 5000, - document: 'doc3.pdf', - driver_name: 'Cici', - vehicle_plate: 'F 9101 GH', - supplier: { - ...baseMetadata, - id: 3, - name: 'Supplier 3', - alias: 'S3', - category: 'Return', - pic: 'PIC 3', - type: 'Type 3', - hatchery: 'Hatchery 3', - phone: '08123456781', - email: 'supplier3@example.com', - address: 'Address 3', - npwp: '1234567890123458', - account_number: '1234567892', - balance: 500, - due_date: 10, - }, - products: [ - { - product: { - ...baseMetadata, - id: 3, - name: 'Product Z', - brand: 'Brand Z', - sku: 'SKU-Z', - product_price: 15000, - selling_price: 18000, - tax: 8, - expiry_period: 90, - uom: { - ...baseMetadata, - id: 3, - name: 'KG', - }, - product_category: { - ...baseMetadata, - id: 3, - code: 'CAT-3', - name: 'Category 3', - }, - suppliers: [], - flags: [], - }, - product_qty: 8, - }, - ], - }, - ], - }, - { - ...baseMetadata, - id: 4, - transfer_reason: 'Transfer Internal', - transfer_date: '2024-06-04', - source_warehouse: { - ...baseMetadata, - id: 4, - name: 'Warehouse D', - type: 'AREA', - area: { id: 4, name: 'Area 4' }, - }, - destination_warehouse: { - ...baseMetadata, - id: 5, - name: 'Warehouse E', - type: 'AREA', - area: { id: 5, name: 'Area 5' }, - }, - products: [ - { - product: { - ...baseMetadata, - id: 4, - name: 'Product A', - brand: 'Brand A', - sku: 'SKU-A', - product_price: 5000, - selling_price: 7000, - tax: 0, - expiry_period: 60, - uom: { - ...baseMetadata, - id: 4, - name: 'LITER', - }, - product_category: { - ...baseMetadata, - id: 4, - code: 'CAT-4', - name: 'Category 4', - }, - suppliers: [], - flags: [], - }, - product_qty: 20, - }, - ], - deliveries: [ - { - delivery_cost: 30000, - delivery_cost_per_item: 1500, - document: 'doc4.pdf', - driver_name: 'Dedi', - vehicle_plate: 'H 2345 IJ', - supplier: { - ...baseMetadata, - id: 4, - name: 'Supplier 4', - alias: 'S4', - category: 'Internal', - pic: 'PIC 4', - type: 'Type 4', - hatchery: 'Hatchery 4', - phone: '08123456782', - email: 'supplier4@example.com', - address: 'Address 4', - npwp: '1234567890123459', - account_number: '1234567893', - balance: 200, - due_date: 20, - }, - products: [ - { - product: { - ...baseMetadata, - id: 4, - name: 'Product A', - brand: 'Brand A', - sku: 'SKU-A', - product_price: 5000, - selling_price: 7000, - tax: 0, - expiry_period: 60, - uom: { - ...baseMetadata, - id: 4, - name: 'LITER', - }, - product_category: { - ...baseMetadata, - id: 4, - code: 'CAT-4', - name: 'Category 4', - }, - suppliers: [], - flags: [], - }, - product_qty: 20, - }, - ], - }, - ], - }, -]; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import { TableRowOptions } from '@/components/table/TableRowOptions'; const MovementTable = () => { - const [search, setSearch] = useState(''); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '' }, + paramMap: { page: 'page', pageSize: 'limit' }, + }); + const [sorting, setSorting] = useState([]); - const [, setSelectedMovement] = useState(undefined); + const [selectedMovement, setSelectedMovement] = useState< + Movement | undefined + >(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const deleteModal = useModal(); + const { + data: movements, + isLoading, + mutate: refreshMovements, + } = useSWR( + `${MovementApi.basePath}${getTableFilterQueryString()}`, + MovementApi.getAllFetcher + ); + const searchChangeHandler = (e: React.ChangeEvent) => { - setSearch(e.target.value); + updateFilter('search', e.target.value); setPage(1); }; @@ -469,17 +62,15 @@ const MovementTable = () => { const confirmationModalDeleteClickHandler = async () => { setIsDeleteLoading(true); - setTimeout(() => { - setIsDeleteLoading(false); + try { + await MovementApi.delete(selectedMovement?.id as number); + refreshMovements(); deleteModal.closeModal(); - }, 1000); + } finally { + setIsDeleteLoading(false); + } }; - const paginatedData = useMemo(() => { - const start = (page - 1) * pageSize; - return dummyMovements.slice(start, start + pageSize); - }, [page, pageSize]); - return (
@@ -489,85 +80,118 @@ const MovementTable = () => { label: 'Tambah Movement', }} search={{ - value: search, + value: tableFilterState.search, onChange: searchChangeHandler, placeholder: 'Cari Movement', }} />
-
Qty Supplier Plat NomorNo Surat Jalan DokumenBiaya Ekspedisi (Rp.)Biaya Ekspedisi / Item (Rp.)Biaya Pengiriman (Rp.)Biaya Per Item (Rp.) Nama SopirAksi
{ if (e.target.checked) { - setSelectedEkspedisi([ - ...selectedEkspedisi, + setSelectedDeliveries([ + ...selectedDeliveries, idx, ]); } else { - setSelectedEkspedisi( - selectedEkspedisi.filter((i) => i !== idx) + setSelectedDeliveries( + selectedDeliveries.filter((i) => i !== idx) ); } }} @@ -706,24 +738,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { formik.setFieldValue( - `ekspedisi.${idx}.product`, + `deliveries.${idx}.products.0.product`, val ); formik.setFieldValue( - `ekspedisi.${idx}.product_id`, + `deliveries.${idx}.products.0.product_id`, (val as OptionType)?.value ); - formik.setFieldValue(`ekspedisi.${idx}.qty`, ''); }} options={getFilteredProductOptions()} - {...isRepeaterInputError( - 'ekspedisi', - 'product', - idx - )} isDisabled={type === 'detail'} isClearable /> @@ -732,11 +758,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { { formik.setFieldValue( - `ekspedisi.${idx}.supplier`, + `deliveries.${idx}.supplier`, val ); formik.setFieldValue( - `ekspedisi.${idx}.supplier_id`, + `deliveries.${idx}.supplier_id`, (val as OptionType)?.value ); }} @@ -762,128 +787,75 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isLoading={isLoadingSuppliers} isDisabled={type === 'detail'} isClearable - {...isRepeaterInputError( - 'ekspedisi', - 'supplier', - idx - )} /> - - { const file = e.target.files?.[0]; if (file) { - const allowedTypes = [ - 'application/pdf', - 'image/jpeg', - 'image/jpg', - ]; - if (!allowedTypes.includes(file.type)) { - toast.error( - 'Mohon upload file berformat PDF atau JPEG/JPG.' - ); - return; - } if (file.size > 2 * 1024 * 1024) { toast.error('Ukuran dokumen maksimal 2 MB!'); return; } formik.setFieldValue( - `ekspedisi.${idx}.dokumen`, + `deliveries.${idx}.document`, file ); } }} {...isRepeaterInputError( - 'ekspedisi', - 'dokumen', + 'deliveries', + 'document', idx )} readOnly={type === 'detail'} - className={{ - wrapper: 'w-full min-w-24', - }} /> @@ -891,19 +863,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
+ data={isResponseSuccess(movements) ? movements?.data : []} columns={[ { header: '#', - cell: (props) => pageSize * (page - 1) + props.row.index + 1, - }, - { - accessorKey: 'source_warehouse', - header: 'Gudang Asal', - cell: (props) => props.row.original.source_warehouse.name, - }, - { - accessorKey: 'destination_warehouse', - header: 'Gudang Tujuan', - cell: (props) => props.row.original.destination_warehouse.name, - }, - { - accessorKey: 'products', - header: 'Nama Produk', cell: (props) => - props.row.original.products - .map((p) => p.product.name) - .join(', '), + 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: 'delivery_cost', - header: 'Biaya Pengiriman', + accessorKey: 'transfer_date', + header: 'Tanggal', cell: (props) => - props.row.original.deliveries - .reduce((sum, d) => sum + d.delivery_cost, 0) - .toLocaleString('id-ID'), + new Date(props.row.original.transfer_date).toLocaleDateString( + 'id-ID' + ), }, { - id: 'actions', - cell: (props) => ( -
- - { - setSelectedMovement(props.row.original); - deleteModal.openModal(); - }} - /> -
- ), + 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={pageSize} - page={page} - totalItems={dummyMovements.length} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(movements) ? movements?.meta?.page : 0} + totalItems={ + isResponseSuccess(movements) ? movements?.meta?.total_results : 0 + } onPageChange={setPage} - isLoading={false} + isLoading={isLoading} sorting={sorting} setSorting={setSorting} className={{ containerClassName: cn({ - 'mb-20': paginatedData.length === 0, + '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!', diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index cb0d228d..148a7dce 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -13,7 +13,7 @@ export type ProductSchema = { export type DeliverySchema = { delivery_cost: number; delivery_cost_per_item?: number | undefined; - document: string | File; + document?: File | string | null; driver_name: string; vehicle_plate: string; supplier: { @@ -64,23 +64,15 @@ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ .transform((value) => (isNaN(value) ? undefined : value)) .min(0, 'Biaya per item minimal 0!') .typeError('Biaya per item harus berupa angka!'), - document: Yup.mixed() - .required('Dokumen wajib diisi!') - .test( - 'fileType', - 'Mohon upload file berformat PDF atau JPEG/JPG.', - (value) => - typeof value === 'string' || - (value instanceof File && - ['application/pdf', 'image/jpeg', 'image/jpg'].includes(value.type)) - ) - .test( - 'fileSize', - 'Ukuran dokumen maksimal 2 MB!', - (value) => - typeof value === 'string' || - (value instanceof File && value.size <= 2 * 1024 * 1024) - ), + document_index: Yup.number().optional(), + document: Yup.mixed() + .nullable() + .test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => { + if (!value) return true; + if (typeof value === 'string') return true; + if (value instanceof File) return value.size <= 2 * 1024 * 1024; + return false; + }), driver_name: Yup.string().required('Nama sopir wajib diisi!'), vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), supplier: Yup.object({ @@ -145,24 +137,25 @@ export const getMovementFormInitialValues = ( : null, destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, products: - initialValues?.products?.map((p) => ({ - product: { value: p.product.id, label: p.product.name }, - product_id: p.product.id, - product_qty: p.product_qty, + initialValues?.details?.map((p) => ({ + product: { value: p.product_id, label: '' }, + product_id: p.product_id, + product_qty: p.quantity, })) ?? [], deliveries: initialValues?.deliveries?.map((d) => ({ - delivery_cost: d.delivery_cost, - delivery_cost_per_item: d.delivery_cost_per_item, - document: d.document, + delivery_cost: d.shipping_cost_total, + delivery_cost_per_item: d.shipping_cost_item, + document_index: 0, + document: d.document_path || null, driver_name: d.driver_name, vehicle_plate: d.vehicle_plate, supplier: { value: d.supplier.id, label: d.supplier.name }, - supplier_id: d.supplier.id, - products: d.products.map((p) => ({ - product: { value: p.product.id, label: p.product.name }, - product_id: p.product.id, - product_qty: p.product_qty, + supplier_id: d.supplier_id, + products: d.items.map((p) => ({ + product: { value: 0, label: '' }, + product_id: 0, + product_qty: p.quantity, })), })) ?? [], }); diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts index 0b6b0962..1894b1a7 100644 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -8,7 +8,6 @@ import { UpdateMovementPayload, } from '@/types/api/inventory/movement'; import { isResponseError } from '@/lib/api-helper'; -import { containsFile, toFormData } from '@/lib/form-data'; export const useMovementFormHandlers = (initialValuesId?: number) => { const router = useRouter(); @@ -17,13 +16,44 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const createMovementHandler = useCallback( - async (payload: CreateMovementPayload) => { - const finalPayload = containsFile(payload) - ? (toFormData(payload) as unknown as CreateMovementPayload) - : payload; + async (payload: CreateMovementPayload, documents: File[] = []) => { + console.log('=== CREATE HANDLER DEBUG ==='); + console.log('1. Received payload:', payload); + console.log('2. Documents count:', documents.length); + + let finalPayload: CreateMovementPayload | FormData; + + if (documents.length > 0) { + // Ada dokumen: kirim sebagai FormData dengan "data" field + console.log('3. Creating FormData (has documents)'); + const formData = new FormData(); + formData.append('data', JSON.stringify(payload)); + documents.forEach((file, index) => { + formData.append(`documents[${index}]`, file); + }); + + console.log('4. FormData entries:'); + for (const [key, value] of formData.entries()) { + if (value instanceof File) { + console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); + } else { + console.log(` ${key}: ${value}`); + } + } + + finalPayload = formData as unknown as CreateMovementPayload; + } else { + // Tidak ada dokumen: kirim sebagai JSON biasa + console.log('3. Sending as JSON (no documents)'); + console.log('4. Payload:', JSON.stringify(payload, null, 2)); + finalPayload = payload; + } + + console.log('=== END CREATE HANDLER DEBUG ==='); const res = await MovementApi.create(finalPayload); if (isResponseError(res)) { + console.error('API Error:', res); setMovementFormErrorMessage(res.message); return; } @@ -34,13 +64,45 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { ); const updateMovementHandler = useCallback( - async (movementId: number, payload: UpdateMovementPayload) => { - const finalPayload = containsFile(payload) - ? (toFormData(payload) as unknown as UpdateMovementPayload) - : payload; + async (movementId: number, payload: UpdateMovementPayload, documents: File[] = []) => { + console.log('=== UPDATE HANDLER DEBUG ==='); + console.log('1. Received payload:', payload); + console.log('2. Movement ID:', movementId); + console.log('3. Documents count:', documents.length); + + let finalPayload: UpdateMovementPayload | FormData; + + if (documents.length > 0) { + // Ada dokumen: kirim sebagai FormData dengan "data" field + console.log('4. Creating FormData (has documents)'); + const formData = new FormData(); + formData.append('data', JSON.stringify(payload)); + documents.forEach((file, index) => { + formData.append(`documents[${index}]`, file); + }); + + console.log('5. FormData entries:'); + for (const [key, value] of formData.entries()) { + if (value instanceof File) { + console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); + } else { + console.log(` ${key}: ${value}`); + } + } + + finalPayload = formData as unknown as UpdateMovementPayload; + } else { + // Tidak ada dokumen: kirim sebagai JSON biasa + console.log('4. Sending as JSON (no documents)'); + console.log('5. Payload:', JSON.stringify(payload, null, 2)); + finalPayload = payload; + } + + console.log('=== END UPDATE HANDLER DEBUG ==='); const res = await MovementApi.update(movementId, finalPayload); if (res?.status === 'error') { + console.error('API Error:', res); setMovementFormErrorMessage(res.message); return; } diff --git a/src/components/table/TableRowOptions.tsx b/src/components/table/TableRowOptions.tsx index 61332b4f..e34f2ad4 100644 --- a/src/components/table/TableRowOptions.tsx +++ b/src/components/table/TableRowOptions.tsx @@ -7,6 +7,7 @@ interface TableRowOptionsProps { recordId: string | number; basePath: string; onDelete?: () => void; + queryParam?: string; } export const TableRowOptions = ({ @@ -14,6 +15,7 @@ export const TableRowOptions = ({ recordId, basePath, onDelete, + queryParam = 'id', }: TableRowOptionsProps) => (
{onDelete && ( @@ -51,9 +53,10 @@ export const TableRowOptions = ({ className='text-error hover:text-inherit justify-start text-sm' > Delete diff --git a/src/services/api/inventory.ts b/src/services/api/inventory.ts index cf799442..ec58f6f2 100644 --- a/src/services/api/inventory.ts +++ b/src/services/api/inventory.ts @@ -1,4 +1,9 @@ import { BaseApiService } from '@/services/api/base'; +import { + CreateProductWarehousePayload, + ProductWarehouse, + UpdateProductWarehousePayload, +} from '@/types/api/inventory/product-warehouse'; import { CreateMovementPayload, Movement, @@ -9,11 +14,17 @@ import { InventoryAdjustment, } from '@/types/api/inventory/adjustment'; +export const ProductWarehouseApi = new BaseApiService< + ProductWarehouse, + CreateProductWarehousePayload, + UpdateProductWarehousePayload +>('/inventory/product-warehouses'); + export const MovementApi = new BaseApiService< Movement, CreateMovementPayload, UpdateMovementPayload ->('/inventory/movements'); +>('/inventory/transfers'); export const inventoryAdjustmentApi = new BaseApiService< InventoryAdjustment, diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index 11da41a5..9e156a1e 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -1,5 +1,4 @@ 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'; @@ -9,20 +8,27 @@ export type BaseMovement = { transfer_date: string; source_warehouse: Warehouse; destination_warehouse: Warehouse; - products: { - product: Product; - product_qty: number; + details: { + id: number; + product_id: number; + quantity: number; + before_quantity: number; + after_quantity: number; }[]; deliveries: { - delivery_cost: number; - delivery_cost_per_item: number; - document: string; - driver_name: string; - vehicle_plate: string; + id: number; + supplier_id: number; supplier: Supplier; - products: { - product: Product; - product_qty: number; + vehicle_plate: string; + driver_name: string; + document_number: string; + document_path: string; + shipping_cost_item: number; + shipping_cost_total: number; + items: { + id: number; + stock_transfer_detail_id: number; + quantity: number; }[]; }[]; }; @@ -41,7 +47,7 @@ export type CreateMovementPayload = { deliveries: { delivery_cost: number; delivery_cost_per_item: number; - document: string | File; + document_index?: number; driver_name: string; vehicle_plate: string; supplier_id: number; diff --git a/src/types/api/inventory/product-warehouse.d.ts b/src/types/api/inventory/product-warehouse.d.ts new file mode 100644 index 00000000..eda8d1b8 --- /dev/null +++ b/src/types/api/inventory/product-warehouse.d.ts @@ -0,0 +1,22 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { Warehouse } from '@/types/api/master-data/warehouse'; +import { Product } from '@/types/api/master-data/product'; + +export type BaseProductWarehouse = { + id: number; + product_id: number; + warehouse_id: number; + quantity: number; + product: Product; + warehouse: Warehouse; +}; + +export type ProductWarehouse = BaseMetadata & BaseProductWarehouse; + +export type CreateProductWarehousePayload = { + product_id: number; + warehouse_id: number; + quantity: number; +}; + +export type UpdateProductWarehousePayload = CreateProductWarehousePayload; From f5ce898bd2f7ee07e0f12573d7f0fa514cead015 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 15:29:26 +0700 Subject: [PATCH 08/16] feat(FE-62,63,65): enhance MovementForm with product warehouse selection, delivery document handling, and stock validation --- .../inventory/movement/form/MovementForm.tsx | 239 ++++++++++++++---- .../movement/form/useMovementFormHandlers.ts | 18 +- 2 files changed, 197 insertions(+), 60 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 1af77cf7..898a1d56 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -25,11 +25,8 @@ import { DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; -import { - ProductApi, - SupplierApi, - WarehouseApi, -} from '@/services/api/master-data'; +import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; +import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; @@ -40,6 +37,10 @@ interface MovementFormProps { const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [, setMovementFormErrorMessage] = useState(''); + const [ + productWarehouseSelectInputValue, + setProductWarehouseSelectInputValue, + ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); @@ -67,7 +68,43 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { validateOnMount: false, enableReinitialize: true, onSubmit: async (values) => { + console.log('=== FORM SUBMIT DEBUG ==='); + console.log('1. Form values received:', values); + setMovementFormErrorMessage(''); + const documents: File[] = []; + const deliveriesPayload = values.deliveries.map((d, idx) => { + let documentIndex = 0; + + console.log(`2. Processing delivery ${idx}:`, { + driver_name: d.driver_name, + document: d.document, + documentType: d.document instanceof File ? 'File' : typeof d.document, + documentSize: d.document instanceof File ? d.document.size : 'N/A', + }); + + if (d.document && d.document instanceof File) { + documents.push(d.document); + documentIndex = documents.length - 1; + console.log(` → Document added at index ${documentIndex}`); + } else { + console.log(` → No document for delivery ${idx}, using index 0`); + } + + return { + delivery_cost: d.delivery_cost, + delivery_cost_per_item: d.delivery_cost_per_item ?? 0, + document_index: documentIndex, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier_id: d.supplier_id, + products: d.products.map((p) => ({ + product_id: p.product_id, + product_qty: p.product_qty, + })), + }; + }); + const payload: CreateMovementPayload = { transfer_reason: values.transfer_reason, transfer_date: values.transfer_date, @@ -77,26 +114,34 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { product_id: p.product_id, product_qty: p.product_qty, })), - deliveries: values.deliveries.map((d) => ({ - delivery_cost: d.delivery_cost, - delivery_cost_per_item: d.delivery_cost_per_item ?? 0, - document: d.document instanceof File ? d.document : d.document, - driver_name: d.driver_name, - vehicle_plate: d.vehicle_plate, - supplier_id: d.supplier_id, - products: d.products.map((p) => ({ - product_id: p.product_id, - product_qty: p.product_qty, - })), - })), + deliveries: deliveriesPayload, }; + console.log('3. Final payload structure:', { + ...payload, + }); + + console.log( + '4. Document indices in deliveries:', + deliveriesPayload.map((d, i) => ({ + delivery: i, + document_index: d.document_index, + })) + ); + + console.log('5. Total documents:', documents.length); + console.log('=== END SUBMIT DEBUG ==='); + switch (type) { case 'add': - await createMovementHandler(payload); + await createMovementHandler(payload, documents); break; case 'edit': - await updateMovementHandler(initialValues?.id as number, payload); + await updateMovementHandler( + initialValues?.id as number, + payload, + documents + ); break; } }, @@ -144,7 +189,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { delivery_cost: 0, delivery_cost_per_item: 0, - document: '', + document: null, driver_name: '', vehicle_plate: '', supplier: null, @@ -265,15 +310,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { })) : []; - // 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 })) + // Product Warehouse selection - Filter by source warehouse + const productWarehouseParams = new URLSearchParams({ + search: productWarehouseSelectInputValue, + }); + if (formik.values.source_warehouse_id) { + productWarehouseParams.append( + 'warehouse_id', + formik.values.source_warehouse_id.toString() + ); + } + const productWarehousesUrl = `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`; + const { data: productWarehouses, isLoading: isLoadingProductWarehouses } = + useSWR( + formik.values.source_warehouse_id ? productWarehousesUrl : null, + ProductWarehouseApi.getAllFetcher + ); + const productWarehouseOptions = isResponseSuccess(productWarehouses) + ? productWarehouses?.data.map((pw) => ({ + value: pw.id, + label: pw.product.name, + product_id: pw.product.id, + warehouse_id: pw.warehouse.id, + warehouse_name: pw.warehouse.name, + quantity: pw.quantity, + })) : []; // Supplier selection @@ -303,7 +364,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }); }, [formik.values.deliveries]); - const getFilteredProductOptions = useCallback(() => { + useEffect(() => { + if (formik.values.source_warehouse_id && type !== 'edit') { + formik.setFieldValue('products', []); + formik.setFieldValue('deliveries', []); + } + }, [formik.values.source_warehouse_id]); + + const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products ?.filter((p) => p.product) @@ -314,6 +382,33 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); }, [formik.values.products]); + const getAvailableStock = useCallback( + (productId: number) => { + const productWarehouse = productWarehouseOptions.find( + (pw) => pw.product_id === productId + ); + return productWarehouse?.quantity ?? 0; + }, + [productWarehouseOptions] + ); + + const getProductQtyError = useCallback( + (productIdx: number) => { + const product = formik.values.products?.[productIdx]; + if (!product || !product.product_id) return null; + + const availableStock = getAvailableStock(product.product_id); + const requestedQty = Number(product.product_qty) || 0; + + if (requestedQty > availableStock) { + return `Qty melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`; + } + + return null; + }, + [formik.values.products, getAvailableStock] + ); + const validateDeliveryQty = useCallback( (deliveryIdx: number, deliveryProductIdx: number, qty: number) => { const delivery = formik.values.deliveries?.[deliveryIdx]; @@ -406,6 +501,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [invalidQtyRows] ); + const hasExceededStock = useMemo(() => { + return ( + formik.values.products?.some((product, idx) => { + return getProductQtyError(idx) !== null; + }) ?? false + ); + }, [formik.values.products, getProductQtyError]); + return ( <>
@@ -656,10 +759,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { (val as OptionType)?.value ); }} - options={productOptions} - onInputChange={setProductSelectInputValue} - isLoading={isLoadingProducts} - isDisabled={type === 'detail'} + options={productWarehouseOptions} + onInputChange={setProductWarehouseSelectInputValue} + isLoading={isLoadingProductWarehouses} + isDisabled={ + type === 'detail' || + !formik.values.source_warehouse_id + } + placeholder={ + !formik.values.source_warehouse_id + ? 'Pilih gudang asal terlebih dahulu' + : 'Pilih produk' + } isClearable {...isRepeaterInputError( 'products', @@ -669,23 +780,46 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { />
{type !== 'detail' && (
- + + {product.product_id && ( +
+ + Stok tersedia: + {' '} + {getAvailableStock( + product.product_id + ).toLocaleString('id-ID')} +
)} - readOnly={type === 'detail'} - className={{ - wrapper: 'w-full min-w-24', - }} - /> +
@@ -819,7 +953,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { (val as OptionType)?.value ); }} - options={getFilteredProductOptions()} + options={getFilteredProductWarehouseOptions()} isDisabled={type === 'detail'} isClearable /> @@ -886,7 +1020,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { const file = e.target.files?.[0]; @@ -1016,7 +1149,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { : undefined } onDelete={deleteMovementClickHandler} - disableSubmit={hasInvalidQty} + disableSubmit={hasInvalidQty || hasExceededStock} /> {movementFormErrorMessage && ( diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts index 1894b1a7..3f46b71a 100644 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -24,7 +24,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { let finalPayload: CreateMovementPayload | FormData; if (documents.length > 0) { - // Ada dokumen: kirim sebagai FormData dengan "data" field console.log('3. Creating FormData (has documents)'); const formData = new FormData(); formData.append('data', JSON.stringify(payload)); @@ -35,7 +34,9 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { console.log('4. FormData entries:'); for (const [key, value] of formData.entries()) { if (value instanceof File) { - console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); + console.log( + ` ${key}: [File] ${value.name} (${value.size} bytes)` + ); } else { console.log(` ${key}: ${value}`); } @@ -43,7 +44,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { finalPayload = formData as unknown as CreateMovementPayload; } else { - // Tidak ada dokumen: kirim sebagai JSON biasa console.log('3. Sending as JSON (no documents)'); console.log('4. Payload:', JSON.stringify(payload, null, 2)); finalPayload = payload; @@ -64,7 +64,11 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { ); const updateMovementHandler = useCallback( - async (movementId: number, payload: UpdateMovementPayload, documents: File[] = []) => { + async ( + movementId: number, + payload: UpdateMovementPayload, + documents: File[] = [] + ) => { console.log('=== UPDATE HANDLER DEBUG ==='); console.log('1. Received payload:', payload); console.log('2. Movement ID:', movementId); @@ -73,7 +77,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { let finalPayload: UpdateMovementPayload | FormData; if (documents.length > 0) { - // Ada dokumen: kirim sebagai FormData dengan "data" field console.log('4. Creating FormData (has documents)'); const formData = new FormData(); formData.append('data', JSON.stringify(payload)); @@ -84,7 +87,9 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { console.log('5. FormData entries:'); for (const [key, value] of formData.entries()) { if (value instanceof File) { - console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); + console.log( + ` ${key}: [File] ${value.name} (${value.size} bytes)` + ); } else { console.log(` ${key}: ${value}`); } @@ -92,7 +97,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { finalPayload = formData as unknown as UpdateMovementPayload; } else { - // Tidak ada dokumen: kirim sebagai JSON biasa console.log('4. Sending as JSON (no documents)'); console.log('5. Payload:', JSON.stringify(payload, null, 2)); finalPayload = payload; From 157dfc75ed4317df8b5b1118c7cdbe701342c1a4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 15:47:29 +0700 Subject: [PATCH 09/16] refactor(FE-63): remove debug logging from form submission and movement handlers --- .../inventory/movement/form/MovementForm.tsx | 27 ------------ .../movement/form/useMovementFormHandlers.ts | 42 ------------------- src/services/http/client.ts | 5 ++- 3 files changed, 4 insertions(+), 70 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 898a1d56..a6a75394 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -68,27 +68,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { validateOnMount: false, enableReinitialize: true, onSubmit: async (values) => { - console.log('=== FORM SUBMIT DEBUG ==='); - console.log('1. Form values received:', values); - setMovementFormErrorMessage(''); const documents: File[] = []; const deliveriesPayload = values.deliveries.map((d, idx) => { let documentIndex = 0; - console.log(`2. Processing delivery ${idx}:`, { - driver_name: d.driver_name, - document: d.document, - documentType: d.document instanceof File ? 'File' : typeof d.document, - documentSize: d.document instanceof File ? d.document.size : 'N/A', - }); - if (d.document && d.document instanceof File) { documents.push(d.document); documentIndex = documents.length - 1; - console.log(` → Document added at index ${documentIndex}`); } else { - console.log(` → No document for delivery ${idx}, using index 0`); } return { @@ -117,21 +105,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { deliveries: deliveriesPayload, }; - console.log('3. Final payload structure:', { - ...payload, - }); - - console.log( - '4. Document indices in deliveries:', - deliveriesPayload.map((d, i) => ({ - delivery: i, - document_index: d.document_index, - })) - ); - - console.log('5. Total documents:', documents.length); - console.log('=== END SUBMIT DEBUG ==='); - switch (type) { case 'add': await createMovementHandler(payload, documents); diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts index 3f46b71a..5c3d80d1 100644 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -17,40 +17,20 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { const createMovementHandler = useCallback( async (payload: CreateMovementPayload, documents: File[] = []) => { - console.log('=== CREATE HANDLER DEBUG ==='); - console.log('1. Received payload:', payload); - console.log('2. Documents count:', documents.length); - let finalPayload: CreateMovementPayload | FormData; if (documents.length > 0) { - console.log('3. Creating FormData (has documents)'); const formData = new FormData(); formData.append('data', JSON.stringify(payload)); documents.forEach((file, index) => { formData.append(`documents[${index}]`, file); }); - console.log('4. FormData entries:'); - for (const [key, value] of formData.entries()) { - if (value instanceof File) { - console.log( - ` ${key}: [File] ${value.name} (${value.size} bytes)` - ); - } else { - console.log(` ${key}: ${value}`); - } - } - finalPayload = formData as unknown as CreateMovementPayload; } else { - console.log('3. Sending as JSON (no documents)'); - console.log('4. Payload:', JSON.stringify(payload, null, 2)); finalPayload = payload; } - console.log('=== END CREATE HANDLER DEBUG ==='); - const res = await MovementApi.create(finalPayload); if (isResponseError(res)) { console.error('API Error:', res); @@ -69,44 +49,22 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { payload: UpdateMovementPayload, documents: File[] = [] ) => { - console.log('=== UPDATE HANDLER DEBUG ==='); - console.log('1. Received payload:', payload); - console.log('2. Movement ID:', movementId); - console.log('3. Documents count:', documents.length); - let finalPayload: UpdateMovementPayload | FormData; if (documents.length > 0) { - console.log('4. Creating FormData (has documents)'); const formData = new FormData(); formData.append('data', JSON.stringify(payload)); documents.forEach((file, index) => { formData.append(`documents[${index}]`, file); }); - console.log('5. FormData entries:'); - for (const [key, value] of formData.entries()) { - if (value instanceof File) { - console.log( - ` ${key}: [File] ${value.name} (${value.size} bytes)` - ); - } else { - console.log(` ${key}: ${value}`); - } - } - finalPayload = formData as unknown as UpdateMovementPayload; } else { - console.log('4. Sending as JSON (no documents)'); - console.log('5. Payload:', JSON.stringify(payload, null, 2)); finalPayload = payload; } - console.log('=== END UPDATE HANDLER DEBUG ==='); - const res = await MovementApi.update(movementId, finalPayload); if (res?.status === 'error') { - console.error('API Error:', res); setMovementFormErrorMessage(res.message); return; } diff --git a/src/services/http/client.ts b/src/services/http/client.ts index adba75e9..9dd382ca 100644 --- a/src/services/http/client.ts +++ b/src/services/http/client.ts @@ -14,6 +14,9 @@ export async function httpClient( (!opts.auth && opts.auth !== 'none' && opts.auth !== 'bearer'); const isBearerAuth = opts.auth === 'bearer' && !!opts.token; + const isFormData = + typeof FormData !== 'undefined' && opts.body instanceof FormData; + const config: AxiosRequestConfig = { url: path, method: opts.method ?? 'GET', @@ -22,7 +25,7 @@ export async function httpClient( timeout: opts.timeoutMs ?? 10_000, withCredentials: isCookieAuth && !isBearerAuth, headers: { - 'Content-Type': 'application/json', + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...(opts.headers ?? {}), ...(isBearerAuth && !isCookieAuth ? { Authorization: `Bearer ${opts.token}` } From a2a57f758c0c7744dd9431aa85dc3e018bf8d5d2 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 16:24:42 +0700 Subject: [PATCH 10/16] refactor(FE-63,63,65): enhance MovementForm to fetch and display product details from ProductWarehouse --- .../movement/form/MovementForm.schema.ts | 96 +++++---- .../inventory/movement/form/MovementForm.tsx | 182 +++++++++++++++++- 2 files changed, 237 insertions(+), 41 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 148a7dce..d5d660ae 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -119,43 +119,61 @@ export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( initialValues?: Movement -): MovementFormValues => ({ - transfer_reason: initialValues?.transfer_reason ?? '', - transfer_date: initialValues?.transfer_date ?? '', - source_warehouse: initialValues?.source_warehouse - ? { - value: initialValues.source_warehouse.id, - label: initialValues.source_warehouse.name, - } - : null, - source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, - destination_warehouse: initialValues?.destination_warehouse - ? { - value: initialValues.destination_warehouse.id, - label: initialValues.destination_warehouse.name, - } - : null, - destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, - products: - initialValues?.details?.map((p) => ({ - product: { value: p.product_id, label: '' }, - product_id: p.product_id, - product_qty: p.quantity, - })) ?? [], - deliveries: - initialValues?.deliveries?.map((d) => ({ - delivery_cost: d.shipping_cost_total, - delivery_cost_per_item: d.shipping_cost_item, - document_index: 0, - document: d.document_path || null, - driver_name: d.driver_name, - vehicle_plate: d.vehicle_plate, - supplier: { value: d.supplier.id, label: d.supplier.name }, - supplier_id: d.supplier_id, - products: d.items.map((p) => ({ - product: { value: 0, label: '' }, - product_id: 0, +): MovementFormValues => { + const detailIdToProductId = new Map(); + initialValues?.details?.forEach((detail) => { + detailIdToProductId.set(detail.id, detail.product_id); + }); + + return { + transfer_reason: initialValues?.transfer_reason ?? '', + transfer_date: initialValues?.transfer_date ?? '', + source_warehouse: initialValues?.source_warehouse + ? { + value: initialValues.source_warehouse.id, + label: initialValues.source_warehouse.name, + } + : null, + source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, + destination_warehouse: initialValues?.destination_warehouse + ? { + value: initialValues.destination_warehouse.id, + label: initialValues.destination_warehouse.name, + } + : null, + destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, + products: + initialValues?.details?.map((p) => ({ + product: { value: p.product_id, label: `Product ID: ${p.product_id}` }, + product_id: p.product_id, product_qty: p.quantity, - })), - })) ?? [], -}); + })) ?? [], + deliveries: + initialValues?.deliveries?.map((d) => { + return { + delivery_cost: d.shipping_cost_total, + delivery_cost_per_item: d.shipping_cost_item, + document_index: 0, + document: d.document_path || null, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier: d.supplier + ? { value: d.supplier.id, label: d.supplier.name } + : null, + supplier_id: d.supplier_id, + products: d.items.map((item) => { + const productId = + detailIdToProductId.get(item.stock_transfer_detail_id) ?? 0; + return { + product: + productId > 0 + ? { value: productId, label: `Product ID: ${productId}` } + : null, + product_id: productId, + product_qty: item.quantity, + }; + }), + }; + }) ?? [], + }; +}; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index a6a75394..adf6658d 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -25,7 +25,11 @@ import { DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; -import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; +import { + SupplierApi, + WarehouseApi, + ProductApi, +} from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; @@ -43,6 +47,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); + const [fetchedProductIds, setFetchedProductIds] = useState>( + new Set() + ); const { deleteModal, @@ -338,12 +345,183 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }, [formik.values.deliveries]); useEffect(() => { - if (formik.values.source_warehouse_id && type !== 'edit') { + if ( + formik.values.source_warehouse_id && + type !== 'edit' && + type !== 'detail' + ) { formik.setFieldValue('products', []); formik.setFieldValue('deliveries', []); } }, [formik.values.source_warehouse_id]); + // Effect to populate product labels from ProductWarehouse data + useEffect(() => { + if (!productWarehouses || !isResponseSuccess(productWarehouses)) return; + if (type !== 'edit' && type !== 'detail') return; + + let hasUpdates = false; + const updatedProducts = formik.values.products?.map((product) => { + if (product.product && product.product.label.startsWith('Product ID:')) { + const productWarehouse = productWarehouses.data.find( + (pw) => pw.product.id === product.product_id + ); + if (productWarehouse) { + hasUpdates = true; + return { + ...product, + product: { + value: productWarehouse.product.id, + label: productWarehouse.product.name, + }, + }; + } + } + return product; + }); + + if (hasUpdates && updatedProducts) { + formik.setFieldValue('products', updatedProducts); + + const updatedDeliveries = formik.values.deliveries?.map((delivery) => { + const updatedDeliveryProducts = delivery.products.map( + (deliveryProduct) => { + if ( + deliveryProduct.product && + deliveryProduct.product.label.startsWith('Product ID:') + ) { + const productWarehouse = productWarehouses.data.find( + (pw) => pw.product.id === deliveryProduct.product_id + ); + if (productWarehouse) { + return { + ...deliveryProduct, + product: { + value: productWarehouse.product.id, + label: productWarehouse.product.name, + }, + }; + } + } + return deliveryProduct; + } + ); + return { + ...delivery, + products: updatedDeliveryProducts, + }; + }); + formik.setFieldValue('deliveries', updatedDeliveries); + } + }, [productWarehouses, type]); + + useEffect(() => { + if (type !== 'edit' && type !== 'detail') return; + + const productIdsToFetch: number[] = []; + + formik.values.products?.forEach((product) => { + if ( + product.product && + product.product.label.startsWith('Product ID:') && + product.product_id > 0 && + !fetchedProductIds.has(product.product_id) + ) { + productIdsToFetch.push(product.product_id); + } + }); + + formik.values.deliveries?.forEach((delivery) => { + delivery.products.forEach((deliveryProduct) => { + if ( + deliveryProduct.product && + deliveryProduct.product.label.startsWith('Product ID:') && + deliveryProduct.product_id > 0 && + !fetchedProductIds.has(deliveryProduct.product_id) + ) { + if (!productIdsToFetch.includes(deliveryProduct.product_id)) { + productIdsToFetch.push(deliveryProduct.product_id); + } + } + }); + }); + + if (productIdsToFetch.length === 0) return; + + const fetchProducts = async () => { + const productMap = new Map(); + const newFetchedIds = new Set(fetchedProductIds); + + for (const productId of productIdsToFetch) { + try { + const response = await ProductApi.getSingle(productId); + if (isResponseSuccess(response)) { + const product = response.data; + productMap.set(product.id, { id: product.id, name: product.name }); + newFetchedIds.add(productId); + } + } catch (error) { + console.error(`Failed to fetch product ${productId}:`, error); + newFetchedIds.add(productId); // Mark as fetched to avoid retry loops + } + } + + if (productMap.size > 0) { + const updatedProducts = formik.values.products?.map((p) => { + const productData = productMap.get(p.product_id); + if (productData) { + return { + ...p, + product: { + value: productData.id, + label: productData.name, + }, + }; + } + return p; + }); + + const updatedDeliveries = formik.values.deliveries?.map((delivery) => { + const updatedDeliveryProducts = delivery.products.map( + (deliveryProduct) => { + const productData = productMap.get(deliveryProduct.product_id); + if (productData) { + return { + ...deliveryProduct, + product: { + value: productData.id, + label: productData.name, + }, + }; + } + return deliveryProduct; + } + ); + return { + ...delivery, + products: updatedDeliveryProducts, + }; + }); + + if (updatedProducts) { + formik.setFieldValue('products', updatedProducts); + } + if (updatedDeliveries) { + formik.setFieldValue('deliveries', updatedDeliveries); + } + } + + setFetchedProductIds(newFetchedIds); + }; + + fetchProducts(); + }, [ + formik.values.products, + formik.values.deliveries, + type, + fetchedProductIds, + ]); + const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products From 501a68267e0bbaa820b64fa819d1c601e568a37a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 16:24:42 +0700 Subject: [PATCH 11/16] refactor(FE-63,63,65): enhance MovementForm to fetch and display product details from ProductWarehouse --- .../movement/form/MovementForm.schema.ts | 96 +++++---- .../inventory/movement/form/MovementForm.tsx | 184 +++++++++++++++++- 2 files changed, 238 insertions(+), 42 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 148a7dce..d5d660ae 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -119,43 +119,61 @@ export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( initialValues?: Movement -): MovementFormValues => ({ - transfer_reason: initialValues?.transfer_reason ?? '', - transfer_date: initialValues?.transfer_date ?? '', - source_warehouse: initialValues?.source_warehouse - ? { - value: initialValues.source_warehouse.id, - label: initialValues.source_warehouse.name, - } - : null, - source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, - destination_warehouse: initialValues?.destination_warehouse - ? { - value: initialValues.destination_warehouse.id, - label: initialValues.destination_warehouse.name, - } - : null, - destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, - products: - initialValues?.details?.map((p) => ({ - product: { value: p.product_id, label: '' }, - product_id: p.product_id, - product_qty: p.quantity, - })) ?? [], - deliveries: - initialValues?.deliveries?.map((d) => ({ - delivery_cost: d.shipping_cost_total, - delivery_cost_per_item: d.shipping_cost_item, - document_index: 0, - document: d.document_path || null, - driver_name: d.driver_name, - vehicle_plate: d.vehicle_plate, - supplier: { value: d.supplier.id, label: d.supplier.name }, - supplier_id: d.supplier_id, - products: d.items.map((p) => ({ - product: { value: 0, label: '' }, - product_id: 0, +): MovementFormValues => { + const detailIdToProductId = new Map(); + initialValues?.details?.forEach((detail) => { + detailIdToProductId.set(detail.id, detail.product_id); + }); + + return { + transfer_reason: initialValues?.transfer_reason ?? '', + transfer_date: initialValues?.transfer_date ?? '', + source_warehouse: initialValues?.source_warehouse + ? { + value: initialValues.source_warehouse.id, + label: initialValues.source_warehouse.name, + } + : null, + source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, + destination_warehouse: initialValues?.destination_warehouse + ? { + value: initialValues.destination_warehouse.id, + label: initialValues.destination_warehouse.name, + } + : null, + destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, + products: + initialValues?.details?.map((p) => ({ + product: { value: p.product_id, label: `Product ID: ${p.product_id}` }, + product_id: p.product_id, product_qty: p.quantity, - })), - })) ?? [], -}); + })) ?? [], + deliveries: + initialValues?.deliveries?.map((d) => { + return { + delivery_cost: d.shipping_cost_total, + delivery_cost_per_item: d.shipping_cost_item, + document_index: 0, + document: d.document_path || null, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier: d.supplier + ? { value: d.supplier.id, label: d.supplier.name } + : null, + supplier_id: d.supplier_id, + products: d.items.map((item) => { + const productId = + detailIdToProductId.get(item.stock_transfer_detail_id) ?? 0; + return { + product: + productId > 0 + ? { value: productId, label: `Product ID: ${productId}` } + : null, + product_id: productId, + product_qty: item.quantity, + }; + }), + }; + }) ?? [], + }; +}; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index a6a75394..5c5138bf 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -25,7 +25,11 @@ import { DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; -import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; +import { + SupplierApi, + WarehouseApi, + ProductApi, +} from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; @@ -43,6 +47,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); + const [fetchedProductIds, setFetchedProductIds] = useState>( + new Set() + ); const { deleteModal, @@ -338,12 +345,183 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }, [formik.values.deliveries]); useEffect(() => { - if (formik.values.source_warehouse_id && type !== 'edit') { + if ( + formik.values.source_warehouse_id && + type !== 'edit' && + type !== 'detail' + ) { formik.setFieldValue('products', []); formik.setFieldValue('deliveries', []); } }, [formik.values.source_warehouse_id]); + // Effect to populate product labels from ProductWarehouse data + useEffect(() => { + if (!productWarehouses || !isResponseSuccess(productWarehouses)) return; + if (type !== 'edit' && type !== 'detail') return; + + let hasUpdates = false; + const updatedProducts = formik.values.products?.map((product) => { + if (product.product && product.product.label.startsWith('Product ID:')) { + const productWarehouse = productWarehouses.data.find( + (pw) => pw.product.id === product.product_id + ); + if (productWarehouse) { + hasUpdates = true; + return { + ...product, + product: { + value: productWarehouse.product.id, + label: productWarehouse.product.name, + }, + }; + } + } + return product; + }); + + if (hasUpdates && updatedProducts) { + formik.setFieldValue('products', updatedProducts); + + const updatedDeliveries = formik.values.deliveries?.map((delivery) => { + const updatedDeliveryProducts = delivery.products.map( + (deliveryProduct) => { + if ( + deliveryProduct.product && + deliveryProduct.product.label.startsWith('Product ID:') + ) { + const productWarehouse = productWarehouses.data.find( + (pw) => pw.product.id === deliveryProduct.product_id + ); + if (productWarehouse) { + return { + ...deliveryProduct, + product: { + value: productWarehouse.product.id, + label: productWarehouse.product.name, + }, + }; + } + } + return deliveryProduct; + } + ); + return { + ...delivery, + products: updatedDeliveryProducts, + }; + }); + formik.setFieldValue('deliveries', updatedDeliveries); + } + }, [productWarehouses, type]); + + useEffect(() => { + if (type !== 'edit' && type !== 'detail') return; + + const productIdsToFetch: number[] = []; + + formik.values.products?.forEach((product) => { + if ( + product.product && + product.product.label.startsWith('Product ID:') && + product.product_id > 0 && + !fetchedProductIds.has(product.product_id) + ) { + productIdsToFetch.push(product.product_id); + } + }); + + formik.values.deliveries?.forEach((delivery) => { + delivery.products.forEach((deliveryProduct) => { + if ( + deliveryProduct.product && + deliveryProduct.product.label.startsWith('Product ID:') && + deliveryProduct.product_id > 0 && + !fetchedProductIds.has(deliveryProduct.product_id) + ) { + if (!productIdsToFetch.includes(deliveryProduct.product_id)) { + productIdsToFetch.push(deliveryProduct.product_id); + } + } + }); + }); + + if (productIdsToFetch.length === 0) return; + + const fetchProducts = async () => { + const productMap = new Map(); + const newFetchedIds = new Set(fetchedProductIds); + + for (const productId of productIdsToFetch) { + try { + const response = await ProductApi.getSingle(productId); + if (isResponseSuccess(response)) { + const product = response.data; + productMap.set(product.id, { id: product.id, name: product.name }); + newFetchedIds.add(productId); + } + } catch (error) { + console.error(`Failed to fetch product ${productId}:`, error); + newFetchedIds.add(productId); // Mark as fetched to avoid retry loops + } + } + + if (productMap.size > 0) { + const updatedProducts = formik.values.products?.map((p) => { + const productData = productMap.get(p.product_id); + if (productData) { + return { + ...p, + product: { + value: productData.id, + label: productData.name, + }, + }; + } + return p; + }); + + const updatedDeliveries = formik.values.deliveries?.map((delivery) => { + const updatedDeliveryProducts = delivery.products.map( + (deliveryProduct) => { + const productData = productMap.get(deliveryProduct.product_id); + if (productData) { + return { + ...deliveryProduct, + product: { + value: productData.id, + label: productData.name, + }, + }; + } + return deliveryProduct; + } + ); + return { + ...delivery, + products: updatedDeliveryProducts, + }; + }); + + if (updatedProducts) { + formik.setFieldValue('products', updatedProducts); + } + if (updatedDeliveries) { + formik.setFieldValue('deliveries', updatedDeliveries); + } + } + + setFetchedProductIds(newFetchedIds); + }; + + fetchProducts(); + }, [ + formik.values.products, + formik.values.deliveries, + type, + fetchedProductIds, + ]); + const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products @@ -484,7 +662,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return ( <> -
+
Date: Thu, 16 Oct 2025 16:46:33 +0700 Subject: [PATCH 12/16] refactor(FE-62,63): update MovementForm to handle 'detail' type with appropriate validations and stock checks --- .../inventory/movement/form/MovementForm.tsx | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 5c5138bf..0483b4c1 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -462,7 +462,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } } catch (error) { console.error(`Failed to fetch product ${productId}:`, error); - newFetchedIds.add(productId); // Mark as fetched to avoid retry loops + newFetchedIds.add(productId); } } @@ -535,16 +535,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const getAvailableStock = useCallback( (productId: number) => { + if (type === 'detail') return 0; const productWarehouse = productWarehouseOptions.find( (pw) => pw.product_id === productId ); return productWarehouse?.quantity ?? 0; }, - [productWarehouseOptions] + [productWarehouseOptions, type] ); const getProductQtyError = useCallback( (productIdx: number) => { + if (type === 'detail') return null; const product = formik.values.products?.[productIdx]; if (!product || !product.product_id) return null; @@ -557,11 +559,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return null; }, - [formik.values.products, getAvailableStock] + [formik.values.products, getAvailableStock, type] ); const validateDeliveryQty = useCallback( (deliveryIdx: number, deliveryProductIdx: number, qty: number) => { + if (type === 'detail') return true; const delivery = formik.values.deliveries?.[deliveryIdx]; if (!delivery) return true; @@ -592,11 +595,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return totalQtyUsed + qty <= Number(relatedProduct.product_qty); }, - [formik.values.deliveries, formik.values.products] + [formik.values.deliveries, formik.values.products, type] ); const getDeliveryQtyError = useCallback( (deliveryIdx: number, deliveryProductIdx: number) => { + if (type === 'detail') return null; const delivery = formik.values.deliveries?.[deliveryIdx]; if (!delivery) return null; @@ -633,32 +637,40 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return null; }, - [formik.values.deliveries, formik.values.products] + [formik.values.deliveries, formik.values.products, type] ); const invalidQtyRows = useMemo( () => - formik.values.deliveries?.flatMap((delivery, deliveryIdx) => - delivery.products.map((product, productIdx) => { - const qty = Number(product.product_qty) || 0; - return !validateDeliveryQty(deliveryIdx, productIdx, qty); - }) - ) ?? [], - [formik.values.deliveries, formik.values.products, validateDeliveryQty] + type === 'detail' + ? [] + : (formik.values.deliveries?.flatMap((delivery, deliveryIdx) => + delivery.products.map((product, productIdx) => { + const qty = Number(product.product_qty) || 0; + return !validateDeliveryQty(deliveryIdx, productIdx, qty); + }) + ) ?? []), + [ + formik.values.deliveries, + formik.values.products, + validateDeliveryQty, + type, + ] ); const hasInvalidQty = useMemo( - () => invalidQtyRows.some(Boolean), - [invalidQtyRows] + () => (type === 'detail' ? false : invalidQtyRows.some(Boolean)), + [invalidQtyRows, type] ); const hasExceededStock = useMemo(() => { + if (type === 'detail') return false; return ( formik.values.products?.some((product, idx) => { return getProductQtyError(idx) !== null; }) ?? false ); - }, [formik.values.products, getProductQtyError]); + }, [formik.values.products, getProductQtyError, type]); return ( <> @@ -960,7 +972,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { wrapper: 'w-full min-w-24', }} /> - {product.product_id && ( + {type !== 'detail' && product.product_id && (
Stok tersedia: From 8c662a51528b404f08cc82744600b2edc2e599f8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 09:22:49 +0700 Subject: [PATCH 13/16] refactor(FE-65): update DeliveryObjectSchema to enforce minimum delivery costs of 1 --- .../pages/inventory/movement/form/MovementForm.schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index d5d660ae..f33888e2 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -58,11 +58,11 @@ const DeliveryProductObjectSchema = Yup.object({ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ delivery_cost: Yup.number() .required('Biaya pengiriman wajib diisi!') - .min(0, 'Biaya minimal 0!') + .min(1, 'Biaya minimal 1!') .typeError('Biaya harus berupa angka!'), delivery_cost_per_item: Yup.number() .transform((value) => (isNaN(value) ? undefined : value)) - .min(0, 'Biaya per item minimal 0!') + .min(1, 'Biaya per item minimal 1!') .typeError('Biaya per item harus berupa angka!'), document_index: Yup.number().optional(), document: Yup.mixed() From 8bf7603f6652cff01dcbb1ca292fb0e7b9eb7903 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 09:46:07 +0700 Subject: [PATCH 14/16] refactor(FE-64): update MovementTable and TableRowOptions to conditionally show edit and delete options --- .../inventory/movement/MovementTable.tsx | 6 +++-- src/components/table/TableRowOptions.tsx | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index e0bc9541..61be40f8 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -157,8 +157,9 @@ const MovementTable = () => { type='dropdown' recordId={props.row.original.id} basePath='/inventory/movement' - onDelete={deleteClickHandler} queryParam='movementId' + showEdit={false} + showDelete={false} /> )} @@ -169,8 +170,9 @@ const MovementTable = () => { type='collapse' recordId={props.row.original.id} basePath='/inventory/movement' - onDelete={deleteClickHandler} queryParam='movementId' + showEdit={false} + showDelete={false} /> )} diff --git a/src/components/table/TableRowOptions.tsx b/src/components/table/TableRowOptions.tsx index e34f2ad4..4e2e2c93 100644 --- a/src/components/table/TableRowOptions.tsx +++ b/src/components/table/TableRowOptions.tsx @@ -8,6 +8,8 @@ interface TableRowOptionsProps { basePath: string; onDelete?: () => void; queryParam?: string; + showEdit?: boolean; + showDelete?: boolean; } export const TableRowOptions = ({ @@ -16,6 +18,8 @@ export const TableRowOptions = ({ basePath, onDelete, queryParam = 'id', + showEdit = true, + showDelete = true, }: TableRowOptionsProps) => (
Detail - - {onDelete && ( + {showEdit && ( + + )} + {showDelete && onDelete && (