Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu

This commit is contained in:
rstubryan
2026-01-08 13:15:31 +07:00
31 changed files with 1310 additions and 754 deletions
+46
View File
@@ -0,0 +1,46 @@
import Alert from '@/components/Alert';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
/**
* Alert Unique Error List
* @param formErrorList - Array of error messages
* @param onClose - Function to close the alert
*/
const AlertErrorList = ({
formErrorList,
onClose,
}: {
formErrorList: string[];
onClose: () => void;
}) => {
return (
<Alert color='error' className='flex flex-col gap-2 px-4 m-4'>
<div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} />
<span className='font-semibold'>
Terdapat {formErrorList.length} error pada form:
</span>
</div>
<Button
onClick={onClose}
variant='link'
className='ml-auto p-0 w-fit text-white'
color='none'
>
<Icon icon='material-symbols:close' width={24} height={24} />
</Button>
</div>
<ul className='list-disc list-inside pl-8 space-y-1 w-full'>
{formErrorList.map((error, index) => (
<li key={index} className='text-sm'>
{error}
</li>
))}
</ul>
</Alert>
);
};
export default AlertErrorList;
+1 -1
View File
@@ -309,7 +309,7 @@ const useApprovalSteps = ({
moduleId: string;
params?: {
page?: number;
limit: number;
limit: number | string;
search?: string;
group_step_number?: boolean;
};
@@ -125,6 +125,7 @@ const InventoryAdjustmentForm = ({
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({
search: '',
limit: '100',
}).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
warehouseUrl,
@@ -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,7 +133,7 @@ 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()
@@ -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,20 +237,23 @@ export const getMovementFormInitialValues = (
}
: null,
destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0,
products:
initialValues?.details?.map((detail) => ({
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) => ({
})) ?? [
{
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_number: d.document_number ?? '',
document: d.document ?? null,
document_path: d.document_path ?? null,
driver_name: d.driver_name ?? '',
@@ -261,6 +275,24 @@ export const getMovementFormInitialValues = (
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)}
@@ -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>
</>
@@ -48,6 +48,8 @@ import DeliveryOrderProductForm from '@/components/pages/marketing/form/repeater
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import RequirePermission from '@/components/helper/RequirePermission';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -217,6 +219,7 @@ const MarketingForm = ({
const [deliveryFormState, setDeliveryFormState] = useState<'add' | 'edit'>(
'add'
);
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
DeliveryOrderProductFormValues[]
>(
@@ -558,11 +561,28 @@ const MarketingForm = ({
);
}, [memoSalesOrder]);
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
// Parse and display errors
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return; // Stop submission
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit();
};
return (
<>
<form
className='flex flex-col gap-4'
onSubmit={formik.handleSubmit}
onSubmit={handleFormSubmit}
onReset={formik.handleReset}
>
<FormHeader
@@ -666,6 +686,14 @@ const MarketingForm = ({
</div>
</div>
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Form Actions */}
<div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
@@ -673,7 +701,7 @@ const MarketingForm = ({
</Button>
<Button
type='submit'
disabled={!formik.isValid || formik.isSubmitting}
disabled={formik.isSubmitting}
isLoading={formik.isSubmitting}
>
Submit
@@ -16,6 +16,8 @@ import Badge from '@/components/Badge';
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm';
import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const DeliveryOrderProductForm = ({
formState,
@@ -40,6 +42,8 @@ const DeliveryOrderProductForm = ({
null
);
const [currentInput, setCurrentInput] = useState<string>('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id
);
@@ -164,15 +168,27 @@ const DeliveryOrderProductForm = ({
}
}, [initialValues]);
const handleValidateForm = () => {
formik.validateForm();
const formErrorList = getUniqueFormikErrors(formik.errors);
setFormErrorList(formErrorList);
if (formErrorList.length > 0) {
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleBlurField(currentInput);
handleValidateForm();
formik.handleSubmit(e);
};
return (
<>
<form
className='size-full'
onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onSubmit={handleFormSubmit}
onReset={handleResetForm}
>
{formikErrorMessage && (
@@ -372,6 +388,13 @@ const DeliveryOrderProductForm = ({
/>
</div>
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning'>
Reset
@@ -379,7 +402,7 @@ const DeliveryOrderProductForm = ({
<Button
type='submit'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
disabled={formik.isSubmitting}
>
Submit
</Button>
@@ -25,15 +25,19 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
value: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
label: Yup.string().required('Kandang wajib diisi!'),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
product_warehouse: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
value: Yup.number()
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
label: Yup.string().required('Produk wajib diisi!'),
}).nullable(),
product_warehouse_id: Yup.number()
.min(1, 'Produk wajib diisi!')
@@ -24,6 +24,8 @@ import {
} from '@/lib/helper';
import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const SalesOrderProductForm = ({
initialValues,
@@ -37,6 +39,7 @@ const SalesOrderProductForm = ({
}) => {
const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({
@@ -169,15 +172,29 @@ const SalesOrderProductForm = ({
}
};
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
// Parse and display errors
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return; // Stop submission
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleBlurField(currentInput);
handleValidateForm();
formik.handleSubmit(e);
};
return (
<>
<form
className='size-full'
onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onSubmit={handleFormSubmit}
onReset={handleResetForm}
>
{formErrorMessage && (
@@ -338,6 +355,15 @@ const SalesOrderProductForm = ({
placeholder='Masukan Total Penjualan'
/>
</div>
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning' onClick={handleResetForm}>
Reset
@@ -345,7 +371,7 @@ const SalesOrderProductForm = ({
<Button
type='submit'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
disabled={formik.isSubmitting}
>
Submit
</Button>
@@ -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,6 +275,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<div className='grid sm:grid-cols-2 gap-4'>
<TextInput
required
label='Merek'
@@ -261,6 +300,8 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.sku}
readOnly={type === 'detail'}
/>
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput
required
label='Satuan'
@@ -270,7 +311,10 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
options={uomOptions}
onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms}
isError={formik.touched.uom_id && Boolean(formik.errors.uom_id)}
isError={
(formik.touched.uom || formik.touched.uom_id) &&
Boolean(formik.errors.uom_id)
}
errorMessage={formik.errors.uom_id as string}
isDisabled={type === 'detail'}
isClearable
@@ -285,13 +329,16 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onInputChange={setCategorySelectInputValue}
isLoading={isLoadingCategories}
isError={
formik.touched.product_category_id &&
(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'
@@ -332,6 +379,8 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.selling_price as string}
readOnly={type === 'detail'}
/>
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput
required
label='Pajak (%)'
@@ -369,6 +418,8 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.expiry_period as string}
readOnly={type === 'detail'}
/>
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput
required
label='Supplier'
@@ -411,6 +462,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable
/>
</div>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
@@ -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' && (
@@ -18,6 +18,7 @@ import { Icon } from '@iconify/react';
import Badge from '@/components/Badge';
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
import RequirePermission from '@/components/helper/RequirePermission';
import { BaseApproval } from '@/types/api/api-general';
const ChickinFormKandang = ({
formType = 'add',
initialValues,
@@ -33,11 +34,16 @@ const ChickinFormKandang = ({
approvals,
isLoading: approvalsLoading,
refresh: refreshApprovals,
rawDataApprovals,
} = useApprovalSteps({
latestApproval: initialValues?.approval,
latestApproval: initialValues?.chickin_approval,
approvalLines: CHICKINS_APPROVAL_LINE,
moduleName: 'CHICKINS',
moduleId: initialValues?.id.toString() ?? '',
params: {
limit: 'limit',
group_step_number: false,
},
});
const afterSubmitFormChickin = () => {
@@ -180,6 +186,7 @@ const ChickinFormKandang = ({
</div>
{openChickin && (
<ChickinLogsView
rawDataApprovals={rawDataApprovals as BaseApproval[]}
initialValues={initialValues}
afterSubmit={afterSubmitFormChickin}
/>
@@ -8,6 +8,7 @@ import PillBadge from '@/components/PillBadge';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin';
import { BaseApproval } from '@/types/api/api-general';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import { useState } from 'react';
@@ -16,9 +17,11 @@ import toast from 'react-hot-toast';
const ChickinLogsView = ({
initialValues,
afterSubmit,
rawDataApprovals,
}: {
initialValues: ProjectFlockKandang;
afterSubmit?: () => void;
rawDataApprovals: BaseApproval[];
}) => {
const confirmModal = useModal();
const [isApproveLoading, setIsApproveLoading] = useState(false);
@@ -60,8 +63,15 @@ const ChickinLogsView = ({
</div>
) : (
(initialValues?.chickins || []).map((chickin, index) => {
const isApproved = chickin.usage_qty !== 0;
const isPending = chickin.pending_usage_qty !== 0;
const latestApproval = rawDataApprovals[0];
const isApproved =
index == (initialValues?.chickins || []).length - 1
? latestApproval?.step_number === 2
: true;
const isPending =
index == (initialValues?.chickins || []).length - 1
? latestApproval?.step_number === 1
: false;
const quantity = isApproved
? chickin.usage_qty
: isPending
@@ -81,7 +91,7 @@ const ChickinLogsView = ({
{/* Header with Status Badge */}
<div className='flex flex-row justify-between items-center'>
<div className='text-lg font-semibold'>
Chick In #{index + 1}
Chick In #{index + 1} - {latestApproval?.step_number}
</div>
<PillBadge
content={
@@ -146,7 +156,8 @@ const ChickinLogsView = ({
})
)}
{initialValues?.approval?.step_number <= 2 && (
{initialValues.chickin_approval &&
initialValues?.chickin_approval?.step_number < 2 && (
<RequirePermission permissions='lti.production.chickins.approve'>
<Button
color='success'
@@ -64,9 +64,9 @@ export const ProjectFlockBudgetsSchema: Yup.ObjectSchema<ProjectFlockBudgetsSche
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
total_price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
.typeError('Total Harga harus berupa angka!')
.min(1, 'Total Harga minimal 1!')
.required('Total Harga wajib diisi!'),
});
export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> =
@@ -6,6 +6,8 @@ import SelectInput, {
useSelect,
} from '@/components/input/SelectInput';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import {
AreaApi,
FcrApi,
@@ -64,8 +66,10 @@ const ProjectFlockForm = ({
const [projectFlockFormErrorMessage, setProjectFlockFormErrorMessage] =
useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [selectedArea, setSelectedArea] = useState('');
const [selectedLocation, setSelectedLocation] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [disabledLocation, setDisabledLocation] = useState(
initialValues?.location?.id ? false : true
);
@@ -125,11 +129,15 @@ const ProjectFlockForm = ({
const {
options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name');
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
search: '',
project_category: selectedCategory,
});
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: '',
location_id: selectedLocation == '' ? '0' : selectedLocation,
limit: 'limit',
}).toString()}`;
const {
data: kandang,
@@ -237,9 +245,19 @@ const ProjectFlockForm = ({
};
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('category', (val as OptionType)?.value);
// Reset production standard when category is changed
formik.setFieldValue('production_standard_id', '');
formik.setFieldValue('production_standard', '');
formik.setFieldValue('category_option', val);
if (val == null) {
formik.setFieldValue('category', val ? (val as OptionType)?.value : '');
setSelectedCategory((val as OptionType)?.value as string);
if (Boolean(val)) {
formik.setFieldTouched('category', false);
formik.setFieldError('category', '');
} else {
formik.setFieldTouched('category', true);
}
};
@@ -378,8 +396,6 @@ const ProjectFlockForm = ({
validationSchema:
formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema,
validateOnBlur: true,
// validateOnChange: true,
// validateOnMount: true,
onSubmit: async (values) => {
setProjectFlockFormErrorMessage('');
const payload: CreateProjectFlockPayload = {
@@ -626,6 +642,17 @@ const ProjectFlockForm = ({
return !isNonstockAlreadyInBudgets;
});
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
// Parse and display errors
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return; // Stop submission
}
};
return (
<>
<section className='w-full'>
@@ -685,7 +712,11 @@ const ProjectFlockForm = ({
<form
className='w-auto h-auto'
onSubmit={formik.handleSubmit}
onSubmit={(e) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
}}
onReset={formik.handleReset}
>
{/* Form Informasi Umum */}
@@ -770,23 +801,6 @@ const ProjectFlockForm = ({
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput
required
label='Standar Produksi'
value={formik.values.production_standard as OptionType}
onChange={(val) => {
optionChangeHandler(val, 'production_standard');
}}
options={optionsProductionStandards}
isLoading={isLoadingProductionStandards}
isError={
formik.touched.production_standard &&
Boolean(formik.errors.production_standard)
}
errorMessage={formik.errors.production_standard as string}
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput
required
label='Kategori'
@@ -800,6 +814,23 @@ const ProjectFlockForm = ({
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput
required
label='Standar Produksi'
value={formik.values.production_standard as OptionType}
onChange={(val) => {
optionChangeHandler(val, 'production_standard');
}}
options={optionsProductionStandards}
isLoading={isLoadingProductionStandards}
isError={
formik.touched.production_standard_id &&
Boolean(formik.errors.production_standard_id)
}
errorMessage={formik.errors.production_standard_id as string}
isClearable
isDisabled={formType != 'add'}
/>
<NumberInput
name='period'
label='Periode'
@@ -1051,6 +1082,14 @@ const ProjectFlockForm = ({
</div>
</div>
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-center gap-2 flex-wrap my-6 px-4'>
{formType !== 'detail' && (
<RequirePermission
@@ -1064,7 +1103,7 @@ const ProjectFlockForm = ({
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
disabled={formik.isSubmitting}
className='px-4 w-full'
>
<Icon icon='mdi:plus' width={24} height={24} />
@@ -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}
>
{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,6 +340,39 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
paginationClassName: 'hidden',
}}
/>
</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 &&
@@ -217,7 +390,6 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
</>
) : null}
</div>
</div>
) : (
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
<Icon
@@ -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,6 +684,7 @@ 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(
@@ -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!'),
@@ -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>
+71
View File
@@ -0,0 +1,71 @@
import { FormikErrors } from 'formik';
export type ErrorMessage = {
key: string;
message: string;
};
/**
* Parse Formik errors object into a flat array of error messages
* @param errors - Formik errors object
* @param parentKey - Parent key for nested objects (used internally for recursion)
* @returns Array of error messages
*/
export function parseFormikErrors<T>(
errors: FormikErrors<T>,
parentKey: string = ''
): ErrorMessage[] {
const errorList: ErrorMessage[] = [];
Object.keys(errors).forEach((key) => {
const value = errors[key as keyof typeof errors];
const fullKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof value === 'string') {
// Direct error message
errorList.push({ key: fullKey, message: value });
} else if (Array.isArray(value)) {
// Array of errors
value.forEach((item, index) => {
if (typeof item === 'string') {
errorList.push({ key: `${fullKey}[${index}]`, message: item });
} else if (item && typeof item === 'object') {
// Nested object in array
const nestedErrors = parseFormikErrors(
item as FormikErrors<unknown>,
`${fullKey}[${index}]`
);
errorList.push(...nestedErrors);
}
});
} else if (value && typeof value === 'object') {
// Nested object
const nestedErrors = parseFormikErrors(
value as FormikErrors<unknown>,
fullKey
);
errorList.push(...nestedErrors);
}
});
return errorList;
}
/**
* Get unique error messages from Formik errors
* @param errors - Formik errors object
* @returns Array of unique error messages
*/
export function getUniqueFormikErrors<T>(errors: FormikErrors<T>): string[] {
const errorList = parseFormikErrors(errors);
return Array.from(new Set(errorList.map((e) => e.message)));
}
/**
* Get all error messages from Formik errors
* @param errors - Formik errors object
* @returns Array of error messages
*/
export function getAllFormikErrors<T>(errors: FormikErrors<T>): ErrorMessage[] {
return parseFormikErrors(errors);
}
+3 -10
View File
@@ -10,9 +10,10 @@ export type BaseProjectFlockKandang = {
kandang_id: number;
kandang: Kandang;
project_flock: ProjectFlock;
available_qtys?: AvailableQty[];
chickins?: Chickin[];
approval: BaseApproval;
chickins?: Chickin[];
available_qtys?: AvailableQty[];
chickin_approval?: BaseApproval;
};
export type AvailableQty = {
@@ -56,14 +57,6 @@ export type ClosingExpense = {
reference_number: string;
};
// "flag_name": "PAKAN",
// "product_warehouse_id": 14,
// "product_id": 8,
// "product_name": "281 SPECIAL STARTER",
// "product_category": "Bahan Baku",
// "uom": "Kilogram",
// "quantity": 1100
export type StockItem = {
flag_name: string;
product_warehouse_id: number;
+63
View File
@@ -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;
};
-1
View File
@@ -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;