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; moduleId: string;
params?: { params?: {
page?: number; page?: number;
limit: number; limit: number | string;
search?: string; search?: string;
group_step_number?: boolean; group_step_number?: boolean;
}; };
@@ -125,6 +125,7 @@ const InventoryAdjustmentForm = ({
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({
search: '', search: '',
limit: '100',
}).toString()}`; }).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
warehouseUrl, warehouseUrl,
@@ -85,7 +85,10 @@ const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).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() product_qty: Yup.number()
.required('Qty wajib diisi!') .required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!') .min(1, 'Qty minimal 1!')
@@ -97,7 +100,10 @@ const DeliveryProductObjectSchema = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).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() product_qty: Yup.number()
.required('Qty wajib diisi!') .required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!') .min(1, 'Qty minimal 1!')
@@ -127,7 +133,7 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
(delivery_cost !== undefined && delivery_cost > 0) (delivery_cost !== undefined && delivery_cost > 0)
); );
}), }),
document_path: Yup.string().optional(), document_path: Yup.string().nullable().optional(),
document_index: Yup.number().optional(), document_index: Yup.number().optional(),
document: Yup.mixed<File | MovementDocument>() document: Yup.mixed<File | MovementDocument>()
.nullable() .nullable()
@@ -142,7 +148,10 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).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() products: Yup.array()
.of(DeliveryProductObjectSchema) .of(DeliveryProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!') .min(1, 'Minimal harus ada 1 produk!')
@@ -161,6 +170,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
}).nullable(), }).nullable(),
source_warehouse_id: Yup.number() source_warehouse_id: Yup.number()
.required('Gudang asal wajib diisi!') .required('Gudang asal wajib diisi!')
.min(1, 'Gudang asal wajib diisi!')
.typeError('Gudang asal wajib diisi!'), .typeError('Gudang asal wajib diisi!'),
destination_warehouse: Yup.object({ destination_warehouse: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
@@ -170,6 +180,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
}).nullable(), }).nullable(),
destination_warehouse_id: Yup.number() destination_warehouse_id: Yup.number()
.required('Gudang tujuan wajib diisi!') .required('Gudang tujuan wajib diisi!')
.min(1, 'Gudang tujuan wajib diisi!')
.typeError('Gudang tujuan wajib diisi!') .typeError('Gudang tujuan wajib diisi!')
.test( .test(
'different-warehouse', 'different-warehouse',
@@ -226,20 +237,23 @@ export const getMovementFormInitialValues = (
} }
: null, : null,
destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0,
products: products: initialValues?.details?.map((detail) => ({
initialValues?.details?.map((detail) => ({
product: { product: {
value: detail.product.id, value: detail.product.id,
label: detail.product.name, label: detail.product.name,
}, },
product_id: detail.product.id, product_id: detail.product.id,
product_qty: detail.quantity, 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: d.shipping_cost_total ?? undefined,
delivery_cost_per_item: d.shipping_cost_item ?? undefined, delivery_cost_per_item: d.shipping_cost_item ?? undefined,
document_number: d.document_number ?? '',
document: d.document ?? null, document: d.document ?? null,
document_path: d.document_path ?? null, document_path: d.document_path ?? null,
driver_name: d.driver_name ?? '', driver_name: d.driver_name ?? '',
@@ -261,6 +275,24 @@ export const getMovementFormInitialValues = (
product_qty: item.quantity, 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 Badge from '@/components/Badge';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant'; import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface MovementFormProps { interface MovementFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -53,6 +55,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
] = useState(''); ] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const createMovementHandler = useCallback( const createMovementHandler = useCallback(
@@ -761,8 +764,36 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
type !== 'edit' && type !== 'edit' &&
type !== 'detail' type !== 'detail'
) { ) {
formik.setFieldValue('products', []); if (formik.values.products.length === 0) {
formik.setFieldValue('deliveries', []); 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]); }, [formik.values.source_warehouse_id]);
@@ -791,6 +822,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.errors.destination_warehouse_id, 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 ( return (
<> <>
<section className='w-full'> <section className='w-full'>
@@ -810,10 +857,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</h1> </h1>
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' 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 */} {/* Top card - Movement details */}
<Card <Card
title='Detail Movement' title='Detail Movement'
@@ -1097,7 +1163,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Produk Produk
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1106,7 +1172,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Qty Qty
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1119,7 +1185,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{formik.values.products?.map((product, idx) => ( {formik.values.products?.map((product, idx) => (
<tr key={`product-row-${idx}-${product.product_id}`}> <tr key={`product-row-${idx}-${product.product_id}`}>
{type !== 'detail' && ( {type !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`product-${idx}`} name={`product-${idx}`}
checked={selectedProducts.includes(idx)} checked={selectedProducts.includes(idx)}
@@ -1311,7 +1377,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Produk Produk
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1320,7 +1386,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Qty Qty
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1329,7 +1395,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Supplier Supplier
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1338,7 +1404,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Plat Nomor Plat Nomor
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1348,7 +1414,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Biaya Pengiriman (Rp.) Biaya Pengiriman (Rp.)
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1357,7 +1423,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Biaya Per Item (Rp.) Biaya Per Item (Rp.)
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1366,7 +1432,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Nama Sopir Nama Sopir
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1379,7 +1445,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{formik.values.deliveries?.map((delivery, idx) => ( {formik.values.deliveries?.map((delivery, idx) => (
<tr key={`delivery-row-${idx}`}> <tr key={`delivery-row-${idx}`}>
{type !== 'detail' && ( {type !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`delivery-${idx}`} name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)} checked={selectedDeliveries.includes(idx)}
@@ -1747,7 +1813,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
disabled={ disabled={
hasInvalidQty || hasInvalidQty ||
hasExceededStock || hasExceededStock ||
!formik.isValid ||
formik.isSubmitting || formik.isSubmitting ||
(formik.values.source_warehouse_id === (formik.values.source_warehouse_id ===
formik.values.destination_warehouse_id && formik.values.destination_warehouse_id &&
@@ -1760,17 +1825,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</div> </div>
)} )}
</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> </form>
</section> </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 { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema'; import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -217,6 +219,7 @@ const MarketingForm = ({
const [deliveryFormState, setDeliveryFormState] = useState<'add' | 'edit'>( const [deliveryFormState, setDeliveryFormState] = useState<'add' | 'edit'>(
'add' 'add'
); );
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [deliveryOrderValues, setDeliveryOrderValues] = useState< const [deliveryOrderValues, setDeliveryOrderValues] = useState<
DeliveryOrderProductFormValues[] DeliveryOrderProductFormValues[]
>( >(
@@ -558,11 +561,28 @@ const MarketingForm = ({
); );
}, [memoSalesOrder]); }, [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 ( return (
<> <>
<form <form
className='flex flex-col gap-4' className='flex flex-col gap-4'
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
> >
<FormHeader <FormHeader
@@ -666,6 +686,14 @@ const MarketingForm = ({
</div> </div>
</div> </div>
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Form Actions */} {/* Form Actions */}
<div className='flex flex-row items-start justify-center gap-2 mt-4'> <div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}> <Button type='reset' color='warning' disabled={formik.isSubmitting}>
@@ -673,7 +701,7 @@ const MarketingForm = ({
</Button> </Button>
<Button <Button
type='submit' type='submit'
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
> >
Submit Submit
@@ -16,6 +16,8 @@ import Badge from '@/components/Badge';
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm'; import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const DeliveryOrderProductForm = ({ const DeliveryOrderProductForm = ({
formState, formState,
@@ -40,6 +42,8 @@ const DeliveryOrderProductForm = ({
null null
); );
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const salesOrder = salesOrders.find( const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id (item) => item.id === initialValues?.marketing_product_id
); );
@@ -164,15 +168,27 @@ const DeliveryOrderProductForm = ({
} }
}, [initialValues]); }, [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 ( return (
<> <>
<form <form
className='size-full' className='size-full'
onSubmit={(e) => { onSubmit={handleFormSubmit}
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm} onReset={handleResetForm}
> >
{formikErrorMessage && ( {formikErrorMessage && (
@@ -372,6 +388,13 @@ const DeliveryOrderProductForm = ({
/> />
</div> </div>
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-end gap-3 mt-4'> <div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning'> <Button type='reset' color='warning'>
Reset Reset
@@ -379,7 +402,7 @@ const DeliveryOrderProductForm = ({
<Button <Button
type='submit' type='submit'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -25,15 +25,19 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
id: Yup.number(), id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'), vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({ kandang: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
label: Yup.string().required('Kandang wajib diisi!'),
}).nullable(), }).nullable(),
kandang_id: Yup.number() kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!') .min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'), .required('Kandang wajib diisi!'),
product_warehouse: Yup.object({ product_warehouse: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
label: Yup.string().required('Produk wajib diisi!'),
}).nullable(), }).nullable(),
product_warehouse_id: Yup.number() product_warehouse_id: Yup.number()
.min(1, 'Produk wajib diisi!') .min(1, 'Produk wajib diisi!')
@@ -24,6 +24,8 @@ import {
} from '@/lib/helper'; } from '@/lib/helper';
import PatternInput from '@/components/input/PatternInput'; import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const SalesOrderProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
@@ -37,6 +39,7 @@ const SalesOrderProductForm = ({
}) => { }) => {
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ============ Formik ============ // ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({ 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 ( return (
<> <>
<form <form
className='size-full' className='size-full'
onSubmit={(e) => { onSubmit={handleFormSubmit}
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm} onReset={handleResetForm}
> >
{formErrorMessage && ( {formErrorMessage && (
@@ -338,6 +355,15 @@ const SalesOrderProductForm = ({
placeholder='Masukan Total Penjualan' placeholder='Masukan Total Penjualan'
/> />
</div> </div>
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-end gap-3 mt-4'> <div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning' onClick={handleResetForm}> <Button type='reset' color='warning' onClick={handleResetForm}>
Reset Reset
@@ -345,7 +371,7 @@ const SalesOrderProductForm = ({
<Button <Button
type='submit' type='submit'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -11,6 +11,8 @@ import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
ProductCategoryFormSchema, ProductCategoryFormSchema,
@@ -39,6 +41,7 @@ const ProductCategoryForm = ({
const deleteModal = useModal(); const deleteModal = useModal();
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createProductCategoryHandler = useCallback( const createProductCategoryHandler = useCallback(
@@ -129,6 +132,22 @@ const ProductCategoryForm = ({
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [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 ( return (
<> <>
<section className='w-full max-w-2xl'> <section className='w-full max-w-2xl'>
@@ -150,10 +169,29 @@ const ProductCategoryForm = ({
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' 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'> <div className='flex flex-col gap-4'>
<TextInput <TextInput
required required
@@ -236,7 +274,7 @@ const ProductCategoryForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -244,17 +282,6 @@ const ProductCategoryForm = ({
</div> </div>
)} )}
</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> </form>
</section> </section>
@@ -29,36 +29,38 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string().required('SKU wajib diisi!'),
uom: Yup.object({ uom: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Satuan wajib dipilih!')
}) .required('Satuan wajib dipilih!'),
.nullable() label: Yup.string().required('Satuan wajib dipilih!'),
.required('Satuan wajib diisi!'), }).nullable(),
uom_id: Yup.number() uom_id: Yup.number()
.required('Satuan wajib diisi!') .min(1, 'Satuan wajib dipilih!')
.typeError('Satuan wajib diisi!'), .required('Satuan wajib dipilih!')
.typeError('Satuan wajib dipilih!'),
product_category: Yup.object({ product_category: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Kategori produk wajib dipilih!')
}) .required('Kategori produk wajib dipilih!'),
.nullable() label: Yup.string().required('Kategori produk wajib dipilih!'),
.required('Kategori produk wajib diisi!'), }).nullable(),
product_category_id: Yup.number() product_category_id: Yup.number()
.required('Kategori produk wajib diisi!') .min(1, 'Kategori produk wajib dipilih!')
.typeError('Kategori produk wajib diisi!'), .required('Kategori produk wajib dipilih!')
.typeError('Kategori produk wajib dipilih!'),
product_price: Yup.number() product_price: Yup.number()
.required('Harga produk wajib diisi!') .required('Harga produk wajib diisi!')
.typeError('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() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .required('Harga jual wajib diisi!')
.typeError('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() tax: Yup.number()
.required('Pajak wajib diisi!') .required('Pajak wajib diisi!')
@@ -69,7 +71,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .required('Periode kadaluarsa wajib diisi!')
.typeError('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() supplier_ids: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!')) .of(Yup.number().required().typeError('Supplier tidak valid!'))
@@ -17,6 +17,8 @@ import SelectInput, {
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
ProductFormSchema, ProductFormSchema,
@@ -48,6 +50,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const deleteModal = useModal(); const deleteModal = useModal();
const [productFormErrorMessage, setProductFormErrorMessage] = useState(''); const [productFormErrorMessage, setProductFormErrorMessage] = useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createProductHandler = useCallback( const createProductHandler = useCallback(
@@ -201,6 +204,22 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [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 ( return (
<> <>
<section className='w-full max-w-2xl'> <section className='w-full max-w-2xl'>
@@ -220,11 +239,30 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</h1> </h1>
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' 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 <TextInput
required required
label='Nama' label='Nama'
@@ -237,6 +275,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.name} errorMessage={formik.errors.name}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<div className='grid sm:grid-cols-2 gap-4'>
<TextInput <TextInput
required required
label='Merek' label='Merek'
@@ -261,6 +300,8 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.sku} errorMessage={formik.errors.sku}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput <SelectInput
required required
label='Satuan' label='Satuan'
@@ -270,7 +311,10 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
options={uomOptions} options={uomOptions}
onInputChange={setUomSelectInputValue} onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms} 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} errorMessage={formik.errors.uom_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
@@ -285,13 +329,16 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onInputChange={setCategorySelectInputValue} onInputChange={setCategorySelectInputValue}
isLoading={isLoadingCategories} isLoading={isLoadingCategories}
isError={ isError={
formik.touched.product_category_id && (formik.touched.product_category ||
formik.touched.product_category_id) &&
Boolean(formik.errors.product_category_id) Boolean(formik.errors.product_category_id)
} }
errorMessage={formik.errors.product_category_id as string} errorMessage={formik.errors.product_category_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required required
label='Harga Produk' label='Harga Produk'
@@ -332,6 +379,8 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.selling_price as string} errorMessage={formik.errors.selling_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required required
label='Pajak (%)' label='Pajak (%)'
@@ -369,6 +418,8 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.expiry_period as string} errorMessage={formik.errors.expiry_period as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput <SelectInput
required required
label='Supplier' label='Supplier'
@@ -411,6 +462,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable isClearable
/> />
</div> </div>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
@@ -463,7 +515,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -471,16 +523,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div> </div>
)} )}
</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> </form>
</section> </section>
{type !== 'add' && ( {type !== 'add' && (
@@ -18,6 +18,7 @@ import { Icon } from '@iconify/react';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line'; import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { BaseApproval } from '@/types/api/api-general';
const ChickinFormKandang = ({ const ChickinFormKandang = ({
formType = 'add', formType = 'add',
initialValues, initialValues,
@@ -33,11 +34,16 @@ const ChickinFormKandang = ({
approvals, approvals,
isLoading: approvalsLoading, isLoading: approvalsLoading,
refresh: refreshApprovals, refresh: refreshApprovals,
rawDataApprovals,
} = useApprovalSteps({ } = useApprovalSteps({
latestApproval: initialValues?.approval, latestApproval: initialValues?.chickin_approval,
approvalLines: CHICKINS_APPROVAL_LINE, approvalLines: CHICKINS_APPROVAL_LINE,
moduleName: 'CHICKINS', moduleName: 'CHICKINS',
moduleId: initialValues?.id.toString() ?? '', moduleId: initialValues?.id.toString() ?? '',
params: {
limit: 'limit',
group_step_number: false,
},
}); });
const afterSubmitFormChickin = () => { const afterSubmitFormChickin = () => {
@@ -180,6 +186,7 @@ const ChickinFormKandang = ({
</div> </div>
{openChickin && ( {openChickin && (
<ChickinLogsView <ChickinLogsView
rawDataApprovals={rawDataApprovals as BaseApproval[]}
initialValues={initialValues} initialValues={initialValues}
afterSubmit={afterSubmitFormChickin} afterSubmit={afterSubmitFormChickin}
/> />
@@ -8,6 +8,7 @@ import PillBadge from '@/components/PillBadge';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin'; import { ChickinApi } from '@/services/api/production/chickin';
import { BaseApproval } from '@/types/api/api-general';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useState } from 'react'; import { useState } from 'react';
@@ -16,9 +17,11 @@ import toast from 'react-hot-toast';
const ChickinLogsView = ({ const ChickinLogsView = ({
initialValues, initialValues,
afterSubmit, afterSubmit,
rawDataApprovals,
}: { }: {
initialValues: ProjectFlockKandang; initialValues: ProjectFlockKandang;
afterSubmit?: () => void; afterSubmit?: () => void;
rawDataApprovals: BaseApproval[];
}) => { }) => {
const confirmModal = useModal(); const confirmModal = useModal();
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
@@ -60,8 +63,15 @@ const ChickinLogsView = ({
</div> </div>
) : ( ) : (
(initialValues?.chickins || []).map((chickin, index) => { (initialValues?.chickins || []).map((chickin, index) => {
const isApproved = chickin.usage_qty !== 0; const latestApproval = rawDataApprovals[0];
const isPending = chickin.pending_usage_qty !== 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 const quantity = isApproved
? chickin.usage_qty ? chickin.usage_qty
: isPending : isPending
@@ -81,7 +91,7 @@ const ChickinLogsView = ({
{/* Header with Status Badge */} {/* Header with Status Badge */}
<div className='flex flex-row justify-between items-center'> <div className='flex flex-row justify-between items-center'>
<div className='text-lg font-semibold'> <div className='text-lg font-semibold'>
Chick In #{index + 1} Chick In #{index + 1} - {latestApproval?.step_number}
</div> </div>
<PillBadge <PillBadge
content={ 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'> <RequirePermission permissions='lti.production.chickins.approve'>
<Button <Button
color='success' color='success'
@@ -64,9 +64,9 @@ export const ProjectFlockBudgetsSchema: Yup.ObjectSchema<ProjectFlockBudgetsSche
.min(1, 'Harga minimal 1!') .min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'), .required('Harga wajib diisi!'),
total_price: Yup.number() total_price: Yup.number()
.typeError('Harga harus berupa angka!') .typeError('Total Harga harus berupa angka!')
.min(1, 'Harga minimal 1!') .min(1, 'Total Harga minimal 1!')
.required('Harga wajib diisi!'), .required('Total Harga wajib diisi!'),
}); });
export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> = export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> =
@@ -6,6 +6,8 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
AreaApi, AreaApi,
FcrApi, FcrApi,
@@ -64,8 +66,10 @@ const ProjectFlockForm = ({
const [projectFlockFormErrorMessage, setProjectFlockFormErrorMessage] = const [projectFlockFormErrorMessage, setProjectFlockFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [selectedArea, setSelectedArea] = useState(''); const [selectedArea, setSelectedArea] = useState('');
const [selectedLocation, setSelectedLocation] = useState(''); const [selectedLocation, setSelectedLocation] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [disabledLocation, setDisabledLocation] = useState( const [disabledLocation, setDisabledLocation] = useState(
initialValues?.location?.id ? false : true initialValues?.location?.id ? false : true
); );
@@ -125,11 +129,15 @@ const ProjectFlockForm = ({
const { const {
options: optionsProductionStandards, options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards, isLoadingOptions: isLoadingProductionStandards,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name'); } = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
search: '',
project_category: selectedCategory,
});
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: '', search: '',
location_id: selectedLocation == '' ? '0' : selectedLocation, location_id: selectedLocation == '' ? '0' : selectedLocation,
limit: 'limit',
}).toString()}`; }).toString()}`;
const { const {
data: kandang, data: kandang,
@@ -237,9 +245,19 @@ const ProjectFlockForm = ({
}; };
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { 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); 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); formik.setFieldTouched('category', true);
} }
}; };
@@ -378,8 +396,6 @@ const ProjectFlockForm = ({
validationSchema: validationSchema:
formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema, formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema,
validateOnBlur: true, validateOnBlur: true,
// validateOnChange: true,
// validateOnMount: true,
onSubmit: async (values) => { onSubmit: async (values) => {
setProjectFlockFormErrorMessage(''); setProjectFlockFormErrorMessage('');
const payload: CreateProjectFlockPayload = { const payload: CreateProjectFlockPayload = {
@@ -626,6 +642,17 @@ const ProjectFlockForm = ({
return !isNonstockAlreadyInBudgets; 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 ( return (
<> <>
<section className='w-full'> <section className='w-full'>
@@ -685,7 +712,11 @@ const ProjectFlockForm = ({
<form <form
className='w-auto h-auto' className='w-auto h-auto'
onSubmit={formik.handleSubmit} onSubmit={(e) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
}}
onReset={formik.handleReset} onReset={formik.handleReset}
> >
{/* Form Informasi Umum */} {/* Form Informasi Umum */}
@@ -770,23 +801,6 @@ const ProjectFlockForm = ({
isClearable isClearable
isDisabled={formType != 'add'} 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 <SelectInput
required required
label='Kategori' label='Kategori'
@@ -800,6 +814,23 @@ const ProjectFlockForm = ({
isClearable isClearable
isDisabled={formType != 'add'} 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 <NumberInput
name='period' name='period'
label='Periode' label='Periode'
@@ -1051,6 +1082,14 @@ const ProjectFlockForm = ({
</div> </div>
</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'> <div className='flex flex-row justify-center gap-2 flex-wrap my-6 px-4'>
{formType !== 'detail' && ( {formType !== 'detail' && (
<RequirePermission <RequirePermission
@@ -1064,7 +1103,7 @@ const ProjectFlockForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
className='px-4 w-full' className='px-4 w-full'
> >
<Icon icon='mdi:plus' width={24} height={24} /> <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 ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
ProjectFlockKandangApi, ProjectFlockKandangApi,
@@ -52,6 +53,7 @@ import {
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import ApprovalSteps, { import ApprovalSteps, {
useApprovalSteps, useApprovalSteps,
@@ -60,7 +62,6 @@ import {
GROWING_RECORDING_APPROVAL_LINE, GROWING_RECORDING_APPROVAL_LINE,
LAYING_RECORDING_APPROVAL_LINE, LAYING_RECORDING_APPROVAL_LINE,
} from '@/config/approval-line'; } from '@/config/approval-line';
import Table from '@/components/Table';
interface RecordingFormProps { interface RecordingFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -92,6 +93,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const [, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const [recordingFormErrorMessage, setRecordingFormErrorMessage] = const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [, setNewRecordingData] = useState<Recording | null>(null); const [, setNewRecordingData] = useState<Recording | null>(null);
const [nextDayRecording, setNextDayRecording] = 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 ===== // ===== HELPER FUNCTIONS =====
useCallback((): OptionType | null => { useCallback((): OptionType | null => {
if ( if (
@@ -1323,9 +1341,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)} )}
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
className='w-full mt-8 flex flex-col gap-6' 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 */} {/* Basic Info Card */}
{(type === 'add' || type === 'edit') && ( {(type === 'add' || type === 'edit') && (
<Card <Card
@@ -2507,9 +2544,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={ disabled={hasExceededStock || formik.isSubmitting}
hasExceededStock || !formik.isValid || formik.isSubmitting
}
> >
Submit Submit
</Button> </Button>
@@ -2534,9 +2569,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={ disabled={hasExceededStock || formik.isSubmitting}
hasExceededStock || !formik.isValid || formik.isSubmitting
}
> >
Submit Submit
</Button> </Button>
@@ -2544,16 +2577,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)} )}
</div> </div>
</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> </form>
</section> </section>
@@ -1,93 +1,86 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import Card from '@/components/Card'; import Card from '@/components/Card';
import UniformityBarChart from '@/components/pages/production/uniformity/chart/UniformityBarChart'; import UniformityBarChart from '@/components/pages/production/uniformity/chart/UniformityBarChart';
import UniformityGaugeChart from '@/components/pages/production/uniformity/chart/UniformityGaugeChart'; import UniformityGaugeChart from '@/components/pages/production/uniformity/chart/UniformityGaugeChart';
import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton'; import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton';
import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton'; import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton';
import { import { Uniformity, type ChartData } from '@/types/api/production/uniformity';
UniformityDetailItem,
Uniformity,
} from '@/types/api/production/uniformity';
interface UniformityChartProps { interface UniformityChartProps {
uniformityData?: Uniformity | null; uniformityData?: Uniformity | null;
uniformityDetails?: UniformityDetailItem[]; isFiltered?: boolean;
} }
const UniformityChart = ({ const UniformityChart = ({
uniformityData, uniformityData,
uniformityDetails, isFiltered = false,
}: UniformityChartProps) => { }: UniformityChartProps) => {
const defaultUniformityDetails: UniformityDetailItem[] = [ const [currentWeekIndex, setCurrentWeekIndex] = useState(0);
{ 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 detailsToUse = uniformityDetails || defaultUniformityDetails; const chartData = useMemo((): ChartData | undefined => {
if (!uniformityData?.chart_data) return undefined;
return uniformityData.chart_data;
}, [uniformityData]);
const barChartData = useMemo(() => { const barChartData = useMemo(() => {
if (!uniformityData) { if (!chartData?.bar_chart) {
return []; 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 []; return [];
} }
const weights = detailsToUse.map((d) => d.weight); return weekData.weight_distribution.map((range) => ({
const minWeight = Math.floor(Math.min(...weights) / 5) * 5; name: range.range,
const maxWeight = Math.ceil(Math.max(...weights) / 5) * 5; uv: range.bird_count,
isIdeal: range.is_ideal_range,
const rangeSize = maxWeight - minWeight < 11 ? 4 : 5; idealCount: range.is_ideal_range
const ranges: string[] = []; ? weekData.ideal_range.total_ideal_birds
: undefined,
for (let start = minWeight; start <= maxWeight; start += rangeSize) { }));
const end = start + rangeSize; }, [chartData]);
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]);
const gaugeChartData = useMemo(() => { 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 { return {
value: uniformityData.uniformity, value: currentWeekData.uniformity_percentage,
label: 'Uniformity', label: 'Uniformity',
week: `Week ${uniformityData.week}`, week: `Week ${currentWeekData.week}`,
currentValue: uniformityData.uniform_qty, currentValue: currentWeekData.ideal_count,
totalValue: uniformityData.chick_qty_of_weight, 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 ( return (
<section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-4'> <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'> <div className='w-full h-full flex items-center justify-center'>
{!uniformityData || barChartData.length === 0 ? ( {shouldShowEmptyState ||
!uniformityData ||
barChartData.length === 0 ? (
<UniformityBarChartSkeleton /> <UniformityBarChartSkeleton />
) : ( ) : (
<UniformityBarChart data={barChartData} /> <UniformityBarChart data={barChartData} />
)} )}
</div> </div>
</Card> </Card>
{!uniformityData || !gaugeChartData ? ( {shouldShowEmptyState || !uniformityData || !gaugeChartData ? (
<Card <Card
variant='bordered' variant='bordered'
title='Weekly Performance ⓘ' title='Weekly Performance ⓘ'
@@ -133,6 +128,9 @@ const UniformityChart = ({
week={gaugeChartData.week} week={gaugeChartData.week}
currentValue={gaugeChartData.currentValue} currentValue={gaugeChartData.currentValue}
totalValue={gaugeChartData.totalValue} totalValue={gaugeChartData.totalValue}
onWeekChange={handleWeekChange}
hasPrevWeek={gaugeChartData.hasPrevWeek}
hasNextWeek={gaugeChartData.hasNextWeek}
/> />
</Card> </Card>
)} )}
@@ -151,8 +151,10 @@ const UniformityConfirmationPreview = ({
const UniformityChartWrapper = ({ const UniformityChartWrapper = ({
uniformitySwrKey, uniformitySwrKey,
isFiltered,
}: { }: {
uniformitySwrKey: string; uniformitySwrKey: string;
isFiltered: boolean;
}) => { }) => {
const { data: uniformities } = useSWR( const { data: uniformities } = useSWR(
uniformitySwrKey, uniformitySwrKey,
@@ -166,31 +168,8 @@ const UniformityChartWrapper = ({
return null; return null;
}, [uniformities]); }, [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 ( return (
<UniformityChart <UniformityChart uniformityData={uniformityData} isFiltered={isFiltered} />
uniformityData={uniformityData}
uniformityDetails={uniformityDetails}
/>
); );
}; };
@@ -374,6 +353,7 @@ const UniformityTable = () => {
if (filterEndDate) { if (filterEndDate) {
queryParams.append('end_date', filterEndDate); queryParams.append('end_date', filterEndDate);
} }
queryParams.append('with_chart', 'true');
} }
const tableQueryString = getTableFilterQueryString(); const tableQueryString = getTableFilterQueryString();
@@ -433,6 +413,7 @@ const UniformityTable = () => {
); );
const handleResetFilters = useCallback(() => { const handleResetFilters = useCallback(() => {
setIsSubmitted(false);
setFilterLocation(null); setFilterLocation(null);
setFilterProjectFlock(null); setFilterProjectFlock(null);
setFilterKandang(null); setFilterKandang(null);
@@ -896,7 +877,10 @@ const UniformityTable = () => {
<div className='my-4 divider'></div> <div className='my-4 divider'></div>
<section> <section>
<UniformityChartWrapper uniformitySwrKey={uniformitySwrKey} /> <UniformityChartWrapper
uniformitySwrKey={uniformitySwrKey}
isFiltered={isSubmitted}
/>
</section> </section>
<Card <Card
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useEffect } from 'react'; import { useMemo, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
@@ -11,9 +11,12 @@ import Badge from '@/components/Badge';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { UniformityDetail as UniformityDetailType } from '@/types/api/production/uniformity'; 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 { useUiStore } from '@/stores/ui/ui.store';
import UniformityDetailsPreview from '@/components/pages/production/uniformity/detail/UniformityDetailsPreview'; 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 { import {
getStatusColor, getStatusColor,
getStatusIndicatorColor, getStatusIndicatorColor,
@@ -33,6 +36,22 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
const setExpandedDrawerContent = useUiStore( const setExpandedDrawerContent = useUiStore(
(s) => s.setExpandedDrawerContent (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 = () => { const handleApprove = () => {
router.push(`/production/uniformity?action=approve&id=${initialValues.id}`); router.push(`/production/uniformity?action=approve&id=${initialValues.id}`);
@@ -43,12 +62,15 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
}; };
const handleViewUniformityDetails = () => { const handleViewUniformityDetails = () => {
if (!uniformity_details || uniformity_details.length === 0) {
setShouldFetchDetails(true);
return;
}
setExpandedDrawerContent( setExpandedDrawerContent(
<UniformityDetailsPreview <UniformityDetailsPreview
info_umum={initialValues.info_umum} info_umum={initialValues.info_umum}
uniformity_details={initialValues.uniformity_details} uniformity_details={uniformity_details}
sampling={initialValues.sampling}
result={initialValues.result}
uniformityId={initialValues.id} uniformityId={initialValues.id}
/> />
); );
@@ -58,6 +80,28 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
}, 0); }, 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(() => { useEffect(() => {
return () => { return () => {
setExpandedDrawerOpen(false); setExpandedDrawerOpen(false);
@@ -154,12 +198,22 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
return ( return (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span>{valueMap[id]}</span> <span>{valueMap[id]}</span>
<Tooltip content='Lihat Detail'> <Tooltip content='Lihat Detail' position='left'>
<button <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} 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} /> <Icon icon='mdi:eye-outline' width={18} height={18} />
)}
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
@@ -173,6 +227,92 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
[initialValues] [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 ( return (
<section className='w-full h-full bg-white border-l border-gray-200'> <section className='w-full h-full bg-white border-l border-gray-200'>
{/* Header */} {/* Header */}
@@ -185,7 +325,7 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
{/* Form Section */} {/* Form Section */}
<div className='divider mt-3.5'></div> <div className='divider mt-3.5'></div>
<section className='w-full px-6'> <section className='w-full px-6 mb-6'>
{initialValues ? ( {initialValues ? (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{/* Info Umum */} {/* Info Umum */}
@@ -200,6 +340,39 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
paginationClassName: 'hidden', 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 */} {/* Approve/Reject Buttons */}
{initialValues.result && {initialValues.result &&
@@ -217,7 +390,6 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
</> </>
) : null} ) : null}
</div> </div>
</div>
) : ( ) : (
<div className='flex flex-col items-center justify-center py-10 text-gray-400'> <div className='flex flex-col items-center justify-center py-10 text-gray-400'>
<Icon <Icon
@@ -1,152 +1,39 @@
'use client'; 'use client';
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import { import {
UniformityDetailItem, UniformityDetailItem,
UniformitySampling,
UniformityResult,
UniformityInfoUmum, UniformityInfoUmum,
} from '@/types/api/production/uniformity'; } from '@/types/api/production/uniformity';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { formatNumber } from '@/lib/helper';
import { DetailOptionType } from '@/types/api/production/uniformity';
import { import {
getWeightStatusColor, getWeightStatusColor,
getWeightStatusIndicatorColor, getWeightStatusIndicatorColor,
getWeightStatusText, getWeightStatusText,
} from '@/components/pages/production/uniformity/uniformity-utils'; } from '@/components/pages/production/uniformity/uniformity-utils';
import { BodyWeightData } from '@/types/api/production/uniformity'; 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 { interface UniformityDetailsPreviewProps {
info_umum: UniformityInfoUmum; info_umum: UniformityInfoUmum;
sampling: UniformitySampling;
result: UniformityResult;
uniformity_details?: UniformityDetailItem[]; uniformity_details?: UniformityDetailItem[];
uniformityId: number; uniformityId: number;
} }
const UniformityDetailsPreview = ({ const UniformityDetailsPreview = ({
info_umum, info_umum,
uniformity_details: initialUniformityDetails, uniformity_details,
sampling,
result,
uniformityId,
}: UniformityDetailsPreviewProps) => { }: UniformityDetailsPreviewProps) => {
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen); 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 = () => { const handleClose = () => {
setExpandedDrawerOpen(false); 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(() => { const tableData = useMemo(() => {
if (!uniformity_details) return []; if (!uniformity_details) return [];
@@ -229,55 +116,10 @@ const UniformityDetailsPreview = ({
{/* Form Section */} {/* Form Section */}
<div className='divider mt-3.5'></div> <div className='divider mt-3.5'></div>
<section className='w-full px-6'> <section className='w-full px-6'>
{info_umum || sampling || result ? ( {info_umum ? (
<div className='flex flex-col gap-4'> <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 */} {/* Body Weight Details */}
{uniformity_details && uniformity_details.length > 0 && ( {uniformity_details && uniformity_details.length > 0 ? (
<div className='mt-4'> <div className='mt-4'>
<Table<BodyWeightData> <Table<BodyWeightData>
data={tableData} data={tableData}
@@ -286,6 +128,17 @@ const UniformityDetailsPreview = ({
className={{ containerClassName: 'mb-5' }} className={{ containerClassName: 'mb-5' }}
/> />
</div> </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> </div>
) : ( ) : (
@@ -24,9 +24,9 @@ type UniformityFormSchemaType = {
}; };
const FileSchema = Yup.mixed<File>() 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) return true;
if (value instanceof File) return value.size <= 2 * 1024 * 1024; if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return false; return false;
}) })
.test('documentType', 'Format file harus Excel', (value): boolean => { .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 { generateUniformityTemplate } from '@/components/pages/production/uniformity/export/UniformityTemplate';
import useSWR from 'swr'; import useSWR from 'swr';
import { cn, formatNumber } from '@/lib/helper'; import { cn, formatNumber } from '@/lib/helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface UniformityFormProps { interface UniformityFormProps {
formType?: 'add' | 'edit'; formType?: 'add' | 'edit';
@@ -77,6 +79,7 @@ const UniformityForm = ({
const [uniformityFormErrorMessage, setUniformityFormErrorMessage] = const [uniformityFormErrorMessage, setUniformityFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); 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 ===== // ===== FORM HANDLERS =====
const handleLocationChange = useCallback( const handleLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
@@ -339,8 +358,8 @@ const UniformityForm = ({
return; return;
} }
if (document.size > 2 * 1024 * 1024) { if (document.size > 5 * 1024 * 1024) {
toast.error(`Ukuran file ${document.name} maksimal 2 MB!`); toast.error(`Ukuran file ${document.name} maksimal 5 MB!`);
return; return;
} }
@@ -454,7 +473,7 @@ const UniformityForm = ({
<section className='w-full px-6 mb-6'> <section className='w-full px-6 mb-6'>
<h2 className='text-2xl font-semibold mb-6'>Informasi Umum</h2> <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 && ( {uniformityFormErrorMessage && (
<div className='alert alert-error' role='alert'> <div className='alert alert-error' role='alert'>
<Icon <Icon
@@ -466,6 +485,14 @@ const UniformityForm = ({
</div> </div>
)} )}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<DateInput <DateInput
required required
label='Tanggal' label='Tanggal'
@@ -693,7 +720,7 @@ const UniformityForm = ({
type='submit' type='submit'
color='primary' color='primary'
className='w-full' className='w-full'
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
> >
{formik.isSubmitting ? ( {formik.isSubmitting ? (
<span className='loading loading-spinner'></span> <span className='loading loading-spinner'></span>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -14,6 +14,7 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
PurchaseRequestAcceptApprovalFormDefaultValues, PurchaseRequestAcceptApprovalFormDefaultValues,
@@ -28,6 +29,7 @@ import {
} from '@/types/api/purchase/purchase'; } from '@/types/api/purchase/purchase';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
@@ -52,7 +54,9 @@ const PurchaseOrderAcceptApprovalForm = ({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const isRejected = initialValues?.latest_approval?.action === 'REJECTED'; const isRejected = initialValues?.latest_approval?.action === 'REJECTED';
@@ -67,7 +71,6 @@ const PurchaseOrderAcceptApprovalForm = ({
| 'purchase_item_id' | 'purchase_item_id'
| 'received_date' | 'received_date'
| 'travel_number' | 'travel_number'
| 'travel_document_path'
| 'vehicle_number' | 'vehicle_number'
| 'expedition_vendor_id' | 'expedition_vendor_id'
| 'received_qty' | 'received_qty'
@@ -180,7 +183,6 @@ const PurchaseOrderAcceptApprovalForm = ({
purchase_item_id: formItem.purchase_item_id || 0, purchase_item_id: formItem.purchase_item_id || 0,
received_date: formItem.received_date || '', received_date: formItem.received_date || '',
travel_number: formItem.travel_number || '', travel_number: formItem.travel_number || '',
travel_document_path: formItem.travel_document_path || '',
vehicle_number: formItem.vehicle_number || '', vehicle_number: formItem.vehicle_number || '',
expedition_vendor_id: formItem.expedition_vendor_id || 0, expedition_vendor_id: formItem.expedition_vendor_id || 0,
received_qty: 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 ===== // ===== API DATA FETCHING =====
const purchaseItems = useMemo(() => { const purchaseItems = useMemo(() => {
if (initialValues?.items) { if (initialValues?.items) {
@@ -235,6 +253,9 @@ const PurchaseOrderAcceptApprovalForm = ({
useEffect(() => { useEffect(() => {
if (purchaseItems.length > 0 && initialValues?.items) { if (purchaseItems.length > 0 && initialValues?.items) {
const updatedItems = initialValues.items.map((item) => { const updatedItems = initialValues.items.map((item) => {
const expeditionVendorId =
item.expedition_vendor_id || item.expedition_vendor?.id || 0;
return { return {
purchase_item: null, purchase_item: null,
purchase_item_id: item.id, purchase_item_id: item.id,
@@ -242,7 +263,6 @@ const PurchaseOrderAcceptApprovalForm = ({
? new Date(item.received_date).toISOString().split('T')[0] ? new Date(item.received_date).toISOString().split('T')[0]
: '', : '',
travel_number: item.travel_number || '', travel_number: item.travel_number || '',
travel_document_path: item.travel_document_path || '',
vehicle_number: item.vehicle_number || '', vehicle_number: item.vehicle_number || '',
expedition_vendor: item.expedition_vendor expedition_vendor: item.expedition_vendor
? { ? {
@@ -250,7 +270,7 @@ const PurchaseOrderAcceptApprovalForm = ({
label: item.expedition_vendor.name, label: item.expedition_vendor.name,
} }
: null, : null,
expedition_vendor_id: item.expedition_vendor_id || 0, expedition_vendor_id: expeditionVendorId,
received_qty: item.total_qty || '', received_qty: item.total_qty || '',
transport_per_item: item.transport_per_item || '', transport_per_item: item.transport_per_item || '',
}; };
@@ -259,20 +279,6 @@ const PurchaseOrderAcceptApprovalForm = ({
} }
}, [purchaseItems, initialValues, key]); }, [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 ===== // ===== HELPER FUNCTIONS =====
const getQuantityExceededError = useCallback( const getQuantityExceededError = useCallback(
(idx: number, receivedQty: number) => { (idx: number, receivedQty: number) => {
@@ -349,7 +355,7 @@ const PurchaseOrderAcceptApprovalForm = ({
return ( return (
<form <form
key={key} key={key}
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
className='w-full flex flex-col gap-6' className='w-full flex flex-col gap-6'
> >
<div className='w-full'> <div className='w-full'>
@@ -358,6 +364,24 @@ const PurchaseOrderAcceptApprovalForm = ({
? 'Konfirmasi Penerimaan Produk' ? 'Konfirmasi Penerimaan Produk'
: 'Edit Penerimaan Produk'} : 'Edit Penerimaan Produk'}
</h2> </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'> <div className='overflow-x-auto'>
<table className='table'> <table className='table'>
@@ -510,33 +534,6 @@ const PurchaseOrderAcceptApprovalForm = ({
}} }}
/> />
</td> </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> <td>
<TextInput <TextInput
required required
@@ -687,6 +684,7 @@ const PurchaseOrderAcceptApprovalForm = ({
name='travel_documents' name='travel_documents'
label='Dokumen Surat Jalan' label='Dokumen Surat Jalan'
accept='.pdf,.jpg,.jpeg,.png' accept='.pdf,.jpg,.jpeg,.png'
ref={fileInputRef}
onChange={(e) => { onChange={(e) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const invalidFiles = files.filter( const invalidFiles = files.filter(
@@ -726,6 +724,10 @@ const PurchaseOrderAcceptApprovalForm = ({
onClick={() => { onClick={() => {
if (type === 'add') { if (type === 'add') {
formik.resetForm(); formik.resetForm();
formik.setFieldValue('travel_documents', []);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} }
setPurchaseOrderFormErrorMessage(''); setPurchaseOrderFormErrorMessage('');
onCancel?.(); onCancel?.();
@@ -741,27 +743,13 @@ const PurchaseOrderAcceptApprovalForm = ({
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={ disabled={
!formik.isValid || formik.isSubmitting || hasQuantityExceededErrors || isRejected
formik.isSubmitting ||
hasQuantityExceededErrors ||
isRejected
} }
> >
Submit Submit
</Button> </Button>
</div> </div>
</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> </div>
</form> </form>
); );
@@ -38,7 +38,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
travel_document_path: string;
vehicle_number: string; vehicle_number: string;
expedition_vendor?: { expedition_vendor?: {
value: number; value: number;
@@ -76,7 +75,6 @@ export type PurchaseAcceptApprovalItemSchema = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
travel_document_path: string;
vehicle_number: string; vehicle_number: string;
expedition_vendor?: { expedition_vendor?: {
value: number; value: number;
@@ -185,9 +183,6 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
travel_number: Yup.string() travel_number: Yup.string()
.required('No. Surat jalan wajib diisi!') .required('No. Surat jalan wajib diisi!')
.typeError('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() vehicle_number: Yup.string()
.required('Nomor kendaraan wajib diisi!') .required('Nomor kendaraan wajib diisi!')
.typeError('Nomor kendaraan wajib diisi!'), .typeError('Nomor kendaraan wajib diisi!'),
@@ -415,7 +410,6 @@ export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcce
purchase_item_id: 0, purchase_item_id: 0,
received_date: '', received_date: '',
travel_number: '', travel_number: '',
travel_document_path: '',
vehicle_number: '', vehicle_number: '',
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
@@ -436,7 +430,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
purchase_item_id: item.id, purchase_item_id: item.id,
received_date: '', received_date: '',
travel_number: '', travel_number: '',
travel_document_path: '',
vehicle_number: '', vehicle_number: '',
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
@@ -447,7 +440,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
purchase_item_id: 0, purchase_item_id: 0,
received_date: '', received_date: '',
travel_number: '', travel_number: '',
travel_document_path: '',
vehicle_number: '', vehicle_number: '',
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
@@ -12,10 +12,12 @@ import NumberInput from '@/components/input/NumberInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { SupplierProducts } from '@/types/api/master-data/supplier'; import { SupplierProducts } from '@/types/api/master-data/supplier';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import { import {
PurchaseRequestStaffApprovalFormDefaultValues, PurchaseRequestStaffApprovalFormDefaultValues,
PurchaseRequestStaffApprovalFormInitialValues, PurchaseRequestStaffApprovalFormInitialValues,
@@ -87,6 +89,7 @@ const PurchaseOrderStaffApprovalForm = ({
const deleteModal = useModal(); const deleteModal = useModal();
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [selectedItemForDelete, setSelectedItemForDelete] = useState< const [selectedItemForDelete, setSelectedItemForDelete] = useState<
number | null number | null
>(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; const supplierProductOptions = baseSupplierProductOptions;
// ===== API DATA FETCHING ===== // ===== API DATA FETCHING =====
@@ -652,16 +671,32 @@ const PurchaseOrderStaffApprovalForm = ({
return ( return (
<> <>
<form <form onSubmit={handleFormSubmit} className='w-full flex flex-col gap-6'>
onSubmit={formik.handleSubmit}
className='w-full flex flex-col gap-6'
>
<div className='w-full'> <div className='w-full'>
<h2 className='text-lg font-semibold mb-4'> <h2 className='text-lg font-semibold mb-4'>
{type === 'add' {type === 'add'
? 'Konfirmasi Item Pembelian' ? 'Konfirmasi Item Pembelian'
: 'Edit Item Pembelian'} : 'Edit Item Pembelian'}
</h2> </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'> <div className='overflow-x-auto'>
{groupedPurchaseItems.length > 0 ? ( {groupedPurchaseItems.length > 0 ? (
<div> <div>
@@ -1164,23 +1199,12 @@ const PurchaseOrderStaffApprovalForm = ({
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting || isRejected} disabled={formik.isSubmitting || isRejected}
> >
Submit Submit
</Button> </Button>
</div> </div>
</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> </div>
</form> </form>
@@ -16,6 +16,7 @@ import SelectInput, {
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
PurchaseRequestFormSchema, PurchaseRequestFormSchema,
@@ -32,6 +33,7 @@ import {
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier'; import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import { PurchaseApi } from '@/services/api/purchase'; import { PurchaseApi } from '@/services/api/purchase';
import Card from '@/components/Card'; import Card from '@/components/Card';
@@ -59,6 +61,7 @@ const PurchaseRequestForm = ({
); );
const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] = const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== TYPE DEFINITIONS ===== // ===== TYPE DEFINITIONS =====
interface ProductOptionType { 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 ===== // ===== API DATA FETCHING =====
const { data: supplierData, isLoading: isLoadingProducts } = useSWR( const { data: supplierData, isLoading: isLoadingProducts } = useSWR(
formik.values.supplier_id && Number(formik.values.supplier_id) > 0 formik.values.supplier_id && Number(formik.values.supplier_id) > 0
@@ -487,10 +506,29 @@ const PurchaseRequestForm = ({
</h1> </h1>
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' 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 */} {/* Basic Info Card */}
<Card <Card
title='Informasi Purchase Request' title='Informasi Purchase Request'
@@ -896,7 +934,7 @@ const PurchaseRequestForm = ({
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -935,17 +973,6 @@ const PurchaseRequestForm = ({
</div> </div>
)} )}
</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> </form>
</section> </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_id: number;
kandang: Kandang; kandang: Kandang;
project_flock: ProjectFlock; project_flock: ProjectFlock;
available_qtys?: AvailableQty[];
chickins?: Chickin[];
approval: BaseApproval; approval: BaseApproval;
chickins?: Chickin[];
available_qtys?: AvailableQty[];
chickin_approval?: BaseApproval;
}; };
export type AvailableQty = { export type AvailableQty = {
@@ -56,14 +57,6 @@ export type ClosingExpense = {
reference_number: string; 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 = { export type StockItem = {
flag_name: string; flag_name: string;
product_warehouse_id: number; product_warehouse_id: number;
+63
View File
@@ -1,6 +1,68 @@
import { BaseMetadata } from '@/types/api/api-general'; import { BaseMetadata } from '@/types/api/api-general';
import { BaseApproval } from '@/types/api/approval/approval'; 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 ==================== // ==================== GET ALL RESPONSE ====================
export type Uniformity = BaseMetadata & { export type Uniformity = BaseMetadata & {
id: number; id: number;
@@ -21,6 +83,7 @@ export type Uniformity = BaseMetadata & {
standard_mean_weight: number | null; standard_mean_weight: number | null;
standard_uniformity: number | null; standard_uniformity: number | null;
created_at: string; created_at: string;
chart_data?: ChartData;
created_by: number; created_by: number;
latest_approval?: BaseApproval; latest_approval?: BaseApproval;
}; };
-1
View File
@@ -120,7 +120,6 @@ export type CreateAcceptApprovalRequestPayload = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
travel_document_path: string;
vehicle_number: string; vehicle_number: string;
expedition_vendor_id: number; expedition_vendor_id: number;
received_qty: number; received_qty: number;