mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Merge branch 'feat/FE/refactor-submission-form' into 'development'
[FEAT/FE] Refactor Submission Form (Alert Error and Submit Button) & Adjust Uniformity Page See merge request mbugroup/lti-web-client!145
This commit is contained in:
@@ -85,7 +85,10 @@ const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
product_id: Yup.number().required('Produk wajib diisi!'),
|
||||
product_id: Yup.number()
|
||||
.required('Produk wajib diisi!')
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
.typeError('Produk wajib diisi!'),
|
||||
product_qty: Yup.number()
|
||||
.required('Qty wajib diisi!')
|
||||
.min(1, 'Qty minimal 1!')
|
||||
@@ -97,7 +100,10 @@ const DeliveryProductObjectSchema = Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
product_id: Yup.number().required('Produk wajib diisi!'),
|
||||
product_id: Yup.number()
|
||||
.required('Produk wajib diisi!')
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
.typeError('Produk wajib diisi!'),
|
||||
product_qty: Yup.number()
|
||||
.required('Qty wajib diisi!')
|
||||
.min(1, 'Qty minimal 1!')
|
||||
@@ -127,13 +133,13 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
|
||||
(delivery_cost !== undefined && delivery_cost > 0)
|
||||
);
|
||||
}),
|
||||
document_path: Yup.string().optional(),
|
||||
document_path: Yup.string().nullable().optional(),
|
||||
document_index: Yup.number().optional(),
|
||||
document: Yup.mixed<File | MovementDocument>()
|
||||
.nullable()
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => {
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 2 * 1024 * 1024;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return true;
|
||||
}),
|
||||
driver_name: Yup.string().required('Nama sopir wajib diisi!'),
|
||||
@@ -142,7 +148,10 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
supplier_id: Yup.number().required('Supplier wajib diisi!'),
|
||||
supplier_id: Yup.number()
|
||||
.required('Supplier wajib diisi!')
|
||||
.min(1, 'Supplier wajib diisi!')
|
||||
.typeError('Supplier wajib diisi!'),
|
||||
products: Yup.array()
|
||||
.of(DeliveryProductObjectSchema)
|
||||
.min(1, 'Minimal harus ada 1 produk!')
|
||||
@@ -161,6 +170,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
|
||||
}).nullable(),
|
||||
source_warehouse_id: Yup.number()
|
||||
.required('Gudang asal wajib diisi!')
|
||||
.min(1, 'Gudang asal wajib diisi!')
|
||||
.typeError('Gudang asal wajib diisi!'),
|
||||
destination_warehouse: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
@@ -170,6 +180,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
|
||||
}).nullable(),
|
||||
destination_warehouse_id: Yup.number()
|
||||
.required('Gudang tujuan wajib diisi!')
|
||||
.min(1, 'Gudang tujuan wajib diisi!')
|
||||
.typeError('Gudang tujuan wajib diisi!')
|
||||
.test(
|
||||
'different-warehouse',
|
||||
@@ -226,41 +237,62 @@ export const getMovementFormInitialValues = (
|
||||
}
|
||||
: null,
|
||||
destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0,
|
||||
products:
|
||||
initialValues?.details?.map((detail) => ({
|
||||
product: {
|
||||
value: detail.product.id,
|
||||
label: detail.product.name,
|
||||
},
|
||||
product_id: detail.product.id,
|
||||
product_qty: detail.quantity,
|
||||
})) ?? [],
|
||||
deliveries:
|
||||
initialValues?.deliveries?.map((d) => ({
|
||||
delivery_cost: d.shipping_cost_total ?? undefined,
|
||||
delivery_cost_per_item: d.shipping_cost_item ?? undefined,
|
||||
document_number: d.document_number ?? '',
|
||||
document: d.document ?? null,
|
||||
document_path: 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 ?? 0,
|
||||
products:
|
||||
d.items?.map((item) => {
|
||||
const productData = detailIdToProductId.get(
|
||||
item.stock_transfer_detail_id
|
||||
);
|
||||
return {
|
||||
product: productData
|
||||
? { value: productData.id, label: productData.name }
|
||||
: null,
|
||||
product_id: productData?.id ?? 0,
|
||||
product_qty: item.quantity,
|
||||
};
|
||||
}) ?? [],
|
||||
})) ?? [],
|
||||
products: initialValues?.details?.map((detail) => ({
|
||||
product: {
|
||||
value: detail.product.id,
|
||||
label: detail.product.name,
|
||||
},
|
||||
product_id: detail.product.id,
|
||||
product_qty: detail.quantity,
|
||||
})) ?? [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
],
|
||||
deliveries: initialValues?.deliveries?.map((d) => ({
|
||||
delivery_cost: d.shipping_cost_total ?? undefined,
|
||||
delivery_cost_per_item: d.shipping_cost_item ?? undefined,
|
||||
document: d.document ?? null,
|
||||
document_path: 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 ?? 0,
|
||||
products:
|
||||
d.items?.map((item) => {
|
||||
const productData = detailIdToProductId.get(
|
||||
item.stock_transfer_detail_id
|
||||
);
|
||||
return {
|
||||
product: productData
|
||||
? { value: productData.id, label: productData.name }
|
||||
: null,
|
||||
product_id: productData?.id ?? 0,
|
||||
product_qty: item.quantity,
|
||||
};
|
||||
}) ?? [],
|
||||
})) ?? [
|
||||
{
|
||||
delivery_cost: undefined,
|
||||
delivery_cost_per_item: undefined,
|
||||
document: null,
|
||||
document_path: null,
|
||||
driver_name: '',
|
||||
vehicle_plate: '',
|
||||
supplier: null,
|
||||
supplier_id: 0,
|
||||
products: [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import Badge from '@/components/Badge';
|
||||
import Card from '@/components/Card';
|
||||
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
interface MovementFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -53,6 +55,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
] = useState('');
|
||||
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
|
||||
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
|
||||
// ===== FORM HANDLERS =====
|
||||
const createMovementHandler = useCallback(
|
||||
@@ -761,8 +764,36 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
type !== 'edit' &&
|
||||
type !== 'detail'
|
||||
) {
|
||||
formik.setFieldValue('products', []);
|
||||
formik.setFieldValue('deliveries', []);
|
||||
if (formik.values.products.length === 0) {
|
||||
formik.setFieldValue('products', [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (formik.values.deliveries.length === 0) {
|
||||
formik.setFieldValue('deliveries', [
|
||||
{
|
||||
delivery_cost: undefined,
|
||||
delivery_cost_per_item: undefined,
|
||||
document: null,
|
||||
document_path: null,
|
||||
driver_name: '',
|
||||
vehicle_plate: '',
|
||||
supplier: null,
|
||||
supplier_id: 0,
|
||||
products: [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, [formik.values.source_warehouse_id]);
|
||||
|
||||
@@ -791,6 +822,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
formik.errors.destination_warehouse_id,
|
||||
]);
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full'>
|
||||
@@ -810,10 +857,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
</h1>
|
||||
</header>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{movementFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{movementFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top card - Movement details */}
|
||||
<Card
|
||||
title='Detail Movement'
|
||||
@@ -1097,7 +1163,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Produk
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1106,7 +1172,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Qty
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1119,7 +1185,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
{formik.values.products?.map((product, idx) => (
|
||||
<tr key={`product-row-${idx}-${product.product_id}`}>
|
||||
{type !== 'detail' && (
|
||||
<td className='!align-middle'>
|
||||
<td className='align-middle!'>
|
||||
<CheckboxInput
|
||||
name={`product-${idx}`}
|
||||
checked={selectedProducts.includes(idx)}
|
||||
@@ -1311,7 +1377,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Produk
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1320,7 +1386,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Qty
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1329,7 +1395,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Supplier
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1338,7 +1404,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Plat Nomor
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1348,7 +1414,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Biaya Pengiriman (Rp.)
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1357,7 +1423,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Biaya Per Item (Rp.)
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1366,7 +1432,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Nama Sopir
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1379,7 +1445,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
{formik.values.deliveries?.map((delivery, idx) => (
|
||||
<tr key={`delivery-row-${idx}`}>
|
||||
{type !== 'detail' && (
|
||||
<td className='!align-middle'>
|
||||
<td className='align-middle!'>
|
||||
<CheckboxInput
|
||||
name={`delivery-${idx}`}
|
||||
checked={selectedDeliveries.includes(idx)}
|
||||
@@ -1589,8 +1655,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('Ukuran dokumen maksimal 2 MB!');
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Ukuran dokumen maksimal 5 MB!');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -1747,7 +1813,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
disabled={
|
||||
hasInvalidQty ||
|
||||
hasExceededStock ||
|
||||
!formik.isValid ||
|
||||
formik.isSubmitting ||
|
||||
(formik.values.source_warehouse_id ===
|
||||
formik.values.destination_warehouse_id &&
|
||||
@@ -1760,17 +1825,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{movementFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{movementFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,8 @@ import TextInput from '@/components/input/TextInput';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
ProductCategoryFormSchema,
|
||||
@@ -39,6 +41,7 @@ const ProductCategoryForm = ({
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [formErrorMessage, setFormErrorMessage] = useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const createProductCategoryHandler = useCallback(
|
||||
@@ -129,6 +132,22 @@ const ProductCategoryForm = ({
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-2xl'>
|
||||
@@ -150,10 +169,29 @@ const ProductCategoryForm = ({
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{formErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{formErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<TextInput
|
||||
required
|
||||
@@ -236,7 +274,7 @@ const ProductCategoryForm = ({
|
||||
type='submit'
|
||||
color='primary'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={formik.isSubmitting}
|
||||
className='px-4'
|
||||
>
|
||||
Submit
|
||||
@@ -244,17 +282,6 @@ const ProductCategoryForm = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{formErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -29,36 +29,38 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
|
||||
sku: Yup.string().required('SKU wajib diisi!'),
|
||||
|
||||
uom: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.required('Satuan wajib diisi!'),
|
||||
value: Yup.number()
|
||||
.min(1, 'Satuan wajib dipilih!')
|
||||
.required('Satuan wajib dipilih!'),
|
||||
label: Yup.string().required('Satuan wajib dipilih!'),
|
||||
}).nullable(),
|
||||
|
||||
uom_id: Yup.number()
|
||||
.required('Satuan wajib diisi!')
|
||||
.typeError('Satuan wajib diisi!'),
|
||||
.min(1, 'Satuan wajib dipilih!')
|
||||
.required('Satuan wajib dipilih!')
|
||||
.typeError('Satuan wajib dipilih!'),
|
||||
|
||||
product_category: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.required('Kategori produk wajib diisi!'),
|
||||
value: Yup.number()
|
||||
.min(1, 'Kategori produk wajib dipilih!')
|
||||
.required('Kategori produk wajib dipilih!'),
|
||||
label: Yup.string().required('Kategori produk wajib dipilih!'),
|
||||
}).nullable(),
|
||||
|
||||
product_category_id: Yup.number()
|
||||
.required('Kategori produk wajib diisi!')
|
||||
.typeError('Kategori produk wajib diisi!'),
|
||||
.min(1, 'Kategori produk wajib dipilih!')
|
||||
.required('Kategori produk wajib dipilih!')
|
||||
.typeError('Kategori produk wajib dipilih!'),
|
||||
|
||||
product_price: Yup.number()
|
||||
.required('Harga produk wajib diisi!')
|
||||
.typeError('Harga produk wajib diisi!')
|
||||
.min(0, 'Harga produk tidak boleh kurang dari 0!'),
|
||||
.min(1, 'Harga produk tidak boleh kurang dari 1!'),
|
||||
|
||||
selling_price: Yup.number()
|
||||
.required('Harga jual wajib diisi!')
|
||||
.typeError('Harga jual wajib diisi!')
|
||||
.min(0, 'Harga jual tidak boleh kurang dari 0!'),
|
||||
.min(1, 'Harga jual tidak boleh kurang dari 1!'),
|
||||
|
||||
tax: Yup.number()
|
||||
.required('Pajak wajib diisi!')
|
||||
@@ -69,7 +71,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
|
||||
expiry_period: Yup.number()
|
||||
.required('Periode kadaluarsa wajib diisi!')
|
||||
.typeError('Periode kadaluarsa wajib diisi!')
|
||||
.min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'),
|
||||
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
|
||||
|
||||
supplier_ids: Yup.array()
|
||||
.of(Yup.number().required().typeError('Supplier tidak valid!'))
|
||||
|
||||
@@ -17,6 +17,8 @@ import SelectInput, {
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
ProductFormSchema,
|
||||
@@ -48,6 +50,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [productFormErrorMessage, setProductFormErrorMessage] = useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const createProductHandler = useCallback(
|
||||
@@ -201,6 +204,22 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-2xl'>
|
||||
@@ -220,11 +239,30 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
</h1>
|
||||
</header>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{productFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{productFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
<TextInput
|
||||
required
|
||||
label='Nama'
|
||||
@@ -237,179 +275,193 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
errorMessage={formik.errors.name}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Merek'
|
||||
name='brand'
|
||||
placeholder='Masukkan merek...'
|
||||
value={formik.values.brand}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.brand && Boolean(formik.errors.brand)}
|
||||
errorMessage={formik.errors.brand}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='SKU'
|
||||
name='sku'
|
||||
placeholder='Masukkan SKU...'
|
||||
value={formik.values.sku}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.sku && Boolean(formik.errors.sku)}
|
||||
errorMessage={formik.errors.sku}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Satuan'
|
||||
placeholder='Pilih satuan...'
|
||||
value={formik.values.uom ?? undefined}
|
||||
onChange={uomChangeHandler}
|
||||
options={uomOptions}
|
||||
onInputChange={setUomSelectInputValue}
|
||||
isLoading={isLoadingUoms}
|
||||
isError={formik.touched.uom_id && Boolean(formik.errors.uom_id)}
|
||||
errorMessage={formik.errors.uom_id as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Kategori Produk'
|
||||
placeholder='Pilih kategori produk...'
|
||||
value={formik.values.product_category ?? undefined}
|
||||
onChange={categoryChangeHandler}
|
||||
options={categoryOptions}
|
||||
onInputChange={setCategorySelectInputValue}
|
||||
isLoading={isLoadingCategories}
|
||||
isError={
|
||||
formik.touched.product_category_id &&
|
||||
Boolean(formik.errors.product_category_id)
|
||||
}
|
||||
errorMessage={formik.errors.product_category_id as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Produk'
|
||||
name='product_price'
|
||||
placeholder='Masukkan harga produk...'
|
||||
value={formik.values.product_price}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix='Rp '
|
||||
isError={
|
||||
formik.touched.product_price &&
|
||||
Boolean(formik.errors.product_price)
|
||||
}
|
||||
errorMessage={formik.errors.product_price as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Jual'
|
||||
name='selling_price'
|
||||
placeholder='Masukkan harga jual...'
|
||||
value={formik.values.selling_price}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix='Rp '
|
||||
isError={
|
||||
formik.touched.selling_price &&
|
||||
Boolean(formik.errors.selling_price)
|
||||
}
|
||||
errorMessage={formik.errors.selling_price as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Pajak (%)'
|
||||
name='tax'
|
||||
placeholder='Masukkan pajak...'
|
||||
value={formik.values.tax}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputSuffix='%'
|
||||
isError={formik.touched.tax && Boolean(formik.errors.tax)}
|
||||
errorMessage={formik.errors.tax as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Periode Kadaluarsa (hari)'
|
||||
name='expiry_period'
|
||||
placeholder='Masukkan periode kadaluarsa...'
|
||||
value={formik.values.expiry_period}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={0}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputSuffix='hari'
|
||||
isError={
|
||||
formik.touched.expiry_period &&
|
||||
Boolean(formik.errors.expiry_period)
|
||||
}
|
||||
errorMessage={formik.errors.expiry_period as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Supplier'
|
||||
placeholder='Pilih supplier...'
|
||||
isMulti
|
||||
value={supplierOptions.filter((opt) =>
|
||||
(formik.values.supplier_ids || []).includes(opt.value)
|
||||
)}
|
||||
onChange={supplierChangeHandler}
|
||||
options={supplierOptions}
|
||||
onInputChange={setSupplierSelectInputValue}
|
||||
isLoading={isLoadingSuppliers}
|
||||
isError={
|
||||
formik.touched.supplier_ids &&
|
||||
Boolean(formik.errors.supplier_ids)
|
||||
}
|
||||
errorMessage={formik.errors.supplier_ids as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Flags'
|
||||
placeholder='Pilih flags...'
|
||||
isMulti
|
||||
value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
|
||||
(formik.values.flags || []).includes(opt.value)
|
||||
)}
|
||||
onChange={(val) => {
|
||||
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||
formik.setFieldValue(
|
||||
'flags',
|
||||
arr.map((v) => (v as OptionType).value)
|
||||
);
|
||||
}}
|
||||
options={PRODUCT_FLAG_OPTIONS}
|
||||
isError={formik.touched.flags && Boolean(formik.errors.flags)}
|
||||
errorMessage={formik.errors.flags as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<TextInput
|
||||
required
|
||||
label='Merek'
|
||||
name='brand'
|
||||
placeholder='Masukkan merek...'
|
||||
value={formik.values.brand}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.brand && Boolean(formik.errors.brand)}
|
||||
errorMessage={formik.errors.brand}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='SKU'
|
||||
name='sku'
|
||||
placeholder='Masukkan SKU...'
|
||||
value={formik.values.sku}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.sku && Boolean(formik.errors.sku)}
|
||||
errorMessage={formik.errors.sku}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<SelectInput
|
||||
required
|
||||
label='Satuan'
|
||||
placeholder='Pilih satuan...'
|
||||
value={formik.values.uom ?? undefined}
|
||||
onChange={uomChangeHandler}
|
||||
options={uomOptions}
|
||||
onInputChange={setUomSelectInputValue}
|
||||
isLoading={isLoadingUoms}
|
||||
isError={
|
||||
(formik.touched.uom || formik.touched.uom_id) &&
|
||||
Boolean(formik.errors.uom_id)
|
||||
}
|
||||
errorMessage={formik.errors.uom_id as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Kategori Produk'
|
||||
placeholder='Pilih kategori produk...'
|
||||
value={formik.values.product_category ?? undefined}
|
||||
onChange={categoryChangeHandler}
|
||||
options={categoryOptions}
|
||||
onInputChange={setCategorySelectInputValue}
|
||||
isLoading={isLoadingCategories}
|
||||
isError={
|
||||
(formik.touched.product_category ||
|
||||
formik.touched.product_category_id) &&
|
||||
Boolean(formik.errors.product_category_id)
|
||||
}
|
||||
errorMessage={formik.errors.product_category_id as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Produk'
|
||||
name='product_price'
|
||||
placeholder='Masukkan harga produk...'
|
||||
value={formik.values.product_price}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix='Rp '
|
||||
isError={
|
||||
formik.touched.product_price &&
|
||||
Boolean(formik.errors.product_price)
|
||||
}
|
||||
errorMessage={formik.errors.product_price as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Jual'
|
||||
name='selling_price'
|
||||
placeholder='Masukkan harga jual...'
|
||||
value={formik.values.selling_price}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix='Rp '
|
||||
isError={
|
||||
formik.touched.selling_price &&
|
||||
Boolean(formik.errors.selling_price)
|
||||
}
|
||||
errorMessage={formik.errors.selling_price as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Pajak (%)'
|
||||
name='tax'
|
||||
placeholder='Masukkan pajak...'
|
||||
value={formik.values.tax}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputSuffix='%'
|
||||
isError={formik.touched.tax && Boolean(formik.errors.tax)}
|
||||
errorMessage={formik.errors.tax as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Periode Kadaluarsa (hari)'
|
||||
name='expiry_period'
|
||||
placeholder='Masukkan periode kadaluarsa...'
|
||||
value={formik.values.expiry_period}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={0}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputSuffix='hari'
|
||||
isError={
|
||||
formik.touched.expiry_period &&
|
||||
Boolean(formik.errors.expiry_period)
|
||||
}
|
||||
errorMessage={formik.errors.expiry_period as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<SelectInput
|
||||
required
|
||||
label='Supplier'
|
||||
placeholder='Pilih supplier...'
|
||||
isMulti
|
||||
value={supplierOptions.filter((opt) =>
|
||||
(formik.values.supplier_ids || []).includes(opt.value)
|
||||
)}
|
||||
onChange={supplierChangeHandler}
|
||||
options={supplierOptions}
|
||||
onInputChange={setSupplierSelectInputValue}
|
||||
isLoading={isLoadingSuppliers}
|
||||
isError={
|
||||
formik.touched.supplier_ids &&
|
||||
Boolean(formik.errors.supplier_ids)
|
||||
}
|
||||
errorMessage={formik.errors.supplier_ids as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Flags'
|
||||
placeholder='Pilih flags...'
|
||||
isMulti
|
||||
value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
|
||||
(formik.values.flags || []).includes(opt.value)
|
||||
)}
|
||||
onChange={(val) => {
|
||||
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||
formik.setFieldValue(
|
||||
'flags',
|
||||
arr.map((v) => (v as OptionType).value)
|
||||
);
|
||||
}}
|
||||
options={PRODUCT_FLAG_OPTIONS}
|
||||
isError={formik.touched.flags && Boolean(formik.errors.flags)}
|
||||
errorMessage={formik.errors.flags as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
{type !== 'add' && (
|
||||
@@ -463,7 +515,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
type='submit'
|
||||
color='primary'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={formik.isSubmitting}
|
||||
className='px-4'
|
||||
>
|
||||
Submit
|
||||
@@ -471,16 +523,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{productFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{productFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
{type !== 'add' && (
|
||||
|
||||
@@ -17,6 +17,7 @@ import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
ProjectFlockKandangApi,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
|
||||
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
|
||||
import { formatDate, formatNumber } from '@/lib/helper';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import toast from 'react-hot-toast';
|
||||
import ApprovalSteps, {
|
||||
useApprovalSteps,
|
||||
@@ -60,7 +62,6 @@ import {
|
||||
GROWING_RECORDING_APPROVAL_LINE,
|
||||
LAYING_RECORDING_APPROVAL_LINE,
|
||||
} from '@/config/approval-line';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
interface RecordingFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -92,6 +93,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const [, setApprovalNotes] = useState('');
|
||||
const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [, setNewRecordingData] = useState<Recording | null>(null);
|
||||
const [nextDayRecording, setNextDayRecording] =
|
||||
@@ -758,6 +760,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== HELPER FUNCTIONS =====
|
||||
useCallback((): OptionType | null => {
|
||||
if (
|
||||
@@ -1323,9 +1341,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{recordingFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{recordingFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Basic Info Card */}
|
||||
{(type === 'add' || type === 'edit') && (
|
||||
<Card
|
||||
@@ -2507,9 +2544,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
color='primary'
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={
|
||||
hasExceededStock || !formik.isValid || formik.isSubmitting
|
||||
}
|
||||
disabled={hasExceededStock || formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
@@ -2534,9 +2569,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
color='primary'
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={
|
||||
hasExceededStock || !formik.isValid || formik.isSubmitting
|
||||
}
|
||||
disabled={hasExceededStock || formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
@@ -2544,16 +2577,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recordingFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{recordingFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,93 +1,86 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import Card from '@/components/Card';
|
||||
import UniformityBarChart from '@/components/pages/production/uniformity/chart/UniformityBarChart';
|
||||
import UniformityGaugeChart from '@/components/pages/production/uniformity/chart/UniformityGaugeChart';
|
||||
import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton';
|
||||
import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton';
|
||||
import {
|
||||
UniformityDetailItem,
|
||||
Uniformity,
|
||||
} from '@/types/api/production/uniformity';
|
||||
import { Uniformity, type ChartData } from '@/types/api/production/uniformity';
|
||||
|
||||
interface UniformityChartProps {
|
||||
uniformityData?: Uniformity | null;
|
||||
uniformityDetails?: UniformityDetailItem[];
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
const UniformityChart = ({
|
||||
uniformityData,
|
||||
uniformityDetails,
|
||||
isFiltered = false,
|
||||
}: UniformityChartProps) => {
|
||||
const defaultUniformityDetails: UniformityDetailItem[] = [
|
||||
{ id: 1, weight: 61, range: 'Ideal' },
|
||||
{ id: 2, weight: 62, range: 'Ideal' },
|
||||
{ id: 3, weight: 63, range: 'Ideal' },
|
||||
{ id: 4, weight: 64, range: 'Ideal' },
|
||||
{ id: 5, weight: 65, range: 'Ideal' },
|
||||
{ id: 6, weight: 66, range: 'Ideal' },
|
||||
{ id: 7, weight: 67, range: 'Ideal' },
|
||||
];
|
||||
const [currentWeekIndex, setCurrentWeekIndex] = useState(0);
|
||||
|
||||
const detailsToUse = uniformityDetails || defaultUniformityDetails;
|
||||
const chartData = useMemo((): ChartData | undefined => {
|
||||
if (!uniformityData?.chart_data) return undefined;
|
||||
return uniformityData.chart_data;
|
||||
}, [uniformityData]);
|
||||
|
||||
const barChartData = useMemo(() => {
|
||||
if (!uniformityData) {
|
||||
if (!chartData?.bar_chart) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!detailsToUse || detailsToUse.length === 0) {
|
||||
const { bar_chart } = chartData;
|
||||
const currentWeekStr = String(bar_chart.current_week);
|
||||
const weekData = bar_chart.all_weeks[currentWeekStr];
|
||||
|
||||
if (!weekData || !weekData.has_data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const weights = detailsToUse.map((d) => d.weight);
|
||||
const minWeight = Math.floor(Math.min(...weights) / 5) * 5;
|
||||
const maxWeight = Math.ceil(Math.max(...weights) / 5) * 5;
|
||||
|
||||
const rangeSize = maxWeight - minWeight < 11 ? 4 : 5;
|
||||
const ranges: string[] = [];
|
||||
|
||||
for (let start = minWeight; start <= maxWeight; start += rangeSize) {
|
||||
const end = start + rangeSize;
|
||||
ranges.push(`${start}-${end}`);
|
||||
}
|
||||
|
||||
const totalIdealCount = detailsToUse.filter(
|
||||
(d) => d.range === 'Ideal'
|
||||
).length;
|
||||
|
||||
return ranges.map((range) => {
|
||||
const [minStr, maxStr] = range.split('-').map(Number);
|
||||
const min = minStr;
|
||||
const max = maxStr;
|
||||
|
||||
const birdsInRange = detailsToUse.filter(
|
||||
(d) => d.weight >= min && d.weight < max
|
||||
).length;
|
||||
|
||||
const hasIdeal = detailsToUse.some(
|
||||
(d) => d.range === 'Ideal' && d.weight >= min && d.weight < max
|
||||
);
|
||||
|
||||
return {
|
||||
name: range,
|
||||
uv: birdsInRange,
|
||||
isIdeal: hasIdeal,
|
||||
idealCount: hasIdeal ? totalIdealCount : undefined,
|
||||
};
|
||||
});
|
||||
}, [uniformityData, detailsToUse]);
|
||||
return weekData.weight_distribution.map((range) => ({
|
||||
name: range.range,
|
||||
uv: range.bird_count,
|
||||
isIdeal: range.is_ideal_range,
|
||||
idealCount: range.is_ideal_range
|
||||
? weekData.ideal_range.total_ideal_birds
|
||||
: undefined,
|
||||
}));
|
||||
}, [chartData]);
|
||||
|
||||
const gaugeChartData = useMemo(() => {
|
||||
if (!uniformityData) return undefined;
|
||||
if (!chartData?.gauge_chart || !uniformityData) return undefined;
|
||||
|
||||
const { gauge_chart } = chartData;
|
||||
const currentWeekData = gauge_chart.available_weeks[currentWeekIndex];
|
||||
|
||||
if (!currentWeekData || !currentWeekData.has_data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: uniformityData.uniformity,
|
||||
value: currentWeekData.uniformity_percentage,
|
||||
label: 'Uniformity',
|
||||
week: `Week ${uniformityData.week}`,
|
||||
currentValue: uniformityData.uniform_qty,
|
||||
totalValue: uniformityData.chick_qty_of_weight,
|
||||
week: `Week ${currentWeekData.week}`,
|
||||
currentValue: currentWeekData.ideal_count,
|
||||
totalValue: currentWeekData.total_count,
|
||||
hasPrevWeek: gauge_chart.week_info.has_prev_week,
|
||||
hasNextWeek: gauge_chart.week_info.has_next_week,
|
||||
};
|
||||
}, [uniformityData]);
|
||||
}, [chartData, currentWeekIndex, uniformityData]);
|
||||
|
||||
const handleWeekChange = (direction: 'prev' | 'next') => {
|
||||
if (!chartData?.gauge_chart) return;
|
||||
|
||||
const { available_weeks, week_info } = chartData.gauge_chart;
|
||||
|
||||
if (direction === 'prev' && week_info.has_prev_week) {
|
||||
setCurrentWeekIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (direction === 'next' && week_info.has_next_week) {
|
||||
setCurrentWeekIndex((prev) =>
|
||||
Math.min(available_weeks.length - 1, prev + 1)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowEmptyState = !isFiltered;
|
||||
|
||||
return (
|
||||
<section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-4'>
|
||||
@@ -100,14 +93,16 @@ const UniformityChart = ({
|
||||
}}
|
||||
>
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
{!uniformityData || barChartData.length === 0 ? (
|
||||
{shouldShowEmptyState ||
|
||||
!uniformityData ||
|
||||
barChartData.length === 0 ? (
|
||||
<UniformityBarChartSkeleton />
|
||||
) : (
|
||||
<UniformityBarChart data={barChartData} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{!uniformityData || !gaugeChartData ? (
|
||||
{shouldShowEmptyState || !uniformityData || !gaugeChartData ? (
|
||||
<Card
|
||||
variant='bordered'
|
||||
title='Weekly Performance ⓘ'
|
||||
@@ -133,6 +128,9 @@ const UniformityChart = ({
|
||||
week={gaugeChartData.week}
|
||||
currentValue={gaugeChartData.currentValue}
|
||||
totalValue={gaugeChartData.totalValue}
|
||||
onWeekChange={handleWeekChange}
|
||||
hasPrevWeek={gaugeChartData.hasPrevWeek}
|
||||
hasNextWeek={gaugeChartData.hasNextWeek}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -151,8 +151,10 @@ const UniformityConfirmationPreview = ({
|
||||
|
||||
const UniformityChartWrapper = ({
|
||||
uniformitySwrKey,
|
||||
isFiltered,
|
||||
}: {
|
||||
uniformitySwrKey: string;
|
||||
isFiltered: boolean;
|
||||
}) => {
|
||||
const { data: uniformities } = useSWR(
|
||||
uniformitySwrKey,
|
||||
@@ -166,31 +168,8 @@ const UniformityChartWrapper = ({
|
||||
return null;
|
||||
}, [uniformities]);
|
||||
|
||||
const shouldFetchDetails = !!uniformityData;
|
||||
const uniformityDetailSwrKey = useMemo(() => {
|
||||
if (!uniformityData) return null;
|
||||
return `${UniformityApi.basePath}/${uniformityData.id}?with_details=true`;
|
||||
}, [uniformityData]);
|
||||
|
||||
const { data: uniformityDetailResponse } = useSWR(
|
||||
uniformityDetailSwrKey,
|
||||
shouldFetchDetails ? UniformityApi.getAllFetcher : null
|
||||
);
|
||||
|
||||
const uniformityDetails = useMemo(() => {
|
||||
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
|
||||
const detailData =
|
||||
uniformityDetailResponse.data as unknown as UniformityDetail;
|
||||
return detailData.uniformity_details;
|
||||
}
|
||||
return undefined;
|
||||
}, [shouldFetchDetails, uniformityDetailResponse]);
|
||||
|
||||
return (
|
||||
<UniformityChart
|
||||
uniformityData={uniformityData}
|
||||
uniformityDetails={uniformityDetails}
|
||||
/>
|
||||
<UniformityChart uniformityData={uniformityData} isFiltered={isFiltered} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -374,6 +353,7 @@ const UniformityTable = () => {
|
||||
if (filterEndDate) {
|
||||
queryParams.append('end_date', filterEndDate);
|
||||
}
|
||||
queryParams.append('with_chart', 'true');
|
||||
}
|
||||
|
||||
const tableQueryString = getTableFilterQueryString();
|
||||
@@ -433,6 +413,7 @@ const UniformityTable = () => {
|
||||
);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setIsSubmitted(false);
|
||||
setFilterLocation(null);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
@@ -896,7 +877,10 @@ const UniformityTable = () => {
|
||||
<div className='my-4 divider'></div>
|
||||
|
||||
<section>
|
||||
<UniformityChartWrapper uniformitySwrKey={uniformitySwrKey} />
|
||||
<UniformityChartWrapper
|
||||
uniformitySwrKey={uniformitySwrKey}
|
||||
isFiltered={isSubmitted}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
@@ -11,9 +11,12 @@ import Badge from '@/components/Badge';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { UniformityDetail as UniformityDetailType } from '@/types/api/production/uniformity';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
import { formatDate, formatNumber } from '@/lib/helper';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import UniformityDetailsPreview from '@/components/pages/production/uniformity/detail/UniformityDetailsPreview';
|
||||
import { UniformityApi } from '@/services/api/uniformity';
|
||||
import useSWR from 'swr';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusIndicatorColor,
|
||||
@@ -33,6 +36,22 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
const setExpandedDrawerContent = useUiStore(
|
||||
(s) => s.setExpandedDrawerContent
|
||||
);
|
||||
const [shouldFetchDetails, setShouldFetchDetails] = useState(false);
|
||||
const [hasFetchedDetails, setHasFetchedDetails] = useState(false);
|
||||
|
||||
const { data: uniformityDetailResponse, isLoading } = useSWR(
|
||||
shouldFetchDetails
|
||||
? `uniformity-detail-${initialValues.id}-with-details`
|
||||
: null,
|
||||
() => UniformityApi.getUniformityDetail(initialValues.id, true)
|
||||
);
|
||||
|
||||
const uniformity_details = useMemo(() => {
|
||||
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
|
||||
return uniformityDetailResponse.data.uniformity_details;
|
||||
}
|
||||
return initialValues.uniformity_details;
|
||||
}, [shouldFetchDetails, uniformityDetailResponse, initialValues]);
|
||||
|
||||
const handleApprove = () => {
|
||||
router.push(`/production/uniformity?action=approve&id=${initialValues.id}`);
|
||||
@@ -43,12 +62,15 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
};
|
||||
|
||||
const handleViewUniformityDetails = () => {
|
||||
if (!uniformity_details || uniformity_details.length === 0) {
|
||||
setShouldFetchDetails(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedDrawerContent(
|
||||
<UniformityDetailsPreview
|
||||
info_umum={initialValues.info_umum}
|
||||
uniformity_details={initialValues.uniformity_details}
|
||||
sampling={initialValues.sampling}
|
||||
result={initialValues.result}
|
||||
uniformity_details={uniformity_details}
|
||||
uniformityId={initialValues.id}
|
||||
/>
|
||||
);
|
||||
@@ -58,6 +80,28 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
}, 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldFetchDetails &&
|
||||
uniformity_details &&
|
||||
uniformity_details.length > 0 &&
|
||||
!hasFetchedDetails
|
||||
) {
|
||||
setExpandedDrawerContent(
|
||||
<UniformityDetailsPreview
|
||||
info_umum={initialValues.info_umum}
|
||||
uniformity_details={uniformity_details}
|
||||
uniformityId={initialValues.id}
|
||||
/>
|
||||
);
|
||||
|
||||
setHasFetchedDetails(true);
|
||||
setTimeout(() => {
|
||||
setExpandedDrawerOpen(true);
|
||||
}, 0);
|
||||
}
|
||||
}, [shouldFetchDetails, uniformity_details, hasFetchedDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setExpandedDrawerOpen(false);
|
||||
@@ -154,12 +198,22 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{valueMap[id]}</span>
|
||||
<Tooltip content='Lihat Detail'>
|
||||
<Tooltip content='Lihat Detail' position='left'>
|
||||
<button
|
||||
className='p-1 hover:bg-gray-100 rounded cursor-pointer'
|
||||
className='p-1 hover:bg-gray-100 rounded cursor-pointer disabled:opacity-50'
|
||||
onClick={handleViewUniformityDetails}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={18} height={18} />
|
||||
{isLoading ? (
|
||||
<Icon
|
||||
icon='mdi:loading'
|
||||
width={18}
|
||||
height={18}
|
||||
className='animate-spin'
|
||||
/>
|
||||
) : (
|
||||
<Icon icon='mdi:eye-outline' width={18} height={18} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -173,6 +227,92 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
[initialValues]
|
||||
);
|
||||
|
||||
const samplingTableData: DetailOptionType[] = useMemo(() => {
|
||||
if (!initialValues.sampling) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'sampling-size',
|
||||
label: 'Sampling size',
|
||||
value: `${formatNumber(initialValues.sampling.chick_qty_of_weight)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'mean-weight',
|
||||
label: 'Mean Weight',
|
||||
value: `${initialValues.sampling.mean_weight} g`,
|
||||
},
|
||||
{
|
||||
id: 'min-limit',
|
||||
label: 'Min Limit (-10%)',
|
||||
value: `${initialValues.sampling.mean_down} g`,
|
||||
},
|
||||
{
|
||||
id: 'max-limit',
|
||||
label: 'Max Limit (+10%)',
|
||||
value: `${initialValues.sampling.mean_up} g`,
|
||||
},
|
||||
];
|
||||
}, [initialValues.sampling]);
|
||||
|
||||
const columnsSampling: ColumnDef<DetailOptionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: 'Label',
|
||||
cell: (props) => props.row.original.label,
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Value',
|
||||
cell: (props) => <span>{props.row.original.value}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const resultTableData: DetailOptionType[] = useMemo(() => {
|
||||
if (!initialValues.result) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'ideal-birds',
|
||||
label: 'Ideal Birds',
|
||||
value: `${formatNumber(initialValues.result.uniform_qty)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'outside-range',
|
||||
label: 'Outside Range',
|
||||
value: `${formatNumber(initialValues.result.outside_qty)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'uniformity',
|
||||
label: 'Uniformity',
|
||||
value: `${initialValues.result.uniformity} %`,
|
||||
},
|
||||
{
|
||||
id: 'cv',
|
||||
label: 'CV',
|
||||
value: `${initialValues.result.cv} %`,
|
||||
},
|
||||
];
|
||||
}, [initialValues.result]);
|
||||
|
||||
const resultColumns: ColumnDef<DetailOptionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: 'Label',
|
||||
cell: (props) => props.row.original.label,
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Value',
|
||||
cell: (props) => <span>{props.row.original.value}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className='w-full h-full bg-white border-l border-gray-200'>
|
||||
{/* Header */}
|
||||
@@ -185,7 +325,7 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
|
||||
{/* Form Section */}
|
||||
<div className='divider mt-3.5'></div>
|
||||
<section className='w-full px-6'>
|
||||
<section className='w-full px-6 mb-6'>
|
||||
{initialValues ? (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Info Umum */}
|
||||
@@ -200,23 +340,55 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Approve/Reject Buttons */}
|
||||
{initialValues.result &&
|
||||
initialValues.latest_approval?.step_name === 'CREATED' ? (
|
||||
<>
|
||||
<div className='divider my-3.5' />
|
||||
<RequirePermission permissions='lti.production.uniformity.approve'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 [&_button]:rounded-lg'>
|
||||
<Button variant='outline' onClick={handleReject}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button onClick={handleApprove}>Approve</Button>
|
||||
</div>
|
||||
</RequirePermission>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Sampling and Range */}
|
||||
{initialValues.sampling && (
|
||||
<div className=''>
|
||||
<p className='text-sm font-medium mb-5'>Sampling and Range</p>
|
||||
<Table<DetailOptionType>
|
||||
data={samplingTableData}
|
||||
columns={columnsSampling}
|
||||
pageSize={4}
|
||||
className={{
|
||||
containerClassName: 'mb-0',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{initialValues.result && (
|
||||
<div className=''>
|
||||
<p className='text-sm font-medium mb-5'>Result</p>
|
||||
<Table<DetailOptionType>
|
||||
data={resultTableData}
|
||||
columns={resultColumns}
|
||||
pageSize={4}
|
||||
className={{
|
||||
containerClassName: 'mb-0',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approve/Reject Buttons */}
|
||||
{initialValues.result &&
|
||||
initialValues.latest_approval?.step_name === 'CREATED' ? (
|
||||
<>
|
||||
<div className='divider my-3.5' />
|
||||
<RequirePermission permissions='lti.production.uniformity.approve'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 [&_button]:rounded-lg'>
|
||||
<Button variant='outline' onClick={handleReject}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button onClick={handleApprove}>Approve</Button>
|
||||
</div>
|
||||
</RequirePermission>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
|
||||
|
||||
@@ -1,152 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
UniformityDetailItem,
|
||||
UniformitySampling,
|
||||
UniformityResult,
|
||||
UniformityInfoUmum,
|
||||
} from '@/types/api/production/uniformity';
|
||||
import Table from '@/components/Table';
|
||||
import Badge from '@/components/Badge';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { DetailOptionType } from '@/types/api/production/uniformity';
|
||||
import {
|
||||
getWeightStatusColor,
|
||||
getWeightStatusIndicatorColor,
|
||||
getWeightStatusText,
|
||||
} from '@/components/pages/production/uniformity/uniformity-utils';
|
||||
import { BodyWeightData } from '@/types/api/production/uniformity';
|
||||
import Button from '@/components/Button';
|
||||
import { UniformityApi } from '@/services/api/uniformity';
|
||||
import useSWR from 'swr';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
interface UniformityDetailsPreviewProps {
|
||||
info_umum: UniformityInfoUmum;
|
||||
sampling: UniformitySampling;
|
||||
result: UniformityResult;
|
||||
uniformity_details?: UniformityDetailItem[];
|
||||
uniformityId: number;
|
||||
}
|
||||
|
||||
const UniformityDetailsPreview = ({
|
||||
info_umum,
|
||||
uniformity_details: initialUniformityDetails,
|
||||
sampling,
|
||||
result,
|
||||
uniformityId,
|
||||
uniformity_details,
|
||||
}: UniformityDetailsPreviewProps) => {
|
||||
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
|
||||
const [shouldFetchDetails, setShouldFetchDetails] = useState(false);
|
||||
|
||||
const { data: uniformityDetailResponse, isLoading } = useSWR(
|
||||
shouldFetchDetails
|
||||
? `uniformity-detail-${uniformityId}-with-details`
|
||||
: null,
|
||||
() => UniformityApi.getUniformityDetail(uniformityId, true)
|
||||
);
|
||||
|
||||
const uniformity_details = useMemo(() => {
|
||||
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
|
||||
return uniformityDetailResponse.data.uniformity_details;
|
||||
}
|
||||
return initialUniformityDetails;
|
||||
}, [shouldFetchDetails, uniformityDetailResponse, initialUniformityDetails]);
|
||||
|
||||
const handleClose = () => {
|
||||
setExpandedDrawerOpen(false);
|
||||
};
|
||||
|
||||
const fetchWeightData = () => {
|
||||
setShouldFetchDetails(true);
|
||||
};
|
||||
|
||||
const samplingTableData: DetailOptionType[] = useMemo(() => {
|
||||
if (!sampling) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'sampling-size',
|
||||
label: 'Sampling size',
|
||||
value: `${formatNumber(sampling.chick_qty_of_weight)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'mean-weight',
|
||||
label: 'Mean Weight',
|
||||
value: `${sampling.mean_weight} g`,
|
||||
},
|
||||
{
|
||||
id: 'min-limit',
|
||||
label: 'Min Limit (-10%)',
|
||||
value: `${sampling.mean_down} g`,
|
||||
},
|
||||
{
|
||||
id: 'max-limit',
|
||||
label: 'Max Limit (+10%)',
|
||||
value: `${sampling.mean_up} g`,
|
||||
},
|
||||
];
|
||||
}, [sampling]);
|
||||
|
||||
const columnsSampling: ColumnDef<DetailOptionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: 'Label',
|
||||
cell: (props) => props.row.original.label,
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Value',
|
||||
cell: (props) => <span>{props.row.original.value}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const resultTableData: DetailOptionType[] = useMemo(() => {
|
||||
if (!result) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'ideal-birds',
|
||||
label: 'Ideal Birds',
|
||||
value: `${formatNumber(result.uniform_qty)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'outside-range',
|
||||
label: 'Outside Range',
|
||||
value: `${formatNumber(result.outside_qty)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'uniformity',
|
||||
label: 'Uniformity',
|
||||
value: `${result.uniformity} %`,
|
||||
},
|
||||
];
|
||||
}, [result]);
|
||||
|
||||
const resultColumns: ColumnDef<DetailOptionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: 'Label',
|
||||
cell: (props) => props.row.original.label,
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Value',
|
||||
cell: (props) => <span>{props.row.original.value}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!uniformity_details) return [];
|
||||
|
||||
@@ -229,55 +116,10 @@ const UniformityDetailsPreview = ({
|
||||
{/* Form Section */}
|
||||
<div className='divider mt-3.5'></div>
|
||||
<section className='w-full px-6'>
|
||||
{info_umum || sampling || result ? (
|
||||
{info_umum ? (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Sampling and Range */}
|
||||
{sampling && (
|
||||
<div className=''>
|
||||
<p className='text-sm font-medium mb-5'>Sampling and Range</p>
|
||||
<Table<DetailOptionType>
|
||||
data={samplingTableData}
|
||||
columns={columnsSampling}
|
||||
pageSize={4}
|
||||
className={{
|
||||
containerClassName: 'mb-0',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className=''>
|
||||
<p className='text-sm font-medium mb-5'>Result</p>
|
||||
<Table<DetailOptionType>
|
||||
data={resultTableData}
|
||||
columns={resultColumns}
|
||||
pageSize={4}
|
||||
className={{
|
||||
containerClassName: 'mb-0',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!uniformity_details || uniformity_details.length === 0 ? (
|
||||
<div className='mt-4'>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={fetchWeightData}
|
||||
disabled={isLoading}
|
||||
className='w-full'
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Show Body Weight Details'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Body Weight Details */}
|
||||
{uniformity_details && uniformity_details.length > 0 && (
|
||||
{uniformity_details && uniformity_details.length > 0 ? (
|
||||
<div className='mt-4'>
|
||||
<Table<BodyWeightData>
|
||||
data={tableData}
|
||||
@@ -286,6 +128,17 @@ const UniformityDetailsPreview = ({
|
||||
className={{ containerClassName: 'mb-5' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
|
||||
<Icon
|
||||
icon='mdi:file-document-outline'
|
||||
width={64}
|
||||
height={64}
|
||||
className='mb-4'
|
||||
/>
|
||||
<p className='text-lg'>No data available</p>
|
||||
<p className='text-sm'>Body weight details not found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -24,9 +24,9 @@ type UniformityFormSchemaType = {
|
||||
};
|
||||
|
||||
const FileSchema = Yup.mixed<File>()
|
||||
.test('documentSize', 'Ukuran file maksimal 2 MB', (value): boolean => {
|
||||
.test('documentSize', 'Ukuran file maksimal 5 MB', (value): boolean => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 2 * 1024 * 1024;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return false;
|
||||
})
|
||||
.test('documentType', 'Format file harus Excel', (value): boolean => {
|
||||
|
||||
@@ -43,7 +43,9 @@ import UniformityResultForm from '@/components/pages/production/uniformity/form/
|
||||
import { generateUniformityTemplate } from '@/components/pages/production/uniformity/export/UniformityTemplate';
|
||||
import useSWR from 'swr';
|
||||
import { cn, formatNumber } from '@/lib/helper';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
interface UniformityFormProps {
|
||||
formType?: 'add' | 'edit';
|
||||
@@ -77,6 +79,7 @@ const UniformityForm = ({
|
||||
|
||||
const [uniformityFormErrorMessage, setUniformityFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -282,6 +285,22 @@ const UniformityForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== FORM HANDLERS =====
|
||||
const handleLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
@@ -339,8 +358,8 @@ const UniformityForm = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.size > 2 * 1024 * 1024) {
|
||||
toast.error(`Ukuran file ${document.name} maksimal 2 MB!`);
|
||||
if (document.size > 5 * 1024 * 1024) {
|
||||
toast.error(`Ukuran file ${document.name} maksimal 5 MB!`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -454,7 +473,7 @@ const UniformityForm = ({
|
||||
<section className='w-full px-6 mb-6'>
|
||||
<h2 className='text-2xl font-semibold mb-6'>Informasi Umum</h2>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className='flex flex-col gap-6'>
|
||||
<form onSubmit={handleFormSubmit} className='flex flex-col gap-6'>
|
||||
{uniformityFormErrorMessage && (
|
||||
<div className='alert alert-error' role='alert'>
|
||||
<Icon
|
||||
@@ -466,6 +485,14 @@ const UniformityForm = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DateInput
|
||||
required
|
||||
label='Tanggal'
|
||||
@@ -693,7 +720,7 @@ const UniformityForm = ({
|
||||
type='submit'
|
||||
color='primary'
|
||||
className='w-full'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? (
|
||||
<span className='loading loading-spinner'></span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
@@ -14,6 +14,7 @@ import SelectInput, {
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
PurchaseRequestAcceptApprovalFormDefaultValues,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '@/types/api/purchase/purchase';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
|
||||
@@ -52,7 +54,9 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
const searchParams = useSearchParams();
|
||||
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [key, setKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isRejected = initialValues?.latest_approval?.action === 'REJECTED';
|
||||
|
||||
@@ -67,7 +71,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
| 'purchase_item_id'
|
||||
| 'received_date'
|
||||
| 'travel_number'
|
||||
| 'travel_document_path'
|
||||
| 'vehicle_number'
|
||||
| 'expedition_vendor_id'
|
||||
| 'received_qty'
|
||||
@@ -180,7 +183,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
purchase_item_id: formItem.purchase_item_id || 0,
|
||||
received_date: formItem.received_date || '',
|
||||
travel_number: formItem.travel_number || '',
|
||||
travel_document_path: formItem.travel_document_path || '',
|
||||
vehicle_number: formItem.vehicle_number || '',
|
||||
expedition_vendor_id: formItem.expedition_vendor_id || 0,
|
||||
received_qty:
|
||||
@@ -210,6 +212,22 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
const purchaseItems = useMemo(() => {
|
||||
if (initialValues?.items) {
|
||||
@@ -235,6 +253,9 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
useEffect(() => {
|
||||
if (purchaseItems.length > 0 && initialValues?.items) {
|
||||
const updatedItems = initialValues.items.map((item) => {
|
||||
const expeditionVendorId =
|
||||
item.expedition_vendor_id || item.expedition_vendor?.id || 0;
|
||||
|
||||
return {
|
||||
purchase_item: null,
|
||||
purchase_item_id: item.id,
|
||||
@@ -242,7 +263,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
? new Date(item.received_date).toISOString().split('T')[0]
|
||||
: '',
|
||||
travel_number: item.travel_number || '',
|
||||
travel_document_path: item.travel_document_path || '',
|
||||
vehicle_number: item.vehicle_number || '',
|
||||
expedition_vendor: item.expedition_vendor
|
||||
? {
|
||||
@@ -250,7 +270,7 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
label: item.expedition_vendor.name,
|
||||
}
|
||||
: null,
|
||||
expedition_vendor_id: item.expedition_vendor_id || 0,
|
||||
expedition_vendor_id: expeditionVendorId,
|
||||
received_qty: item.total_qty || '',
|
||||
transport_per_item: item.transport_per_item || '',
|
||||
};
|
||||
@@ -259,20 +279,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
}
|
||||
}, [purchaseItems, initialValues, key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
formik.values.travel_documents &&
|
||||
formik.values.travel_documents.length > 0
|
||||
) {
|
||||
const fileNames = formik.values.travel_documents
|
||||
.map((file) => file.name)
|
||||
.join(', ');
|
||||
formik.values.items?.forEach((item, idx) => {
|
||||
formik.setFieldValue(`items.${idx}.travel_document_path`, fileNames);
|
||||
});
|
||||
}
|
||||
}, [formik.values.travel_documents]);
|
||||
|
||||
// ===== HELPER FUNCTIONS =====
|
||||
const getQuantityExceededError = useCallback(
|
||||
(idx: number, receivedQty: number) => {
|
||||
@@ -349,7 +355,7 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
return (
|
||||
<form
|
||||
key={key}
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
className='w-full flex flex-col gap-6'
|
||||
>
|
||||
<div className='w-full'>
|
||||
@@ -358,6 +364,24 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
? 'Konfirmasi Penerimaan Produk'
|
||||
: 'Edit Penerimaan Produk'}
|
||||
</h2>
|
||||
{purchaseOrderFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseOrderFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='table'>
|
||||
@@ -510,33 +534,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className='hidden'>
|
||||
<TextInput
|
||||
required
|
||||
name={`items.${idx}.travel_document_path`}
|
||||
type='text'
|
||||
value={formItem?.travel_document_path || ''}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue(
|
||||
`items.${idx}.travel_document_path`,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
isRepeaterInputError(idx, 'travel_document_path')
|
||||
.isError
|
||||
}
|
||||
errorMessage={
|
||||
isRepeaterInputError(idx, 'travel_document_path')
|
||||
.errorMessage
|
||||
}
|
||||
placeholder='Masukkan path dokumen'
|
||||
className={{
|
||||
wrapper: 'min-w-52 md:min-w-72 lg:min-w-80',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextInput
|
||||
required
|
||||
@@ -687,14 +684,15 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
name='travel_documents'
|
||||
label='Dokumen Surat Jalan'
|
||||
accept='.pdf,.jpg,.jpeg,.png'
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const invalidFiles = files.filter(
|
||||
(file) => file.size > 2 * 1024 * 1024
|
||||
(file) => file.size > 5 * 1024 * 1024
|
||||
);
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error('Ukuran dokumen maksimal 2 MB!');
|
||||
toast.error('Ukuran dokumen maksimal 5 MB!');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -726,6 +724,10 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
onClick={() => {
|
||||
if (type === 'add') {
|
||||
formik.resetForm();
|
||||
formik.setFieldValue('travel_documents', []);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
setPurchaseOrderFormErrorMessage('');
|
||||
onCancel?.();
|
||||
@@ -741,27 +743,13 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={
|
||||
!formik.isValid ||
|
||||
formik.isSubmitting ||
|
||||
hasQuantityExceededErrors ||
|
||||
isRejected
|
||||
formik.isSubmitting || hasQuantityExceededErrors || isRejected
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{purchaseOrderFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseOrderFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
travel_document_path: string;
|
||||
vehicle_number: string;
|
||||
expedition_vendor?: {
|
||||
value: number;
|
||||
@@ -76,7 +75,6 @@ export type PurchaseAcceptApprovalItemSchema = {
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
travel_document_path: string;
|
||||
vehicle_number: string;
|
||||
expedition_vendor?: {
|
||||
value: number;
|
||||
@@ -185,9 +183,6 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
|
||||
travel_number: Yup.string()
|
||||
.required('No. Surat jalan wajib diisi!')
|
||||
.typeError('No. Surat jalan wajib diisi!'),
|
||||
travel_document_path: Yup.string()
|
||||
.required('Dokumen Surat jalan wajib diisi!')
|
||||
.typeError('Dokumen Surat jalan wajib diisi!'),
|
||||
vehicle_number: Yup.string()
|
||||
.required('Nomor kendaraan wajib diisi!')
|
||||
.typeError('Nomor kendaraan wajib diisi!'),
|
||||
@@ -395,9 +390,9 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
|
||||
.of(
|
||||
Yup.mixed<File>()
|
||||
.required('Dokumen surat jalan wajib diupload!')
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => {
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 2 * 1024 * 1024;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return true;
|
||||
})
|
||||
)
|
||||
@@ -415,7 +410,6 @@ export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcce
|
||||
purchase_item_id: 0,
|
||||
received_date: '',
|
||||
travel_number: '',
|
||||
travel_document_path: '',
|
||||
vehicle_number: '',
|
||||
expedition_vendor_id: 0,
|
||||
received_qty: '',
|
||||
@@ -436,7 +430,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
|
||||
purchase_item_id: item.id,
|
||||
received_date: '',
|
||||
travel_number: '',
|
||||
travel_document_path: '',
|
||||
vehicle_number: '',
|
||||
expedition_vendor_id: 0,
|
||||
received_qty: '',
|
||||
@@ -447,7 +440,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
|
||||
purchase_item_id: 0,
|
||||
received_date: '',
|
||||
travel_number: '',
|
||||
travel_document_path: '',
|
||||
vehicle_number: '',
|
||||
expedition_vendor_id: 0,
|
||||
received_qty: '',
|
||||
|
||||
@@ -12,10 +12,12 @@ import NumberInput from '@/components/input/NumberInput';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import { SupplierProducts } from '@/types/api/master-data/supplier';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import {
|
||||
PurchaseRequestStaffApprovalFormDefaultValues,
|
||||
PurchaseRequestStaffApprovalFormInitialValues,
|
||||
@@ -87,6 +89,7 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
const deleteModal = useModal();
|
||||
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [selectedItemForDelete, setSelectedItemForDelete] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
@@ -415,6 +418,22 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
const supplierProductOptions = baseSupplierProductOptions;
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
@@ -652,16 +671,32 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className='w-full flex flex-col gap-6'
|
||||
>
|
||||
<form onSubmit={handleFormSubmit} className='w-full flex flex-col gap-6'>
|
||||
<div className='w-full'>
|
||||
<h2 className='text-lg font-semibold mb-4'>
|
||||
{type === 'add'
|
||||
? 'Konfirmasi Item Pembelian'
|
||||
: 'Edit Item Pembelian'}
|
||||
</h2>
|
||||
{purchaseOrderFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseOrderFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='overflow-x-auto'>
|
||||
{groupedPurchaseItems.length > 0 ? (
|
||||
<div>
|
||||
@@ -1164,23 +1199,12 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
color='primary'
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={!formik.isValid || formik.isSubmitting || isRejected}
|
||||
disabled={formik.isSubmitting || isRejected}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{purchaseOrderFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseOrderFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import SelectInput, {
|
||||
} from '@/components/input/SelectInput';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
PurchaseRequestFormSchema,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
|
||||
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import { PurchaseApi } from '@/services/api/purchase';
|
||||
|
||||
import Card from '@/components/Card';
|
||||
@@ -59,6 +61,7 @@ const PurchaseRequestForm = ({
|
||||
);
|
||||
const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
|
||||
// ===== TYPE DEFINITIONS =====
|
||||
interface ProductOptionType {
|
||||
@@ -211,6 +214,22 @@ const PurchaseRequestForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
const { data: supplierData, isLoading: isLoadingProducts } = useSWR(
|
||||
formik.values.supplier_id && Number(formik.values.supplier_id) > 0
|
||||
@@ -487,10 +506,29 @@ const PurchaseRequestForm = ({
|
||||
</h1>
|
||||
</header>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{purchaseRequestFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseRequestFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Basic Info Card */}
|
||||
<Card
|
||||
title='Informasi Purchase Request'
|
||||
@@ -896,7 +934,7 @@ const PurchaseRequestForm = ({
|
||||
color='primary'
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
@@ -935,17 +973,6 @@ const PurchaseRequestForm = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{purchaseRequestFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseRequestFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
+63
@@ -1,6 +1,68 @@
|
||||
import { BaseMetadata } from '@/types/api/api-general';
|
||||
import { BaseApproval } from '@/types/api/approval/approval';
|
||||
|
||||
// ==================== CHART DATA TYPES ====================
|
||||
export type WeightDistributionRange = {
|
||||
range: string;
|
||||
min_weight: number;
|
||||
max_weight: number;
|
||||
bird_count: number;
|
||||
is_ideal_range: boolean;
|
||||
};
|
||||
|
||||
export type IdealRange = {
|
||||
min_weight: number;
|
||||
max_weight: number;
|
||||
total_ideal_birds: number;
|
||||
};
|
||||
|
||||
export type StatisticsData = {
|
||||
min_weight: number;
|
||||
max_weight: number;
|
||||
average_weight: number;
|
||||
total_birds_measured: number;
|
||||
};
|
||||
|
||||
export type WeekBarChartData = {
|
||||
has_data: boolean;
|
||||
weight_distribution: WeightDistributionRange[];
|
||||
ideal_range: IdealRange;
|
||||
statistics: StatisticsData;
|
||||
};
|
||||
|
||||
export type BarChart = {
|
||||
current_week: number;
|
||||
all_weeks: Record<string, WeekBarChartData>;
|
||||
};
|
||||
|
||||
export type AvailableWeek = {
|
||||
week: number;
|
||||
uniformity_percentage: number;
|
||||
ideal_count: number;
|
||||
outside_ideal_count: number;
|
||||
total_count: number;
|
||||
has_data: boolean;
|
||||
};
|
||||
|
||||
export type WeekInfo = {
|
||||
total_weeks: number;
|
||||
weeks_with_data: number;
|
||||
current_week_index: number;
|
||||
has_prev_week: boolean;
|
||||
has_next_week: boolean;
|
||||
};
|
||||
|
||||
export type GaugeChart = {
|
||||
current_week: number;
|
||||
available_weeks: AvailableWeek[];
|
||||
week_info: WeekInfo;
|
||||
};
|
||||
|
||||
export type ChartData = {
|
||||
bar_chart: BarChart;
|
||||
gauge_chart: GaugeChart;
|
||||
};
|
||||
|
||||
// ==================== GET ALL RESPONSE ====================
|
||||
export type Uniformity = BaseMetadata & {
|
||||
id: number;
|
||||
@@ -21,6 +83,7 @@ export type Uniformity = BaseMetadata & {
|
||||
standard_mean_weight: number | null;
|
||||
standard_uniformity: number | null;
|
||||
created_at: string;
|
||||
chart_data?: ChartData;
|
||||
created_by: number;
|
||||
latest_approval?: BaseApproval;
|
||||
};
|
||||
|
||||
Vendored
-1
@@ -120,7 +120,6 @@ export type CreateAcceptApprovalRequestPayload = {
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
travel_document_path: string;
|
||||
vehicle_number: string;
|
||||
expedition_vendor_id: number;
|
||||
received_qty: number;
|
||||
|
||||
Reference in New Issue
Block a user