feat(FE-170): add egg handling and validation to daily recording form

This commit is contained in:
rstubryan
2025-10-31 00:02:04 +07:00
parent 0e77597a70
commit 59b0eeea2b
@@ -15,14 +15,21 @@ import { FormActions } from '@/components/helper/form/FormActions';
import { RecordingApi } from '@/services/api/production';
import {
CreateGrowingRecordingPayload,
CreateLayingRecordingPayload,
UpdateGrowingRecordingPayload,
UpdateLayingRecordingPayload,
Recording,
} from '@/types/api/production/recording';
import { type BaseApiResponse } from '@/types/api/api-general';
import {
RecordingGrowingFormSchema,
RecordingLayingFormSchema,
RecordingGrowingFormValues,
RecordingLayingFormValues,
getRecordingGrowingFormInitialValues,
getRecordingLayingFormInitialValues,
UpdateRecordingGrowingFormSchema,
UpdateRecordingLayingFormSchema,
} from './RecordingForm.schema';
import { useRecordingFormHandlers } from './useRecordingFormHandlers';
import { ProjectFlockApi } from '@/services/api/production';
@@ -49,6 +56,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const [selectedBodyWeights, setSelectedBodyWeights] = useState<number[]>([]);
const [selectedStocks, setSelectedStocks] = useState<number[]>([]);
const [selectedDepletions, setSelectedDepletions] = useState<number[]>([]);
const [selectedEggs, setSelectedEggs] = useState<number[]>([]);
const [editingAverageIndex] = useState<number | null>(null);
const [manuallyEditedRows, setManuallyEditedRows] = useState<Set<number>>(
@@ -219,6 +227,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const { data: depletionProductsData, isLoading: isLoadingDepletionProducts } =
useSWR(depletionProductsUrl, ProductWarehouseApi.getAllFetcher);
const eggProductsUrl = useMemo(() => {
if (!selectedLocation) return null;
const params = new URLSearchParams({
search: '',
location_id: selectedLocation.value.toString(),
});
return `${ProductWarehouseApi.basePath}?${params.toString()}`;
}, [selectedLocation]);
const { data: eggProductsData, isLoading: isLoadingEggProducts } = useSWR(
eggProductsUrl,
ProductWarehouseApi.getAllFetcher
);
// ===== DATA PROCESSING =====
const locationOptions = useMemo(() => {
if (!isResponseSuccess(locations)) return [];
@@ -358,6 +380,27 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return options;
}, [depletionProductsData]);
const eggProducts = useMemo(() => {
const options: OptionType[] = [];
if (isResponseSuccess(eggProductsData)) {
eggProductsData.data.forEach((product) => {
const productName = product.product.name;
if (
productName.toLowerCase().includes('telur') ||
productName.toLowerCase().includes('egg')
) {
options.push({
value: product.id,
label: product.product.name,
});
}
});
}
return options;
}, [eggProductsData]);
// ===== FORM HANDLERS =====
const {
deleteModal,
@@ -369,46 +412,109 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
confirmationModalDeleteClickHandler,
} = useRecordingFormHandlers(initialValues?.id);
const formikInitialValues = useMemo<RecordingGrowingFormValues>(
() => getRecordingGrowingFormInitialValues(initialValues),
[initialValues]
);
const isLayingCategory =
projectFlockKandangLookup?.project_flock?.category === 'LAYING';
const formik = useFormik<RecordingGrowingFormValues>({
const formikInitialValues = useMemo(() => {
if (isLayingCategory) {
return getRecordingLayingFormInitialValues(
initialValues
) as RecordingLayingFormValues;
}
return getRecordingGrowingFormInitialValues(initialValues);
}, [initialValues, isLayingCategory]);
const formik = useFormik<
RecordingGrowingFormValues | RecordingLayingFormValues
>({
initialValues: formikInitialValues,
validationSchema:
type === 'edit'
validationSchema: (() => {
if (isLayingCategory) {
return type === 'edit'
? UpdateRecordingLayingFormSchema
: RecordingLayingFormSchema;
}
return type === 'edit'
? UpdateRecordingGrowingFormSchema
: RecordingGrowingFormSchema,
: RecordingGrowingFormSchema;
})(),
validateOnChange: true,
validateOnBlur: true,
onSubmit: async (values) => {
const payload: CreateGrowingRecordingPayload = {
project_flock_kandangs_id: values.project_flock_kandangs_id,
body_weights: (values.body_weights ?? []).map((bw) => ({
avg_weight:
typeof bw.avg_weight === 'number'
? bw.avg_weight
: parseFloat(String(bw.avg_weight)) || 0,
qty: bw.qty || 0,
})),
stocks: (values.stocks ?? []).map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
usage_qty: stock.usage_qty || 0,
})),
depletions: (values.depletions ?? []).map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id,
qty: depletion.qty || 0,
})),
};
if (isLayingCategory) {
const layingValues = values as RecordingLayingFormValues;
switch (type) {
case 'add':
await createRecordingHandler(payload);
break;
case 'edit':
await updateRecordingHandler(initialValues?.id as number, payload);
break;
const layingPayload = {
project_flock_kandangs_id: layingValues.project_flock_kandangs_id,
body_weights: (layingValues.body_weights ?? []).map((bw) => ({
avg_weight:
typeof bw.avg_weight === 'number'
? bw.avg_weight
: parseFloat(String(bw.avg_weight)) || 0,
qty: bw.qty || 0,
})),
stocks: (layingValues.stocks ?? []).map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
usage_qty: stock.usage_qty || 0,
})),
depletions: (layingValues.depletions ?? []).map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id,
qty: depletion.qty || 0,
})),
eggs: (layingValues.eggs ?? []).map((egg) => ({
product_warehouse_id: egg.product_warehouse_id,
qty: egg.qty || 0,
})),
};
switch (type) {
case 'add':
await createRecordingHandler(
layingPayload as CreateLayingRecordingPayload
);
break;
case 'edit':
await updateRecordingHandler(
initialValues?.id as number,
layingPayload as UpdateLayingRecordingPayload
);
break;
}
} else {
const growingValues = values as RecordingGrowingFormValues;
const growingPayload = {
project_flock_kandangs_id: growingValues.project_flock_kandangs_id,
body_weights: (growingValues.body_weights ?? []).map((bw) => ({
avg_weight:
typeof bw.avg_weight === 'number'
? bw.avg_weight
: parseFloat(String(bw.avg_weight)) || 0,
qty: bw.qty || 0,
})),
stocks: (growingValues.stocks ?? []).map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
usage_qty: stock.usage_qty || 0,
})),
depletions: (growingValues.depletions ?? []).map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id,
qty: depletion.qty || 0,
})),
};
switch (type) {
case 'add':
await createRecordingHandler(
growingPayload as CreateGrowingRecordingPayload
);
break;
case 'edit':
await updateRecordingHandler(
initialValues?.id as number,
growingPayload as UpdateGrowingRecordingPayload
);
break;
}
}
},
});
@@ -436,7 +542,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const getAvailableStock = useCallback(
(productWarehouseId: number) => {
if (type === 'detail') return 0;
if ((type as 'add' | 'edit' | 'detail') === 'detail') return 0;
if (!isResponseSuccess(stockProducts)) return 0;
const productWarehouse = stockProducts.data.find(
(pw) => pw.id === productWarehouseId
@@ -448,7 +554,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const getStockUsageError = useCallback(
(stockIdx: number) => {
if (type === 'detail') return null;
if ((type as 'add' | 'edit' | 'detail') === 'detail') return null;
const stock = formik.values.stocks?.[stockIdx];
if (!stock || !stock.product_warehouse_id) return null;
const availableStock = getAvailableStock(stock.product_warehouse_id);
@@ -463,7 +569,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const getStockUsageAdornment = useCallback(
(stockIdx: number) => {
if (type === 'detail') return null;
if ((type as 'add' | 'edit' | 'detail') === 'detail') return null;
const stock = formik.values.stocks?.[stockIdx];
if (!stock || !stock.product_warehouse_id) return null;
const availableStock = getAvailableStock(stock.product_warehouse_id);
@@ -566,7 +672,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
);
const hasExceededStock = useMemo(() => {
if (type === 'detail') return false;
if ((type as 'add' | 'edit' | 'detail') === 'detail') return false;
return (
formik.values.stocks?.some((stock, idx) => {
return getStockUsageError(idx) !== null;
@@ -574,40 +680,35 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
);
}, [formik.values.stocks, getStockUsageError, type]);
const isRepeaterInputError = <
T extends 'body_weights' | 'stocks' | 'depletions',
>(
arrayName: T,
column: T extends 'body_weights'
? keyof RecordingGrowingFormValues['body_weights'][0]
: T extends 'stocks'
? keyof RecordingGrowingFormValues['stocks'][0]
: T extends 'depletions'
? keyof RecordingGrowingFormValues['depletions'][0]
: never,
const isRepeaterInputError = (
arrayName: 'body_weights' | 'stocks' | 'depletions' | 'eggs',
column: string,
idx: number
) => {
if (
!formik.touched[arrayName] ||
!Array.isArray(formik.touched[arrayName])
) {
const touched = formik.touched as Record<string, unknown>;
const errors = formik.errors as Record<string, unknown>;
if (!touched[arrayName] || !Array.isArray(touched[arrayName])) {
return {
isError: false,
errorMessage: '',
};
}
const touchedField = formik.touched[arrayName]?.[idx]?.[column as string];
const errorField = formik.errors[arrayName]?.[idx] as Record<
const touchedField = (touched[arrayName] as unknown[])?.[idx] as Record<
string,
string
unknown
>;
const errorField = (errors[arrayName] as unknown[])?.[idx] as Record<
string,
unknown
>;
return {
isError: touchedField && Boolean(errorField?.[column as string]),
isError: touchedField && Boolean(errorField?.[column]),
errorMessage:
touchedField && errorField?.[column as string]
? errorField[column as string]
touchedField && errorField?.[column]
? (errorField[column] as string)
: '',
};
};
@@ -899,7 +1000,51 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedDepletions([]);
};
// Eggs Handlers
const addEgg = () => {
const newEggs = [
...((formik.values as RecordingLayingFormValues).eggs || []),
{
product_warehouse_id: 0,
qty: 0,
},
];
formik.setFieldValue('eggs', newEggs);
};
const handleEggQtyChangeWrapper = useCallback(
(idx: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value) || 0;
formik.setFieldValue(`eggs.${idx}.qty`, value);
},
[formik]
);
const removeEgg = (idx: number) => {
const updatedEggs = (
formik.values as RecordingLayingFormValues
).eggs?.filter((_, i) => i !== idx);
formik.setFieldValue('eggs', updatedEggs);
};
const removeSelectedEggs = () => {
const updatedEggs = (
formik.values as RecordingLayingFormValues
).eggs?.filter((_, idx) => !selectedEggs.includes(idx));
formik.setFieldValue('eggs', updatedEggs);
setSelectedEggs([]);
};
// ===== EFFECTS =====
useEffect(() => {
if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') {
const layingValues = formik.values as RecordingLayingFormValues;
if (!layingValues.eggs || layingValues.eggs.length === 0) {
formik.setFieldValue('eggs', [{ product_warehouse_id: 0, qty: 0 }]);
}
}
}, [isLayingCategory, type, formik]);
useEffect(() => {
if (formik.values.body_weights && editingAverageIndex === null) {
const updatedBodyWeights = formik.values.body_weights.map(
@@ -976,7 +1121,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
: 'grid grid-cols-3 gap-4'
}
>
{type === 'detail' ? null : (
{(type as 'add' | 'edit' | 'detail') === 'detail' ? null : (
<>
<SelectInput
required
@@ -1033,18 +1178,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</>
)}
{type === 'detail' && formik.values.project_flock_kandang && (
<div className='form-control'>
<label className='label'>
<span className='label-text font-semibold'>
Project Flock - Kandang
</span>
</label>
<div className='input input-bordered bg-gray-50'>
{formik.values.project_flock_kandang.label}
{(type as 'add' | 'edit' | 'detail') === 'detail' &&
formik.values.project_flock_kandang && (
<div className='form-control'>
<label className='label'>
<span className='label-text font-semibold'>
Project Flock - Kandang
</span>
</label>
<div className='input input-bordered bg-gray-50'>
{formik.values.project_flock_kandang.label}
</div>
</div>
</div>
)}
)}
</div>
</Card>
@@ -1060,7 +1206,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<table className='table'>
<thead>
<tr>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>
<CheckboxInput
name='select-all-body-weights'
@@ -1116,13 +1262,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<span className='text-error'>*</span>
</span>
</th>
{type !== 'detail' && <th>Action</th>}
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>Action</th>
)}
</tr>
</thead>
<tbody>
{formik.values.body_weights?.map((bw, idx) => (
<tr key={`body-weight-${idx}`}>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'>
<CheckboxInput
name={`body-weight-${idx}`}
@@ -1232,7 +1380,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
/>
</td>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td>
<div className='flex items-center'>
<Button
@@ -1254,7 +1402,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</tbody>
</table>
</div>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'>
{selectedBodyWeights.length > 0 && (
<Button
@@ -1293,7 +1441,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<table className='table'>
<thead>
<tr>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>
<CheckboxInput
name='select-all-stocks'
@@ -1338,13 +1486,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<span className='text-error'>*</span>
</span>
</th>
{type !== 'detail' && <th>Action</th>}
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>Action</th>
)}
</tr>
</thead>
<tbody>
{formik.values.stocks?.map((stock, idx) => (
<tr key={`stock-${idx}`}>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'>
<CheckboxInput
name={`stock-${idx}`}
@@ -1444,10 +1594,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}}
placeholder='Jumlah Pakai'
/>
{type !== 'detail' && getStockUsageAdornment(idx)}
{(type as 'add' | 'edit' | 'detail') !== 'detail' &&
getStockUsageAdornment(idx)}
</div>
</td>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td>
<div className='flex items-center'>
<Button
@@ -1469,7 +1620,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</tbody>
</table>
</div>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'>
{selectedStocks.length > 0 && (
<Button
@@ -1508,7 +1659,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<table className='table'>
<thead>
<tr>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>
<CheckboxInput
name='select-all-depletions'
@@ -1555,13 +1706,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<span className='text-error'>*</span>
</span>
</th>
{type !== 'detail' && <th>Action</th>}
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>Action</th>
)}
</tr>
</thead>
<tbody>
{formik.values.depletions?.map((depletion, idx) => (
<tr key={`depletion-${idx}`}>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'>
<CheckboxInput
name={`depletion-${idx}`}
@@ -1653,7 +1806,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
placeholder='Jumlah'
/>
</td>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td>
<div className='flex items-center'>
<Button
@@ -1675,7 +1828,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</tbody>
</table>
</div>
{type !== 'detail' && (
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'>
{selectedDepletions.length > 0 && (
<Button
@@ -1702,6 +1855,220 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)}
</Card>
{/* Eggs Table - Only for LAYING Category */}
{isLayingCategory && (
<Card
title='Telur'
className={{
wrapper: 'w-full mb-4 shadow',
title: 'mb-4',
}}
>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>
<CheckboxInput
name='select-all-eggs'
checked={
((formik.values as RecordingLayingFormValues).eggs
?.length ?? 0) === selectedEggs.length &&
((formik.values as RecordingLayingFormValues).eggs
?.length ?? 0) > 0
}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedEggs(
(
formik.values as RecordingLayingFormValues
).eggs?.map((_, idx) => idx) ?? []
);
} else {
setSelectedEggs([]);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</th>
)}
<th>
Produk Telur
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>
Jumlah
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>Action</th>
)}
</tr>
</thead>
<tbody>
{(formik.values as RecordingLayingFormValues).eggs?.map(
(egg, idx) => (
<tr key={`egg-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'>
<CheckboxInput
name={`egg-${idx}`}
checked={selectedEggs.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedEggs([...selectedEggs, idx]);
} else {
setSelectedEggs(
selectedEggs.filter((i) => i !== idx)
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</td>
)}
<td>
<SelectInput
required
value={
depletionProducts.find(
(product) =>
product.value === egg.product_warehouse_id
) || null
}
onChange={(selectedOption) => {
const option =
selectedOption as OptionType | null;
formik.setFieldValue(
`eggs.${idx}.product_warehouse_id`,
option?.value || 0
);
}}
options={depletionProducts}
placeholder='Pilih Produk Telur'
isLoading={isLoadingEggProducts}
isError={
isRepeaterInputError(
'eggs',
'product_warehouse_id',
idx
).isError
}
errorMessage={
isRepeaterInputError(
'eggs',
'product_warehouse_id',
idx
).errorMessage
}
isDisabled={type === 'detail'}
className={{
wrapper: 'w-full min-w-48',
}}
isSearchable
isClearable={type !== 'detail'}
/>
</td>
<td>
<div className='flex flex-col gap-1'>
<NumberInput
required
name={`eggs.${idx}.qty`}
value={egg.qty}
onChange={handleEggQtyChangeWrapper(idx)}
onBlur={formik.handleBlur}
decimalScale={0}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
isError={
isRepeaterInputError('eggs', 'qty', idx)
.isError
}
errorMessage={
isRepeaterInputError('eggs', 'qty', idx)
.errorMessage
}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
placeholder='Jumlah'
/>
</div>
</td>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td>
<div className='flex items-center'>
<Button
type='button'
color='error'
onClick={() => removeEgg(idx)}
>
<Icon
icon='mdi:trash-can'
width={24}
height={24}
/>
</Button>
</div>
</td>
)}
</tr>
)
)}
</tbody>
</table>
</div>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'>
{selectedEggs.length > 0 && (
<Button
type='button'
color='error'
onClick={removeSelectedEggs}
disabled={selectedEggs.length === 0}
className='w-fit'
>
<Icon icon='mdi:trash-can' width={24} height={24} />
Hapus Terpilih ({selectedEggs.length})
</Button>
)}
<Button
type='button'
color='success'
onClick={addEgg}
className='w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Telur
</Button>
</div>
)}
</Card>
)}
{/* Action buttons */}
<FormActions<RecordingGrowingFormValues>
type={type}
@@ -1750,7 +2117,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
/>
{/* Approve Confirmation Modal */}
{type === 'detail' && (
{(type as 'add' | 'edit' | 'detail') === 'detail' && (
<ConfirmationModal
ref={approveModal.ref}
type='success'
@@ -1768,7 +2135,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)}
{/* Reject Confirmation Modal */}
{type === 'detail' && (
{(type as 'add' | 'edit' | 'detail') === 'detail' && (
<ConfirmationModal
ref={rejectModal.ref}
type='error'