feat(FE): calculation penjualan telur + peti

This commit is contained in:
randy-ar
2026-02-05 04:41:46 +07:00
parent dfd86a04e0
commit cb22fd1037
7 changed files with 166 additions and 53 deletions
@@ -14,8 +14,6 @@ import {
DeliveryProductToFieldValues,
mergeSOwithDO,
SalesProductToFieldValues,
} from '@/components/pages/marketing/form/MarketingForm';
import {
DeliveryOrderFormValues,
DeliveryOrderSchema,
getFilledMarketingFormInitialValues,
@@ -210,7 +208,6 @@ const SalesOrderFormModal = ({
convertion_unit: normalizedConvertionUnit,
weight_per_convertion:
product.weight_per_convertion ?? undefined,
week: product.weeks?.value ?? undefined,
} as CreateSalesOrderProductPayload;
}),
} as CreateSalesOrderPayload)
@@ -94,7 +94,7 @@ export type SalesOrderFormValues = Yup.InferType<typeof SalesOrderSchema>;
export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
// ================ Helper Function ================
const SalesProductToFieldValues = (
export const SalesProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
return {
@@ -123,9 +123,11 @@ const SalesProductToFieldValues = (
value: product.convertion_unit,
label: formatTitleCase(product.convertion_unit),
},
total_peti: product.total_peti,
weight_per_convertion: product.weight_per_convertion,
};
};
const DeliveryProductToFieldValues = (
export const DeliveryProductToFieldValues = (
salesOrders: BaseSalesOrder[],
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
@@ -221,3 +223,11 @@ export const getFilledMarketingFormInitialValues = (
),
};
};
export const getPricePerConvertion = (
totalPrice: number,
weightPerConvertion: number,
totalPeti: number
) => {
return totalPrice / (weightPerConvertion * totalPeti);
};
@@ -34,10 +34,6 @@ type SalesOrderProductSchemaType = {
price_sisa_berat?: number | null | undefined;
/** Harga per butir telur untuk TELUR + QTY */
price_per_qty?: number | null | undefined;
weeks?: {
value: number;
label: string;
} | null;
};
export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
@@ -92,13 +88,6 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
sisa_berat: Yup.number().nullable().optional().notRequired(),
price_sisa_berat: Yup.number().nullable().optional().notRequired(),
price_per_qty: Yup.number().nullable().optional().notRequired(),
weeks: Yup.object({
value: Yup.number().required('Minggu wajib diisi!'),
label: Yup.string().required('Minggu wajib diisi!'),
})
.nullable()
.optional()
.notRequired(),
});
export type SalesOrderProductFormValues = Yup.InferType<
@@ -5,7 +5,7 @@ import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, useMemo, useState } from 'react';
import { RefObject, useEffect, useMemo, useState } from 'react';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data';
@@ -49,7 +49,33 @@ const SalesOrderProductForm = ({
const [currentInput, setCurrentInput] = useState<string>('');
const [selectedProductWarehouse, setSelectedProductWarehouse] =
useState<ProductWarehouse | null>(null);
const [hasSisaBerat, setHasSisaBerat] = useState<boolean>(false);
// Check jika ada sisa berat = total_weight - (weight_per_convertion * total_peti)
const initialSisaBerat =
initialValues?.total_weight &&
initialValues?.weight_per_convertion &&
initialValues?.total_peti
? Number(initialValues.total_weight) -
Number(initialValues.weight_per_convertion) *
Number(initialValues.total_peti)
: 0;
const initialPricePerConvertion =
initialValues?.total_price &&
initialValues?.total_peti &&
Number(initialValues.total_peti) !== 0
? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti)
: 0;
const initialPriceSisaBerat =
Number(initialValues?.total_price) -
initialPricePerConvertion * Number(initialValues?.total_peti);
const [hasSisaBerat, setHasSisaBerat] = useState<boolean>(
initialSisaBerat > 0
);
// ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({
@@ -70,11 +96,13 @@ const SalesOrderProductForm = ({
initialValues?.weight_per_convertion != null
? Number(initialValues.weight_per_convertion)
: null,
price_per_convertion: initialPricePerConvertion,
convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null,
weeks: initialValues?.weeks || null,
price_per_qty: initialValues?.price_per_qty ?? null,
sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat,
},
validationSchema: SalesOrderProductSchema,
onSubmit: async (values) => {
@@ -168,6 +196,15 @@ const SalesOrderProductForm = ({
qty: '',
avg_weight: '',
total_price: '',
total_peti: null,
price_per_qty: null,
price_sisa_berat: null,
sisa_berat: null,
convertion_unit: null,
marketing_type: null,
weight_per_convertion: null,
price_per_convertion: null,
uom: '',
},
});
};
@@ -182,6 +219,28 @@ const SalesOrderProductForm = ({
});
};
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
setHasSisaBerat(newHasSisaBerat);
if (!newHasSisaBerat) {
// Ketika OFF - set nilai ke 0 dan recalculate tanpa sisa
formik.setFieldValue('sisa_berat', 0);
formik.setFieldValue('price_sisa_berat', 0);
}
// Langsung trigger recalculation dengan hasSisaBerat yang baru
handleMarketingCalculation('total_peti', {
values: {
...formik.values,
sisa_berat: newHasSisaBerat ? formik.values.sisa_berat : 0,
price_sisa_berat: newHasSisaBerat ? formik.values.price_sisa_berat : 0,
},
setFieldValue: formik.setFieldValue,
hasSisaBerat: newHasSisaBerat,
});
};
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
formik,
@@ -314,7 +373,7 @@ const SalesOrderProductForm = ({
/>
)}
{formik.values.convertion_unit &&
formik.values.convertion_unit.value === 'peti' && (
formik.values.convertion_unit.value.toLowerCase() === 'peti' && (
<div className='flex flex-col'>
<label className='font-semibold text-xs py-2 leading-5'>
Tipe Konversi <span className='text-error'>*</span>
@@ -387,32 +446,33 @@ const SalesOrderProductForm = ({
);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('weight_per_convertion')}
/>
</div>
</div>
)}
{/* Konversi Satuan Weeks Pullet */}
{formik.values.marketing_type?.value.toLowerCase() ===
{/* {formik.values.marketing_type?.value.toLowerCase() ===
'ayam_pullet' && (
<SelectInputRadio
required
label='Minggu'
options={optionsWeeks}
value={formik.values.weeks}
value={formik.values.weeks || undefined}
onChange={(val) => {
formik.setFieldValue('weeks', val);
}}
placeholder='Pilih Weeks'
/>
)}
)} */}
{/* Total Peti */}
{formik.values.convertion_unit?.value === 'peti' && (
{formik.values.convertion_unit?.value.toLowerCase() === 'peti' && (
<NumberInput
required
label='Total Peti'
name='total_pet'
name='total_peti'
value={formik.values.total_peti ?? undefined}
onChange={(e) => {
formik.handleChange(e);
@@ -429,7 +489,7 @@ const SalesOrderProductForm = ({
<span className='text-sm text-base-content/50'>Kg</span>
</div>
}
bottomLabel={`1 ${formik.values.convertion_unit?.value} = ${formik.values.weight_per_convertion ?? 0} Kg`}
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
/>
)}
@@ -586,12 +646,9 @@ const SalesOrderProductForm = ({
<div className='py-2 gap-3 flex items-center'>
<input
type='checkbox'
name='sisa_berat'
name='sisa_berat_toggle'
checked={hasSisaBerat}
onChange={() => {
setHasSisaBerat(!hasSisaBerat);
}}
onBlur={() => handleBlurField('sisa_berat')}
onChange={() => handleSisaBeratToggle(!hasSisaBerat)}
className='toggle toggle-primary rounded-full before:rounded-full before:bg-base-content/50 border-base-content/50 checked:border-primary checked:bg-primary checked:before:bg-base-100'
/>
<label className='text-sm text-base-content/50'>
@@ -232,7 +232,7 @@ const SalesOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Total Peti</td>
<td className='text-sm px-4 py-3'>
{item.convertion_unit?.label}
{item.total_peti} {item.convertion_unit?.label}
</td>
</tr>
)}
+79 -21
View File
@@ -199,9 +199,12 @@ export const calculateAyam = (field: string, ctx: CalculationContext): void => {
/**
* TELUR + PETI: Penjualan telur dalam satuan peti
* - Formula: total_price = (price_per_convertion × total_peti) + price_sisa_berat
*
* Formulas:
* - total_weight = (weight_per_convertion × total_peti) + sisa_berat
* - Payload: unit_price = total_price / qty (normalisasi untuk BE)
* - total_price = (price_per_convertion × total_peti) + price_sisa_berat
* - unit_price = total_price / total_weight (untuk BE)
* - avg_weight = total_weight / qty
*/
export const calculateTelurPeti = (
field: string,
@@ -217,27 +220,83 @@ export const calculateTelurPeti = (
: 0;
const qty = Number(values.qty || 0);
// Helper untuk menghitung dan set unit_price = total_price / total_weight
const updateUnitPrice = (tp: number, tw: number) => {
if (tw > 0 && tp > 0) {
setFieldValue('unit_price', roundPrice(tp / tw));
}
};
switch (field) {
case 'price_per_convertion':
case 'total_peti':
case 'price_sisa_berat': {
// Recalculate total_price
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
setFieldValue('total_price', roundPrice(totalPrice));
// Recalculate unit_price (normalized for BE)
if (qty > 0) {
setFieldValue('unit_price', roundPrice(totalPrice / qty));
case 'price_per_convertion': {
// Recalculate total_price = (price_per_convertion × total_peti) + price_sisa_berat
if (pricePerConvertion > 0 && totalPeti > 0) {
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
setFieldValue('total_price', roundPrice(totalPrice));
// Recalculate unit_price = total_price / total_weight
const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
updateUnitPrice(totalPrice, totalWeight);
}
break;
}
case 'total_peti': {
// Recalculate total_weight = (weight_per_convertion × total_peti) + sisa_berat
let totalWeight = 0;
if (weightPerConvertion > 0 && totalPeti > 0) {
totalWeight = weightPerConvertion * totalPeti + sisaBerat;
setFieldValue('total_weight', roundWeight(totalWeight));
// Recalculate avg_weight = total_weight / qty
if (qty > 0) {
setFieldValue('avg_weight', preciseWeight(totalWeight / qty));
}
}
// Recalculate total_price = (price_per_convertion × total_peti) + price_sisa_berat
if (pricePerConvertion > 0 && totalPeti > 0) {
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
setFieldValue('total_price', roundPrice(totalPrice));
// Recalculate unit_price = total_price / total_weight
updateUnitPrice(totalPrice, totalWeight);
}
break;
}
case 'price_sisa_berat': {
// Recalculate total_price
if (pricePerConvertion > 0 && totalPeti > 0) {
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
setFieldValue('total_price', roundPrice(totalPrice));
// Recalculate unit_price = total_price / total_weight
const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
updateUnitPrice(totalPrice, totalWeight);
}
break;
}
case 'weight_per_convertion': {
// Recalculate total_weight = (weight_per_convertion × total_peti) + sisa_berat
if (weightPerConvertion > 0 && totalPeti > 0) {
const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
setFieldValue('total_weight', roundWeight(totalWeight));
// Recalculate avg_weight = total_weight / qty
if (qty > 0) {
setFieldValue('avg_weight', preciseWeight(totalWeight / qty));
}
// Recalculate unit_price = total_price / total_weight
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
updateUnitPrice(totalPrice, totalWeight);
}
break;
}
case 'weight_per_convertion':
case 'sisa_berat': {
// Recalculate total_weight
const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
setFieldValue('total_weight', roundWeight(totalWeight));
// Recalculate avg_weight
if (qty > 0) {
setFieldValue('avg_weight', preciseWeight(totalWeight / qty));
if (weightPerConvertion > 0 && totalPeti > 0) {
const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
setFieldValue('total_weight', roundWeight(totalWeight));
// Recalculate avg_weight = total_weight / qty
if (qty > 0) {
setFieldValue('avg_weight', preciseWeight(totalWeight / qty));
}
// Recalculate unit_price = total_price / total_weight
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
updateUnitPrice(totalPrice, totalWeight);
}
break;
}
@@ -250,10 +309,9 @@ export const calculateTelurPeti = (
roundPrice((totalPrice - priceSisaBerat) / totalPeti)
);
}
// Update unit_price (normalized for BE)
if (qty > 0) {
setFieldValue('unit_price', roundPrice(totalPrice / qty));
}
// Update unit_price = total_price / total_weight
const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
updateUnitPrice(totalPrice, totalWeight);
break;
}
}
+2
View File
@@ -38,6 +38,8 @@ export type BaseSalesOrder = {
vehicle_number: string;
marketing_type: string;
convertion_unit: string;
total_peti: number;
weight_per_convertion: number;
};
export type BaseDeliveryOrder = {