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, DeliveryProductToFieldValues,
mergeSOwithDO, mergeSOwithDO,
SalesProductToFieldValues, SalesProductToFieldValues,
} from '@/components/pages/marketing/form/MarketingForm';
import {
DeliveryOrderFormValues, DeliveryOrderFormValues,
DeliveryOrderSchema, DeliveryOrderSchema,
getFilledMarketingFormInitialValues, getFilledMarketingFormInitialValues,
@@ -210,7 +208,6 @@ const SalesOrderFormModal = ({
convertion_unit: normalizedConvertionUnit, convertion_unit: normalizedConvertionUnit,
weight_per_convertion: weight_per_convertion:
product.weight_per_convertion ?? undefined, product.weight_per_convertion ?? undefined,
week: product.weeks?.value ?? undefined,
} as CreateSalesOrderProductPayload; } as CreateSalesOrderProductPayload;
}), }),
} as CreateSalesOrderPayload) } as CreateSalesOrderPayload)
@@ -94,7 +94,7 @@ export type SalesOrderFormValues = Yup.InferType<typeof SalesOrderSchema>;
export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>; export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
// ================ Helper Function ================ // ================ Helper Function ================
const SalesProductToFieldValues = ( export const SalesProductToFieldValues = (
product: BaseSalesOrder product: BaseSalesOrder
): SalesOrderProductFormValues => { ): SalesOrderProductFormValues => {
return { return {
@@ -123,9 +123,11 @@ const SalesProductToFieldValues = (
value: product.convertion_unit, value: product.convertion_unit,
label: formatTitleCase(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[], salesOrders: BaseSalesOrder[],
delivery: BaseDeliveryOrder delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => { ): 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; price_sisa_berat?: number | null | undefined;
/** Harga per butir telur untuk TELUR + QTY */ /** Harga per butir telur untuk TELUR + QTY */
price_per_qty?: number | null | undefined; price_per_qty?: number | null | undefined;
weeks?: {
value: number;
label: string;
} | null;
}; };
export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> = export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
@@ -92,13 +88,6 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
sisa_berat: Yup.number().nullable().optional().notRequired(), sisa_berat: Yup.number().nullable().optional().notRequired(),
price_sisa_berat: Yup.number().nullable().optional().notRequired(), price_sisa_berat: Yup.number().nullable().optional().notRequired(),
price_per_qty: 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< export type SalesOrderProductFormValues = Yup.InferType<
@@ -5,7 +5,7 @@ import {
SalesOrderProductFormValues, SalesOrderProductFormValues,
SalesOrderProductSchema, SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema'; } 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 { OptionType, useSelect } from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data'; import { WarehouseApi } from '@/services/api/master-data';
@@ -49,7 +49,33 @@ const SalesOrderProductForm = ({
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
const [selectedProductWarehouse, setSelectedProductWarehouse] = const [selectedProductWarehouse, setSelectedProductWarehouse] =
useState<ProductWarehouse | null>(null); 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 ============ // ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({ const formik = useFormik<SalesOrderProductFormValues>({
@@ -70,11 +96,13 @@ const SalesOrderProductForm = ({
initialValues?.weight_per_convertion != null initialValues?.weight_per_convertion != null
? Number(initialValues.weight_per_convertion) ? Number(initialValues.weight_per_convertion)
: null, : null,
price_per_convertion: initialPricePerConvertion,
convertion_unit: initialValues?.convertion_unit || null, convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null, marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null, total_peti: initialValues?.total_peti ?? null,
weeks: initialValues?.weeks || null,
price_per_qty: initialValues?.price_per_qty ?? null, price_per_qty: initialValues?.price_per_qty ?? null,
sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat,
}, },
validationSchema: SalesOrderProductSchema, validationSchema: SalesOrderProductSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
@@ -168,6 +196,15 @@ const SalesOrderProductForm = ({
qty: '', qty: '',
avg_weight: '', avg_weight: '',
total_price: '', 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 ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList( const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
formik, formik,
@@ -314,7 +373,7 @@ const SalesOrderProductForm = ({
/> />
)} )}
{formik.values.convertion_unit && {formik.values.convertion_unit &&
formik.values.convertion_unit.value === 'peti' && ( formik.values.convertion_unit.value.toLowerCase() === 'peti' && (
<div className='flex flex-col'> <div className='flex flex-col'>
<label className='font-semibold text-xs py-2 leading-5'> <label className='font-semibold text-xs py-2 leading-5'>
Tipe Konversi <span className='text-error'>*</span> Tipe Konversi <span className='text-error'>*</span>
@@ -387,32 +446,33 @@ const SalesOrderProductForm = ({
); );
setCurrentInput(e.target.name); setCurrentInput(e.target.name);
}} }}
onBlur={() => handleBlurField('weight_per_convertion')}
/> />
</div> </div>
</div> </div>
)} )}
{/* Konversi Satuan Weeks Pullet */} {/* Konversi Satuan Weeks Pullet */}
{formik.values.marketing_type?.value.toLowerCase() === {/* {formik.values.marketing_type?.value.toLowerCase() ===
'ayam_pullet' && ( 'ayam_pullet' && (
<SelectInputRadio <SelectInputRadio
required required
label='Minggu' label='Minggu'
options={optionsWeeks} options={optionsWeeks}
value={formik.values.weeks} value={formik.values.weeks || undefined}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue('weeks', val); formik.setFieldValue('weeks', val);
}} }}
placeholder='Pilih Weeks' placeholder='Pilih Weeks'
/> />
)} )} */}
{/* Total Peti */} {/* Total Peti */}
{formik.values.convertion_unit?.value === 'peti' && ( {formik.values.convertion_unit?.value.toLowerCase() === 'peti' && (
<NumberInput <NumberInput
required required
label='Total Peti' label='Total Peti'
name='total_pet' name='total_peti'
value={formik.values.total_peti ?? undefined} value={formik.values.total_peti ?? undefined}
onChange={(e) => { onChange={(e) => {
formik.handleChange(e); formik.handleChange(e);
@@ -429,7 +489,7 @@ const SalesOrderProductForm = ({
<span className='text-sm text-base-content/50'>Kg</span> <span className='text-sm text-base-content/50'>Kg</span>
</div> </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'> <div className='py-2 gap-3 flex items-center'>
<input <input
type='checkbox' type='checkbox'
name='sisa_berat' name='sisa_berat_toggle'
checked={hasSisaBerat} checked={hasSisaBerat}
onChange={() => { onChange={() => handleSisaBeratToggle(!hasSisaBerat)}
setHasSisaBerat(!hasSisaBerat);
}}
onBlur={() => handleBlurField('sisa_berat')}
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' 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'> <label className='text-sm text-base-content/50'>
@@ -232,7 +232,7 @@ const SalesOrderProductTable = ({
<tr> <tr>
<td className='text-sm px-4 py-3'>Total Peti</td> <td className='text-sm px-4 py-3'>Total Peti</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{item.convertion_unit?.label} {item.total_peti} {item.convertion_unit?.label}
</td> </td>
</tr> </tr>
)} )}
+79 -21
View File
@@ -199,9 +199,12 @@ export const calculateAyam = (field: string, ctx: CalculationContext): void => {
/** /**
* TELUR + PETI: Penjualan telur dalam satuan peti * 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 * - 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 = ( export const calculateTelurPeti = (
field: string, field: string,
@@ -217,27 +220,83 @@ export const calculateTelurPeti = (
: 0; : 0;
const qty = Number(values.qty || 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) { switch (field) {
case 'price_per_convertion': case 'price_per_convertion': {
case 'total_peti': // Recalculate total_price = (price_per_convertion × total_peti) + price_sisa_berat
case 'price_sisa_berat': { if (pricePerConvertion > 0 && totalPeti > 0) {
// Recalculate total_price const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat; setFieldValue('total_price', roundPrice(totalPrice));
setFieldValue('total_price', roundPrice(totalPrice)); // Recalculate unit_price = total_price / total_weight
// Recalculate unit_price (normalized for BE) const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
if (qty > 0) { updateUnitPrice(totalPrice, totalWeight);
setFieldValue('unit_price', roundPrice(totalPrice / qty)); }
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; break;
} }
case 'weight_per_convertion':
case 'sisa_berat': { case 'sisa_berat': {
// Recalculate total_weight // Recalculate total_weight
const totalWeight = weightPerConvertion * totalPeti + sisaBerat; if (weightPerConvertion > 0 && totalPeti > 0) {
setFieldValue('total_weight', roundWeight(totalWeight)); const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
// Recalculate avg_weight setFieldValue('total_weight', roundWeight(totalWeight));
if (qty > 0) { // Recalculate avg_weight = total_weight / qty
setFieldValue('avg_weight', preciseWeight(totalWeight / 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; break;
} }
@@ -250,10 +309,9 @@ export const calculateTelurPeti = (
roundPrice((totalPrice - priceSisaBerat) / totalPeti) roundPrice((totalPrice - priceSisaBerat) / totalPeti)
); );
} }
// Update unit_price (normalized for BE) // Update unit_price = total_price / total_weight
if (qty > 0) { const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
setFieldValue('unit_price', roundPrice(totalPrice / qty)); updateUnitPrice(totalPrice, totalWeight);
}
break; break;
} }
} }
+2
View File
@@ -38,6 +38,8 @@ export type BaseSalesOrder = {
vehicle_number: string; vehicle_number: string;
marketing_type: string; marketing_type: string;
convertion_unit: string; convertion_unit: string;
total_peti: number;
weight_per_convertion: number;
}; };
export type BaseDeliveryOrder = { export type BaseDeliveryOrder = {