From 22f1a32e1b0dc699655f64f63d1a392429235561 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 11:59:22 +0700 Subject: [PATCH 001/174] feat(FE-137): integrate API for daily recording with enhanced data structure and validation --- .../recording/form/RecordingForm.schema.ts | 275 +-- .../recording/form/RecordingForm.tsx | 1903 ++++++----------- src/types/api/production/recording.d.ts | 86 +- 3 files changed, 764 insertions(+), 1500 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 4b0b37dd..beffa26a 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -1,212 +1,167 @@ import * as Yup from 'yup'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; -import { Recording } from '@/types/api/production/recording'; +import { + Recording, + CreateRecordingPayload, +} from '@/types/api/production/recording'; export const RecordingFormSchema = Yup.object({ - flock: Yup.object({ + project_flock_kandang: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - flock_id: Yup.number() + project_flock_kandang_id: Yup.number() .default(0) - .typeError('Flock wajib diisi!') + .typeError('Project Flock Kandang wajib diisi!') .test( - 'is-valid-flock', - 'Flock wajib diisi!', + 'is-valid-project-flock-kandang', + 'Project Flock Kandang wajib diisi!', (value) => value !== undefined && value !== null && value > 0 ) - .required('Flock wajib diisi!'), - location: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - location_id: Yup.number() - .default(0) - .typeError('Lokasi wajib diisi!') - .test( - 'is-valid-location', - 'Lokasi wajib diisi!', - (value) => value !== undefined && value !== null && value > 0 - ) - .required('Lokasi wajib diisi!'), - coop: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - coop_id: Yup.number() - .default(0) - .typeError('Kandang wajib diisi!') - .test( - 'is-valid-coop', - 'Kandang wajib diisi!', - (value) => value !== undefined && value !== null && value > 0 - ) - .required('Kandang wajib diisi!'), - recording_date: Yup.date() - .required('Tanggal recording wajib diisi') - .typeError('Format tanggal tidak valid'), - feed_data: Yup.array() + .required('Project Flock Kandang wajib diisi!'), + record_datetime: Yup.date() + .required('Tanggal dan waktu recording wajib diisi') + .typeError('Format tanggal dan waktu tidak valid'), + status: Yup.number() + .optional() + .oneOf([0, 1, 2, 3], 'Status tidak valid') + .typeError('Status harus berupa angka!'), + ontime: Yup.boolean().optional(), + body_weights: Yup.array() .of( Yup.object({ - feed_id: Yup.string().required('Nama pakan wajib diisi!'), - feed_qty: Yup.mixed().notRequired(), - feed_stock: Yup.number() - .required('Jumlah pakan yang digunakan wajib diisi!') - .min(1, 'Jumlah pakan minimal 1!') - .typeError('Jumlah pakan yang digunakan harus berupa angka!') - .test( - 'is-not-exceed-qty', - 'Jumlah pakan yang digunakan tidak boleh melebihi stok tersedia!', - function (value) { - const { feed_qty } = this.parent; - if (value === undefined) return true; - if ( - feed_qty === undefined || - feed_qty === '' || - typeof feed_qty !== 'number' - ) - return true; - return value <= feed_qty; - } - ), - }) - ) - .min(1, 'Minimal harus ada 1 data pakan!') - .required('Data pakan wajib diisi!'), - body_weight: Yup.array() - .of( - Yup.object({ - chicken_weight: Yup.number() + weight: Yup.number() .required('Berat ayam wajib diisi!') .min(1, 'Berat ayam minimal 1 gram!') .typeError('Berat ayam harus berupa angka!'), - chicken_count: Yup.number() + qty: Yup.number() .required('Jumlah ayam wajib diisi!') .min(1, 'Jumlah ayam minimal 1 ekor!') - .typeError('Jumlah ayam harus berupa angka!'), - average_chicken_weight: Yup.number() - .required('Rata-rata berat ayam wajib diisi!') - .min(1, 'Rata-rata berat ayam minimal 1 gram!') - .typeError('Rata-rata berat ayam harus berupa angka!'), + .typeError('Jumlah ayam harus berupa angka!') + .default(1), + notes: Yup.string().optional(), }) ) .min(1, 'Minimal harus ada 1 data bobot badan!') .required('Data bobot badan wajib diisi!'), - vaccination: Yup.array() + stocks: Yup.array() .of( Yup.object({ - vaccine_id: Yup.string().required('Nama vaksin wajib diisi!'), - total_stock: Yup.mixed().notRequired(), - used_stock: Yup.number() - .required('Jumlah vaksin yang digunakan wajib diisi!') - .min(1, 'Jumlah vaksin minimal 1!') - .typeError('Jumlah vaksin yang digunakan harus berupa angka!') - .test( - 'is-not-exceed-total', - 'Jumlah vaksin yang digunakan tidak boleh melebihi stok tersedia!', - function (value) { - const { total_stock } = this.parent; - if (value === undefined) return true; - if ( - total_stock === undefined || - total_stock === '' || - typeof total_stock !== 'number' - ) - return true; - return value <= total_stock; - } - ), + product_warehouse_id: Yup.number() + .required('Produk wajib diisi!') + .min(1, 'Produk wajib diisi!') + .typeError('Produk harus berupa angka!'), + increase: Yup.number() + .optional() + .min(0, 'Penambahan tidak boleh negatif!') + .typeError('Penambahan harus berupa angka!'), + decrease: Yup.number() + .optional() + .min(0, 'Pengurangan tidak boleh negatif!') + .typeError('Pengurangan harus berupa angka!'), + usage_amount: Yup.number() + .optional() + .min(0, 'Jumlah penggunaan tidak boleh negatif!') + .typeError('Jumlah penggunaan harus berupa angka!'), + notes: Yup.string().optional(), }) ) - .min(1, 'Minimal harus ada 1 data vaksinasi!') - .required('Data vaksinasi wajib diisi!'), - mortality: Yup.array() + .min(1, 'Minimal harus ada 1 data stok!') + .required('Data stok wajib diisi!'), + depletions: Yup.array() .of( Yup.object({ - condition: Yup.mixed() + product_warehouse_id: Yup.number() + .required('Produk wajib diisi!') + .min(1, 'Produk wajib diisi!') + .typeError('Produk harus berupa angka!'), + condition: Yup.string() + .required('Kondisi depletions wajib diisi!') .oneOf( - RECORDING_FLAG_OPTIONS.map((opt) => opt.value), - 'Kondisi tidak valid!' + RECORDING_FLAG_OPTIONS.map((option) => option.value), + 'Kondisi depletions tidak valid!' ) - .required('Kondisi wajib diisi!'), - count: Yup.number() - .required('Jumlah mortalitas wajib diisi!') - .min(1, 'Jumlah mortalitas minimal 1 ekor!') - .typeError('Jumlah mortalitas harus berupa angka!'), + .typeError('Kondisi depletions harus berupa teks!') + .min(1, 'Kondisi depletions wajib diisi!'), + total: Yup.number() + .required('Jumlah depletions wajib diisi!') + .min(1, 'Jumlah depletions minimal 1!') + .typeError('Jumlah depletions harus berupa angka!'), + notes: Yup.string().optional(), }) ) - .min(1, 'Minimal harus ada 1 data mortalitas!') - .required('Data mortalitas wajib diisi!'), + .min(1, 'Minimal harus ada 1 data depletions!') + .required('Data depletions wajib diisi!'), }); export const UpdateRecordingFormSchema = RecordingFormSchema; export type RecordingFormValues = Yup.InferType; +type RecordingFormData = Partial & { + body_weights?: CreateRecordingPayload['body_weights']; + stocks?: CreateRecordingPayload['stocks']; + depletions?: CreateRecordingPayload['depletions']; +}; + export const getRecordingFormInitialValues = ( - initialValues?: Recording + initialValues?: RecordingFormData ): RecordingFormValues => ({ - flock: initialValues?.flock + project_flock_kandang: initialValues?.project_flock_kandang_id ? { - value: initialValues.flock.id, - label: initialValues.flock.name, + value: initialValues.project_flock_kandang_id, + label: `Project Flock Kandang #${initialValues.project_flock_kandang_id}`, } : null, - flock_id: initialValues?.flock?.id ?? 0, - location: initialValues?.location - ? { - value: initialValues.location.id, - label: initialValues.location.name, - } - : null, - location_id: initialValues?.location?.id ?? 0, - coop: initialValues?.coop - ? { - value: initialValues.coop.id, - label: initialValues.coop.name, - } - : null, - coop_id: initialValues?.coop?.id ?? 0, - recording_date: initialValues?.recording_date - ? new Date(initialValues.recording_date) + project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0, + record_datetime: initialValues?.record_datetime + ? new Date(initialValues.record_datetime) : new Date(), - feed_data: initialValues?.feed_data - ? initialValues.feed_data.map((feed) => ({ - feed_id: feed.feed_name, - feed_qty: feed.feed_qty, - feed_stock: feed.feed_stock, - })) - : [ - { - feed_id: '', - feed_qty: '', - feed_stock: 0, - }, - ], - body_weight: initialValues?.body_weight ?? [ + status: initialValues?.status ?? 1, + ontime: initialValues?.ontime ?? true, + body_weights: initialValues?.body_weights?.map( + (bw: NonNullable[0]) => ({ + weight: bw.weight, + qty: bw.qty, + notes: bw.notes || '', + }) + ) ?? [ { - chicken_weight: 0, - chicken_count: 0, - average_chicken_weight: 0, + weight: 0, + qty: 1, + notes: '', }, ], - vaccination: initialValues?.vaccination - ? initialValues.vaccination.map((vaccine) => ({ - vaccine_id: vaccine.vaccine_name, - total_stock: vaccine.total_stock, - used_stock: vaccine.used_stock, - })) - : [ - { - vaccine_id: '', - total_stock: '', - used_stock: 0, - }, - ], - mortality: initialValues?.mortality ?? [ + stocks: initialValues?.stocks?.map( + (stock: NonNullable[0]) => ({ + product_warehouse_id: stock.product_warehouse_id, + increase: stock.increase, + decrease: stock.decrease, + usage_amount: stock.usage_amount, + notes: stock.notes, + }) + ) ?? [ { + product_warehouse_id: 0, + increase: 0, + decrease: 0, + usage_amount: 0, + notes: '', + }, + ], + depletions: initialValues?.depletions?.map( + (depletion: NonNullable[0]) => ({ + product_warehouse_id: depletion.product_warehouse_id, + condition: depletion.condition, + total: depletion.total, + notes: depletion.notes, + }) + ) ?? [ + { + product_warehouse_id: 0, condition: '', - count: 0, + total: 0, + notes: '', }, ], }); diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 8c166700..41d9401e 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1,13 +1,11 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useFormik } from 'formik'; import { Icon } from '@iconify/react'; -import Button from '@/components/Button'; + import TextInput from '@/components/input/TextInput'; -import NumberInput from '@/components/input/NumberInput'; import CheckboxInput from '@/components/input/CheckboxInput'; -import SelectInput, { OptionType } from '@/components/input/SelectInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { FormHeader } from '@/components/helper/form/FormHeader'; import { FormActions } from '@/components/helper/form/FormActions'; @@ -22,15 +20,7 @@ import { UpdateRecordingFormSchema, } from './RecordingForm.schema'; import { useRecordingFormHandlers } from './useRecordingFormHandlers'; -import { ProjectFlockApi } from '@/services/api/production'; -import { isResponseSuccess } from '@/lib/api-helper'; -import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; -import useSWR from 'swr'; -import { ProductWarehouseApi } from '@/services/api/inventory'; -import { ProjectFlock } from '@/types/api/production/project-flock'; -import { Warehouse } from '@/types/api/master-data/warehouse'; -import { LocationApi } from '@/services/api/master-data'; -import FieldMessage from '@/components/helper/FieldMessage'; + import Card from '@/components/Card'; interface RecordingFormProps { @@ -39,15 +29,9 @@ interface RecordingFormProps { } const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { - const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); - const [flockSelectInputValue, setFlockSelectInputValue] = useState(''); - const [selectedProjectFlock, setSelectedProjectFlock] = - useState(null); - const [selectedFeed, setSelectedFeed] = useState([]); - const [selectedWeight, setSelectedWeight] = useState([]); - const [selectedVaccine, setSelectedVaccine] = useState([]); - const [selectedMortality, setSelectedMortality] = useState([]); - const [, setRecordingFormErrorMessage] = useState(''); + const [selectedBodyWeights, setSelectedBodyWeights] = useState([]); + const [selectedStocks, setSelectedStocks] = useState([]); + const [selectedDepletions, setSelectedDepletions] = useState([]); const { deleteModal, @@ -71,57 +55,49 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { validateOnChange: true, validateOnBlur: true, onSubmit: async (values) => { - setRecordingFormErrorMessage(''); const payload: CreateRecordingPayload = { - flock_id: values.flock_id, - location_id: values.location_id, - coop_id: values.coop_id, - recording_date: - values.recording_date instanceof Date - ? values.recording_date.toISOString() + project_flock_kandang_id: values.project_flock_kandang_id, + record_datetime: + values.record_datetime instanceof Date + ? values.record_datetime.toISOString() : '', - feed_data: (values.feed_data ?? []).map((p) => ({ - feed_id: p.feed_id, - feed_qty: - typeof p.feed_qty === 'number' - ? p.feed_qty - : parseFloat(String(p.feed_qty)) || 0, - feed_stock: - typeof p.feed_stock === 'number' - ? p.feed_stock - : parseFloat(String(p.feed_stock)) || 0, + status: values.status, + ontime: values.ontime, + body_weights: (values.body_weights ?? []).map((bw) => ({ + weight: + typeof bw.weight === 'number' + ? bw.weight + : parseFloat(String(bw.weight)) || 0, + qty: + typeof bw.qty === 'number' + ? bw.qty + : parseFloat(String(bw.qty)) || 0, + notes: bw.notes, })), - body_weight: (values.body_weight ?? []).map((b) => ({ - chicken_weight: - typeof b.chicken_weight === 'number' - ? b.chicken_weight - : parseFloat(String(b.chicken_weight)) || 0, - chicken_count: - typeof b.chicken_count === 'number' - ? b.chicken_count - : parseFloat(String(b.chicken_count)) || 0, - average_chicken_weight: - typeof b.average_chicken_weight === 'number' - ? b.average_chicken_weight - : parseFloat(String(b.average_chicken_weight)) || 0, + stocks: (values.stocks ?? []).map((stock) => ({ + product_warehouse_id: stock.product_warehouse_id, + increase: + typeof stock.increase === 'number' + ? stock.increase + : parseFloat(String(stock.increase)) || 0, + decrease: + typeof stock.decrease === 'number' + ? stock.decrease + : parseFloat(String(stock.decrease)) || 0, + usage_amount: + typeof stock.usage_amount === 'number' + ? stock.usage_amount + : parseFloat(String(stock.usage_amount)) || 0, + notes: stock.notes, })), - vaccination: (values.vaccination ?? []).map((v) => ({ - vaccine_id: v.vaccine_id, - total_stock: - typeof v.total_stock === 'number' - ? v.total_stock - : parseFloat(String(v.total_stock)) || 0, - used_stock: - typeof v.used_stock === 'number' - ? v.used_stock - : parseFloat(String(v.used_stock)) || 0, - })), - mortality: (values.mortality ?? []).map((m) => ({ - condition: m.condition, - count: - typeof m.count === 'number' - ? m.count - : parseFloat(String(m.count)) || 0, + depletions: (values.depletions ?? []).map((depletion) => ({ + product_warehouse_id: depletion.product_warehouse_id, + condition: depletion.condition, + total: + typeof depletion.total === 'number' + ? depletion.total + : parseFloat(String(depletion.total)) || 0, + notes: depletion.notes, })), }; @@ -136,465 +112,109 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, }); - // Locations - const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue }).toString()}`; - const { data: locations, isLoading: isLoadingLocations } = useSWR( - locationsUrl, - LocationApi.getAllFetcher - ); - - // Project Flocks - const projectFlocksUrl = useMemo(() => { - if (!formik.values.location_id) return null; - const params = new URLSearchParams({ - search: flockSelectInputValue, - location_id: formik.values.location_id.toString(), - }); - return `${ProjectFlockApi.basePath}?${params.toString()}`; - }, [formik.values.location_id, flockSelectInputValue]); - - const { data: projectFlocks, isLoading: isLoadingFlocks } = useSWR( - projectFlocksUrl, - ProjectFlockApi.getAllFetcher - ); - - // Pakan Products - const pakanUrl = useMemo(() => { - if (!formik.values.location_id) return null; - const params = new URLSearchParams({ - flag: 'PAKAN', - search: '', - location_id: formik.values.location_id.toString(), - }); - return `${ProductWarehouseApi.basePath}?${params.toString()}`; - }, [formik.values.location_id]); - - const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR( - pakanUrl, - ProductWarehouseApi.getAllFetcher - ); - - // OVK Products - const ovkUrl = useMemo(() => { - if (!formik.values.location_id) return null; - const params = new URLSearchParams({ - flag: 'OVK', - search: '', - location_id: formik.values.location_id.toString(), - }); - return `${ProductWarehouseApi.basePath}?${params.toString()}`; - }, [formik.values.location_id]); - - const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR( - ovkUrl, - ProductWarehouseApi.getAllFetcher - ); - - // COMPUTED VALUES - const buildWarehouseLabel = useCallback((warehouse: Warehouse) => { - const parts: string[] = [warehouse.name]; - - if ('kandang' in warehouse && warehouse.kandang) { - parts.push(warehouse.kandang.name); - } - - if ('location' in warehouse && warehouse.location) { - parts.push(warehouse.location.name); - } - - if (warehouse.area) { - parts.push(warehouse.area.name); - } - - return parts.join(' - '); - }, []); - - const locationOptions = isResponseSuccess(locations) - ? locations.data.map((loc) => ({ value: loc.id, label: loc.name })) - : []; - - const flockOptions = isResponseSuccess(projectFlocks) - ? projectFlocks.data.map((flock) => ({ - value: flock.id, - label: flock.flock.name, - })) - : []; - - const coopOptions = useMemo(() => { - if (!selectedProjectFlock || !selectedProjectFlock.kandangs) return []; - return selectedProjectFlock.kandangs.map((kandang) => ({ - value: kandang.id, - label: kandang.name, - })); - }, [selectedProjectFlock]); - - const filteredPakanProducts = useMemo(() => { - if (!isResponseSuccess(pakanProducts) || !formik.values.location_id) - return []; - - return pakanProducts.data.filter((product) => { - const warehouse = product.warehouse; - - const hasLocationMatch = - 'location' in warehouse && warehouse.location - ? warehouse.location.id === formik.values.location_id - : false; - - const hasPakanFlag = product.product.flags?.includes('PAKAN'); - - return hasLocationMatch && hasPakanFlag; - }); - }, [pakanProducts, formik.values.location_id]); - - const pakanOptions = useMemo( - () => - filteredPakanProducts.map((product) => ({ - value: product.id, - label: `${product.product.name} - ${buildWarehouseLabel(product.warehouse)} (Stock: ${product.quantity.toLocaleString('id-ID')})`, - })), - [filteredPakanProducts, buildWarehouseLabel] - ); - - const pakanStockMap = useMemo(() => { - const map = new Map(); - filteredPakanProducts.forEach((product) => { - map.set(product.id, product.quantity); - }); - return map; - }, [filteredPakanProducts]); - - const filteredOvkProducts = useMemo(() => { - if (!isResponseSuccess(ovkProducts) || !formik.values.location_id) - return []; - - return ovkProducts.data.filter((product) => { - const warehouse = product.warehouse; - - // Validate location match - const hasLocationMatch = - 'location' in warehouse && warehouse.location - ? warehouse.location.id === formik.values.location_id - : false; - - // Validate product has OVK flag - const hasOvkFlag = product.product.flags?.includes('OVK'); - - return hasLocationMatch && hasOvkFlag; - }); - }, [ovkProducts, formik.values.location_id]); - - const ovkOptions = useMemo( - () => - filteredOvkProducts.map((product) => ({ - value: product.id, - label: `${product.product.name} - ${buildWarehouseLabel(product.warehouse)} (Stock: ${product.quantity.toLocaleString('id-ID')})`, - })), - [filteredOvkProducts, buildWarehouseLabel] - ); - - const ovkStockMap = useMemo(() => { - const map = new Map(); - filteredOvkProducts.forEach((product) => { - map.set(product.id, product.quantity); - }); - return map; - }, [filteredOvkProducts]); - - // EFFECTS - useEffect(() => { - if (initialValues?.flock && isResponseSuccess(projectFlocks)) { - const flock = projectFlocks.data.find( - (f) => f.id === initialValues.flock.id - ); - if (flock) { - setSelectedProjectFlock(flock); - } - } - }, [initialValues, projectFlocks]); - - // Auto-calculate average weight when chicken weight or count changes - useEffect(() => { - if (formik.values.body_weight) { - const updatedBodyWeight = formik.values.body_weight.map((weight) => ({ - ...weight, - average_chicken_weight: - weight.chicken_count > 0 - ? Math.round(weight.chicken_weight / weight.chicken_count) - : 0, - })); - - // Only update if values are different to avoid infinite loops - const hasChanges = updatedBodyWeight.some( - (updated, idx) => - updated.average_chicken_weight !== - formik.values.body_weight[idx]?.average_chicken_weight - ); - - if (hasChanges) { - formik.setFieldValue('body_weight', updatedBodyWeight); - } - } - }, [ - formik.values.body_weight?.map((w) => w.chicken_weight), - formik.values.body_weight?.map((w) => w.chicken_count), - ]); - // EVENT HANDLERS - Select Inputs - const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - const locationValue = (val as OptionType)?.value; - - formik.setFieldValue('location', val, false); - formik.setFieldValue('location_id', locationValue || 0, false); - - formik.setFieldValue('flock', null, false); - formik.setFieldValue('flock_id', 0, false); - formik.setFieldValue('coop', null, false); - formik.setFieldValue('coop_id', 0, false); - setSelectedProjectFlock(null); - setFlockSelectInputValue(''); + const projectFlockChangeHandler = (value: string) => { + const projectFlockId = parseInt(value) || 0; + formik.setFieldValue('project_flock_kandang_id', projectFlockId, false); }; - const flockChangeHandler = (val: OptionType | OptionType[] | null) => { - const flockValue = (val as OptionType)?.value; - - const selected = isResponseSuccess(projectFlocks) - ? projectFlocks.data.find((flock) => flock.id === flockValue) - : null; - - setSelectedProjectFlock(selected || null); - - formik.setFieldValue('flock', val, false); - formik.setFieldValue('flock_id', flockValue || 0, false); - - formik.setFieldValue('coop', null, false); - formik.setFieldValue('coop_id', 0, false); - }; - - const coopChangeHandler = (val: OptionType | OptionType[] | null) => { - const coopValue = (val as OptionType)?.value; - - formik.setFieldValue('coop', val, false); - formik.setFieldValue('coop_id', coopValue || 0, false); - }; - - // EVENT HANDLERS - Feed Data - const addFeedData = () => { - const newFeedData = [ - ...(formik.values.feed_data || []), - { - feed: null, - feed_id: '', - feed_qty: '', - feed_stock: 0, - }, - ]; - formik.setFieldValue('feed_data', newFeedData); - }; - - const removeFeedData = (idx: number) => { - const updatedFeedData = formik.values.feed_data?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('feed_data', updatedFeedData); - }; - - const removeSelectedFeedData = () => { - const updatedFeedData = formik.values.feed_data?.filter( - (_, idx) => !selectedFeed.includes(idx) - ); - formik.setFieldValue('feed_data', updatedFeedData); - setSelectedFeed([]); - }; - - // EVENT HANDLERS - Body Weight + // EVENT HANDLERS - Body Weights const addBodyWeight = () => { - const newBodyWeight = [ - ...(formik.values.body_weight || []), + const newBodyWeights = [ + ...(formik.values.body_weights || []), { - chicken_weight: 0, - chicken_count: 0, - average_chicken_weight: 0, + weight: 0, + qty: 1, + notes: '', }, ]; - formik.setFieldValue('body_weight', newBodyWeight); + formik.setFieldValue('body_weights', newBodyWeights); }; - // Handle calculation when chicken_weight changes - const handleChickenWeightChange = useCallback( - (idx: number, value: number) => { - formik.setFieldValue(`body_weight.${idx}.chicken_weight`, value); - - const currentWeight = formik.values.body_weight?.[idx]; - if (currentWeight) { - const chickenCount = currentWeight.chicken_count; - if (chickenCount > 0 && value > 0) { - const averageWeight = Math.round(value / chickenCount); - formik.setFieldValue( - `body_weight.${idx}.average_chicken_weight`, - averageWeight - ); - } else { - formik.setFieldValue(`body_weight.${idx}.average_chicken_weight`, ''); - } - } - }, - [formik] - ); - - // Handle calculation when chicken_count changes - const handleChickenCountChange = useCallback( - (idx: number, value: number) => { - formik.setFieldValue(`body_weight.${idx}.chicken_count`, value); - - const currentWeight = formik.values.body_weight?.[idx]; - if (currentWeight) { - const chickenWeight = currentWeight.chicken_weight; - if (chickenWeight > 0 && value > 0) { - const averageWeight = Math.round(chickenWeight / value); - formik.setFieldValue( - `body_weight.${idx}.average_chicken_weight`, - averageWeight - ); - } else { - formik.setFieldValue(`body_weight.${idx}.average_chicken_weight`, ''); - } - } - }, - [formik] - ); - - // Handle calculation when average_weight changes - const handleAverageWeightChange = useCallback( - (idx: number, value: number) => { - formik.setFieldValue(`body_weight.${idx}.average_chicken_weight`, value); - - const currentWeight = formik.values.body_weight?.[idx]; - if (currentWeight) { - const chickenCount = currentWeight.chicken_count; - if (chickenCount > 0 && value > 0) { - const totalWeight = value * chickenCount; - formik.setFieldValue( - `body_weight.${idx}.chicken_weight`, - totalWeight - ); - } else if (value === 0) { - formik.setFieldValue(`body_weight.${idx}.chicken_weight`, ''); - } - } - }, - [formik] - ); - - // Create wrapper handlers that match NumberInput's onChange signature - const handleChickenWeightChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseFloat(e.target.value) || 0; - handleChickenWeightChange(idx, value); - }, - [handleChickenWeightChange] - ); - - const handleChickenCountChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseFloat(e.target.value) || 0; - handleChickenCountChange(idx, value); - }, - [handleChickenCountChange] - ); - - const handleAverageWeightChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseFloat(e.target.value) || 0; - handleAverageWeightChange(idx, value); - }, - [handleAverageWeightChange] - ); - const removeBodyWeight = (idx: number) => { - const updatedBodyWeight = formik.values.body_weight?.filter( + const updatedBodyWeights = formik.values.body_weights?.filter( (_, i) => i !== idx ); - formik.setFieldValue('body_weight', updatedBodyWeight); + formik.setFieldValue('body_weights', updatedBodyWeights); }; - const removeSelectedBodyWeight = () => { - const updatedBodyWeight = formik.values.body_weight?.filter( - (_, idx) => !selectedWeight.includes(idx) + const removeSelectedBodyWeights = () => { + const updatedBodyWeights = formik.values.body_weights?.filter( + (_, idx) => !selectedBodyWeights.includes(idx) ); - formik.setFieldValue('body_weight', updatedBodyWeight); - setSelectedWeight([]); + formik.setFieldValue('body_weights', updatedBodyWeights); + setSelectedBodyWeights([]); }; - // EVENT HANDLERS - Vaccination - const addVaccination = () => { - const newVaccination = [ - ...(formik.values.vaccination || []), + // EVENT HANDLERS - Stocks + const addStock = () => { + const newStocks = [ + ...(formik.values.stocks || []), { - vaccine: null, - vaccine_id: '', - total_stock: '', - used_stock: 0, + product_warehouse_id: 0, + increase: 0, + decrease: 0, + usage_amount: 0, + notes: '', }, ]; - formik.setFieldValue('vaccination', newVaccination); + formik.setFieldValue('stocks', newStocks); }; - const removeVaccination = (idx: number) => { - const updatedVaccination = formik.values.vaccination?.filter( - (_, i) => i !== idx + const removeStock = (idx: number) => { + const updatedStocks = formik.values.stocks?.filter((_, i) => i !== idx); + formik.setFieldValue('stocks', updatedStocks); + }; + + const removeSelectedStocks = () => { + const updatedStocks = formik.values.stocks?.filter( + (_, idx) => !selectedStocks.includes(idx) ); - formik.setFieldValue('vaccination', updatedVaccination); + formik.setFieldValue('stocks', updatedStocks); + setSelectedStocks([]); }; - const removeSelectedVaccination = () => { - const updatedVaccination = formik.values.vaccination?.filter( - (_, idx) => !selectedVaccine.includes(idx) - ); - formik.setFieldValue('vaccination', updatedVaccination); - setSelectedVaccine([]); - }; - - // EVENT HANDLERS - Mortality - const addMortality = () => { - const newMortality = [ - ...(formik.values.mortality || []), + // EVENT HANDLERS - Depletions + const addDepletion = () => { + const newDepletions = [ + ...(formik.values.depletions || []), { - condition: RECORDING_FLAG_OPTIONS[0].value, - count: 0, + product_warehouse_id: 0, + condition: '', + total: 0, + notes: '', }, ]; - formik.setFieldValue('mortality', newMortality); + formik.setFieldValue('depletions', newDepletions); }; - const removeMortality = (idx: number) => { - const updatedMortality = formik.values.mortality?.filter( + const removeDepletion = (idx: number) => { + const updatedDepletions = formik.values.depletions?.filter( (_, i) => i !== idx ); - formik.setFieldValue('mortality', updatedMortality); + formik.setFieldValue('depletions', updatedDepletions); }; - const removeSelectedMortality = () => { - const updatedMortality = formik.values.mortality?.filter( - (_, idx) => !selectedMortality.includes(idx) + const removeSelectedDepletions = () => { + const updatedDepletions = formik.values.depletions?.filter( + (_, idx) => !selectedDepletions.includes(idx) ); - formik.setFieldValue('mortality', updatedMortality); - setSelectedMortality([]); + formik.setFieldValue('depletions', updatedDepletions); + setSelectedDepletions([]); }; // HELPER FUNCTIONS const isRepeaterInputError = < - T extends 'feed_data' | 'body_weight' | 'vaccination' | 'mortality', + T extends 'body_weights' | 'stocks' | 'depletions', >( arrayName: T, - column: T extends 'feed_data' - ? keyof RecordingFormValues['feed_data'][0] - : T extends 'body_weight' - ? keyof RecordingFormValues['body_weight'][0] - : T extends 'vaccination' - ? keyof RecordingFormValues['vaccination'][0] - : T extends 'mortality' - ? keyof RecordingFormValues['mortality'][0] - : never, + column: T extends 'body_weights' + ? keyof RecordingFormValues['body_weights'][0] + : T extends 'stocks' + ? keyof RecordingFormValues['stocks'][0] + : T extends 'depletions' + ? keyof RecordingFormValues['depletions'][0] + : never, idx: number ) => { if ( @@ -642,114 +262,88 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { body: 'flex flex-col gap-6', }} > -
- + { - locationChangeHandler(val); - setTimeout(() => { - formik.setFieldTouched('location', true); - formik.setFieldTouched('location_id', true); - }, 0); + label='Project Flock Kandang ID' + type='number' + name='project_flock_kandang_id' + value={formik.values.project_flock_kandang_id?.toString() || ''} + onChange={(e) => { + const value = e.target.value; + formik.setFieldValue( + 'project_flock_kandang_id', + parseInt(value) || 0 + ); + projectFlockChangeHandler(value); }} - options={locationOptions} - onInputChange={setLocationSelectInputValue} - isLoading={isLoadingLocations} + onBlur={formik.handleBlur} isError={ - formik.touched.location_id && - Boolean(formik.errors.location_id) + formik.touched.project_flock_kandang_id && + Boolean(formik.errors.project_flock_kandang_id) } - errorMessage={formik.errors.location_id as string} - isDisabled={type === 'detail'} - isClearable - placeholder='Pilih lokasi terlebih dahulu' + errorMessage={formik.errors.project_flock_kandang_id as string} + readOnly={type === 'detail'} + placeholder='Masukkan project flock kandang ID' /> { - const date = e.target.value ? new Date(e.target.value) : null; - formik.setFieldValue('recording_date', date); + const datetime = e.target.value + ? new Date(e.target.value) + : null; + formik.setFieldValue('record_datetime', datetime); }} onBlur={formik.handleBlur} isError={ - formik.touched.recording_date && - Boolean(formik.errors.recording_date) + formik.touched.record_datetime && + Boolean(formik.errors.record_datetime) } - errorMessage={formik.errors.recording_date as string} + errorMessage={formik.errors.record_datetime as string} readOnly={type === 'detail'} /> - { - flockChangeHandler(val); - setTimeout(() => { - formik.setFieldTouched('flock', true); - formik.setFieldTouched('flock_id', true); - }, 0); + { + const value = e.target.value; + formik.setFieldValue('status', parseInt(value) || 0); }} - options={flockOptions} - onInputChange={setFlockSelectInputValue} - isLoading={isLoadingFlocks} - isError={ - formik.touched.flock_id && Boolean(formik.errors.flock_id) - } - errorMessage={formik.errors.flock_id as string} - isDisabled={type === 'detail' || !formik.values.location_id} - isClearable - placeholder={ - !formik.values.location_id - ? 'Pilih lokasi terlebih dahulu' - : 'Pilih Flock' - } + onBlur={formik.handleBlur} + isError={formik.touched.status && Boolean(formik.errors.status)} + errorMessage={formik.errors.status as string} + readOnly={type === 'detail'} + placeholder='Masukkan status (0-3)' /> - { - coopChangeHandler(val); - setTimeout(() => { - formik.setFieldTouched('coop', true); - formik.setFieldTouched('coop_id', true); - }, 0); + { + formik.setFieldValue('ontime', e.target.checked); }} - options={coopOptions} - isError={ - formik.touched.coop_id && Boolean(formik.errors.coop_id) - } - errorMessage={formik.errors.coop_id as string} - isDisabled={type === 'detail' || !selectedProjectFlock} - isClearable - placeholder={ - !selectedProjectFlock - ? 'Pilih flock terlebih dahulu' - : 'Pilih Kandang' - } + disabled={type === 'detail'} />
- - {/* Feed Data Table */} + {/* Body Weights Table */} {
0 + formik.values.body_weights?.length === + selectedBodyWeights.length && + formik.values.body_weights?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedFeed( - formik.values.feed_data?.map( + setSelectedBodyWeights( + formik.values.body_weights?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedFeed([]); + setSelectedBodyWeights([]); } }} naked={true} @@ -787,248 +381,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} - Nama Pakan - - * - - - Total Stock pada saat ini - - Jumlah Stock yang digunakan - - * - - - {type !== 'detail' && Action} - - - - {formik.values.feed_data?.map((feed, idx) => ( - - {type !== 'detail' && ( - -
- { - if (e.target.checked) { - setSelectedFeed([...selectedFeed, idx]); - } else { - setSelectedFeed( - selectedFeed.filter((i) => i !== idx) - ); - } - }} - naked={true} - size='sm' - /> - -
- - )} - - - Number(opt.value) === Number(feed.feed_id) - ) ?? null - } - onChange={(val) => { - const productWarehouseId = - (val as OptionType)?.value ?? 0; - const stock = productWarehouseId - ? (pakanStockMap.get( - productWarehouseId as number - ) ?? '') - : ''; - - formik.setFieldValue(`feed_data.${idx}.feed`, val); - formik.setFieldValue( - `feed_data.${idx}.feed_id`, - productWarehouseId || '' - ); - formik.setFieldValue( - `feed_data.${idx}.feed_qty`, - stock - ); - formik.setFieldValue( - `feed_data.${idx}.feed_stock`, - 0 - ); - setTimeout(() => { - formik.setFieldTouched( - `feed_data.${idx}.feed`, - true - ); - formik.setFieldTouched( - `feed_data.${idx}.feed_id`, - true - ); - }, 0); - }} - options={pakanOptions} - isLoading={isLoadingPakan} - isError={ - isRepeaterInputError('feed_data', 'feed_id', idx) - .isError - } - errorMessage={ - isRepeaterInputError('feed_data', 'feed_id', idx) - .errorMessage - } - isDisabled={type === 'detail'} - isClearable - className={{ - wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - - - - - - {type !== 'detail' && ( - -
- - -
- - )} - - ))} - - -
- {type !== 'detail' && ( -
- {selectedFeed.length > 0 && ( - - )} - -
- )} -
- - {/* Body Weight Table */} - -
- - - - {type !== 'detail' && ( - - )} - - + {type !== 'detail' && } - {formik.values.body_weight?.map((weight, idx) => ( - + {formik.values.body_weights?.map((bw, idx) => ( + {type !== 'detail' && ( )} - - - {type !== 'detail' && ( - - )} - - ))} - -
-
- 0 - } - onChange={(e) => { - if (e.target.checked) { - setSelectedWeight( - formik.values.body_weight?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedWeight([]); - } - }} - naked={true} - size='sm' - /> -
-
- Berat (Gram) + Berat Ayam (gram) { * - Rata-rata berat Ayam - - * - - CatatanAction
-
+
{ if (e.target.checked) { - setSelectedWeight([...selectedWeight, idx]); + setSelectedBodyWeights([ + ...selectedBodyWeights, + idx, + ]); } else { - setSelectedWeight( - selectedWeight.filter((i) => i !== idx) + setSelectedBodyWeights( + selectedBodyWeights.filter((i) => i !== idx) ); } }} naked={true} size='sm' /> -
- - - - -
- -
-
-
- - -
-
-
- {type !== 'detail' && ( -
- {selectedWeight.length > 0 && ( - - )} - -
- )} -
- - {/* Vaccination Table */} - -
- - - - {type !== 'detail' && ( - - )} - - - - {type !== 'detail' && } - - - - {formik.values.vaccination?.map((vaccine, idx) => ( - - {type !== 'detail' && ( - - )} - - + {type !== 'detail' && ( )} @@ -1458,39 +513,32 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
-
- 0 - } - onChange={(e) => { - if (e.target.checked) { - setSelectedVaccine( - formik.values.vaccination?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedVaccine([]); - } - }} - naked={true} - size='sm' - /> -
-
- Name Vaksin - - * - - Total Stock pada saat ini - Jumlah Stock yang digunakan - - * - - Action
-
- { - if (e.target.checked) { - setSelectedVaccine([...selectedVaccine, idx]); - } else { - setSelectedVaccine( - selectedVaccine.filter((i) => i !== idx) - ); - } - }} - naked={true} - size='sm' - /> - -
-
- - Number(opt.value) === Number(vaccine.vaccine_id) - ) ?? null + typeof bw.weight === 'number' + ? bw.weight.toString() + : bw.weight } - onChange={(val) => { - const productWarehouseId = - (val as OptionType)?.value ?? 0; - const stock = productWarehouseId - ? (ovkStockMap.get( - productWarehouseId as number - ) ?? '') - : ''; - formik.setFieldValue( - `vaccination.${idx}.vaccine`, - val - ); - formik.setFieldValue( - `vaccination.${idx}.vaccine_id`, - productWarehouseId || '' - ); - formik.setFieldValue( - `vaccination.${idx}.total_stock`, - stock - ); - formik.setFieldValue( - `vaccination.${idx}.used_stock`, - 0 - ); - // Set touched after setting values to trigger validation - setTimeout(() => { - formik.setFieldTouched( - `vaccination.${idx}.vaccine`, - true - ); - formik.setFieldTouched( - `vaccination.${idx}.vaccine_id`, - true - ); - }, 0); - }} - options={ovkOptions} - isLoading={isLoadingOvk} + onChange={formik.handleChange} + onBlur={formik.handleBlur} isError={ - isRepeaterInputError( - 'vaccination', - 'vaccine_id', - idx - ).isError + isRepeaterInputError('body_weights', 'weight', idx) + .isError } errorMessage={ - isRepeaterInputError( - 'vaccination', - 'vaccine_id', - idx - ).errorMessage + isRepeaterInputError('body_weights', 'weight', idx) + .errorMessage } - isDisabled={type === 'detail'} - isClearable + readOnly={type === 'detail'} className={{ - wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', + wrapper: 'w-full min-w-32', }} + placeholder='Berat ayam (gram)' /> - - + + -
- - -
+
{type !== 'detail' && ( -
- {selectedVaccine.length > 0 && ( - + + Hapus Terpilih ({selectedBodyWeights.length}) + )} - + + Tambah Bobot Badan +
)}
- {/* Mortality Table */} + {/* Stocks Table */} {
0 + formik.values.stocks?.length === + selectedStocks.length && + formik.values.stocks?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedMortality( - formik.values.mortality?.map( - (_, idx) => idx - ) ?? [] + setSelectedStocks( + formik.values.stocks?.map((_, idx) => idx) ?? + [] ); } else { - setSelectedMortality([]); + setSelectedStocks([]); } }} naked={true} @@ -1528,16 +575,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} - Kondisi/Alasan Mortalitas - - * - - - - Jumlah + Product Warehouse ID { * + Penambahan + Pengurangan + Jumlah Pakai + Catatan {type !== 'detail' && Action} - {formik.values.mortality?.map((mortality, idx) => ( - + {formik.values.stocks?.map((stock, idx) => ( + {type !== 'detail' && ( -
+
{ if (e.target.checked) { - setSelectedMortality([ - ...selectedMortality, - idx, - ]); + setSelectedStocks([...selectedStocks, idx]); } else { - setSelectedMortality( - selectedMortality.filter((i) => i !== idx) + setSelectedStocks( + selectedStocks.filter((i) => i !== idx) ); } }} naked={true} size='sm' /> -
)} - opt.value === mortality.condition - )} - onChange={(val) => { - formik.setFieldTouched( - `mortality.${idx}.condition`, - true - ); - formik.setFieldValue( - `mortality.${idx}.condition`, - (val as OptionType)?.value - ); - }} + name={`stocks.${idx}.product_warehouse_id`} + type='number' + value={stock.product_warehouse_id?.toString() || ''} + onChange={formik.handleChange} + onBlur={formik.handleBlur} isError={ - isRepeaterInputError('mortality', 'condition', idx) - .isError + isRepeaterInputError( + 'stocks', + 'product_warehouse_id', + idx + ).isError } errorMessage={ - isRepeaterInputError('mortality', 'condition', idx) - .errorMessage + isRepeaterInputError( + 'stocks', + 'product_warehouse_id', + idx + ).errorMessage } - options={RECORDING_FLAG_OPTIONS} - isDisabled={type === 'detail'} - isClearable + readOnly={type === 'detail'} className={{ - wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', + wrapper: 'w-full min-w-32', }} + placeholder='Product Warehouse ID' /> - + + + + + + + + + {type !== 'detail' && ( -
- - -
+ )} @@ -1656,32 +751,255 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{type !== 'detail' && ( -
- {selectedMortality.length > 0 && ( - + + Hapus Terpilih ({selectedStocks.length}) + )} - + + Tambah Stok + +
+ )} + + + {/* Depletions Table */} + +
+ + + + {type !== 'detail' && ( + + )} + + + + + {type !== 'detail' && } + + + + {formik.values.depletions?.map((depletion, idx) => ( + + {type !== 'detail' && ( + + )} + + + + + {type !== 'detail' && ( + + )} + + ))} + +
+
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedDepletions( + formik.values.depletions?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedDepletions([]); + } + }} + naked={true} + size='sm' + /> +
+
+ Product Warehouse ID + + * + + + Kondisi + + * + + + Total + + * + + CatatanAction
+
+ { + if (e.target.checked) { + setSelectedDepletions([ + ...selectedDepletions, + idx, + ]); + } else { + setSelectedDepletions( + selectedDepletions.filter((i) => i !== idx) + ); + } + }} + naked={true} + size='sm' + /> +
+
+ + + + + + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedDepletions.length > 0 && ( + + )} +
)}
@@ -1697,7 +1015,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } onDelete={deleteRecordingClickHandler} /> - {recordingFormErrorMessage && (
Date: Thu, 23 Oct 2025 19:52:21 +0700 Subject: [PATCH 002/174] refactor(FE-Storyless): add flock_id, area_id, fcr_id, location_id, and kandang_ids to project-flock type definition --- src/types/api/production/project-flock.d.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index 7561e9ca..ce4043c4 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -10,11 +10,16 @@ export type BaseProjectFlock = { name: string; status: string; flock: Flock; + flock_id: number; area: Area; + area_id: number; category: string; fcr: Fcr; + fcr_id: number; location: Location; + location_id: number; period: number; + kandang_ids: number[]; kandangs: Kandang[]; }; From cebe738beb72879797955ab8bcbd21f1f019b2ed Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 19:52:38 +0700 Subject: [PATCH 003/174] refactor(FE-114): enhance type safety and improve checkbox input handling --- .../production/recording/RecordingTable.tsx | 154 ++++++------------ .../recording/form/RecordingForm.tsx | 26 +-- 2 files changed, 56 insertions(+), 124 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 85ec092f..b0f95460 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -16,95 +16,31 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import { type CellContext } from '@tanstack/react-table'; import { type Recording } from '@/types/api/production/recording'; +import { type ProjectFlock } from '@/types/api/production/project-flock'; -const dummyRecordings: Recording[] = [ +// Extended type that includes related data +type RecordingWithRelations = Recording & { + project_flock?: ProjectFlock; +}; + +const dummyRecordings: RecordingWithRelations[] = [ { id: 1, - flock: { - id: 1, - name: 'Flock Recording 1', - created_at: '2024-01-01', - updated_at: '2024-01-01', - created_user: { - id: 1, - id_user: 1, - email: 'admin@example.com', - name: 'Admin', - }, - }, - recording_date: '2024-01-01', - location: { - id: 1, - name: 'Location 1', - address: 'Jl. Contoh No. 1', - area: { - id: 1, - name: 'Area 1', - }, - created_at: '2024-01-01', - updated_at: '2024-01-01', - created_user: { - id: 1, - id_user: 1, - email: 'admin@example.com', - name: 'Admin', - }, - }, - coop: { - id: 1, - name: 'Coop 1', - status: 'ACTIVE', - location: { - id: 1, - name: 'Location 1', - address: 'Jl. Contoh No. 1', - area: { - id: 1, - name: 'Area 1', - }, - }, - pic: { - id: 1, - id_user: 1, - email: 'pic@example.com', - name: 'PIC User', - }, - created_at: '2024-01-01', - updated_at: '2024-01-01', - created_user: { - id: 1, - id_user: 1, - email: 'admin@example.com', - name: 'Admin', - }, - }, - feed_data: [ - { - feed_name: 'Feed 1', - feed_qty: 100, - feed_stock: 500, - }, - ], - body_weight: [ - { - chicken_weight: 2.5, - chicken_count: 1000, - average_chicken_weight: 2.5, - }, - ], - vaccination: [ - { - vaccine_name: 'Vaccine 1', - total_stock: 200, - used_stock: 150, - }, - ], - mortality: [ - { - condition: 'NORMAL', - count: 5, - }, - ], + project_flock_kandang_id: 1, + record_date: '2024-01-01', + ontime: true, + day: 10, + status: 1, + total_depletion: 10, + cum_depletion_rate: 1.0, + daily_gain: 50, + avg_daily_gain: 5.0, + cum_intake: 200, + fcr_value: 1.5, + total_chick: 1000, + daily_depletion_rate: 0.5, + cum_depletion: 20, + record_datetime: '2024-01-01T08:00:00Z', created_at: '2024-01-01', updated_at: '2024-01-01', created_user: { @@ -112,6 +48,10 @@ const dummyRecordings: Recording[] = [ id_user: 1, email: 'admin@example.com', name: 'Admin', + image: null, + npk: '0001', + created_at: '2024-01-01', + updated_at: '2024-01-01', }, }, ]; @@ -122,7 +62,7 @@ const RowOptionsMenu = ({ deleteClickHandler, }: { type: 'dropdown' | 'collapse'; - props: CellContext; + props: CellContext; deleteClickHandler: () => void; }) => { return ( @@ -178,7 +118,7 @@ const RecordingTable = () => { const [pageSize, setPageSize] = useState(10); const [sorting, setSorting] = useState([]); const [selectedRecordings, setSelectedRecordings] = useState([]); - const [, setSelectedRecording] = useState(undefined); + const [, setSelectedRecording] = useState(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false); const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); @@ -206,10 +146,15 @@ const RecordingTable = () => { const paginatedData = useMemo(() => { const filteredData = dummyRecordings.filter( - (recording) => - recording.flock.name.toLowerCase().includes(search.toLowerCase()) || - recording.location.name.toLowerCase().includes(search.toLowerCase()) || - recording.coop.name.toLowerCase().includes(search.toLowerCase()) + (recording: RecordingWithRelations) => { + const projectName = recording.project_flock?.name || ''; + const locationName = recording.project_flock?.location?.name || ''; + const coopName = recording.project_flock?.kandangs?.[0]?.name || ''; + + return projectName.toLowerCase().includes(search.toLowerCase()) || + locationName.toLowerCase().includes(search.toLowerCase()) || + coopName.toLowerCase().includes(search.toLowerCase()); + } ); const start = (page - 1) * pageSize; return filteredData.slice(start, start + pageSize); @@ -383,35 +328,34 @@ const RecordingTable = () => { cell: (props) => pageSize * (page - 1) + props.row.index + 1, }, { - accessorKey: 'flock.name', header: 'Flock', + cell: (props) => props.row.original.project_flock?.name || '-', }, { - accessorKey: 'recording_date', + accessorKey: 'record_date', header: 'Tanggal Recording', cell: (props) => - new Date(props.row.original.recording_date).toLocaleDateString(), + new Date(props.row.original.record_date).toLocaleDateString(), }, { - accessorKey: 'location.name', header: 'Lokasi', + cell: (props) => props.row.original.project_flock?.location?.name || '-', }, { - accessorKey: 'coop.name', header: 'Kandang', + cell: (props) => { + const coopName = props.row.original.project_flock?.kandangs?.[0]?.name; + return coopName || '-'; + }, }, { - accessorKey: 'mortality', - header: 'Total Mortality', - cell: (props) => - props.row.original.mortality.reduce( - (acc, curr) => acc + curr.count, - 0 - ), + accessorKey: 'total_depletion', + header: 'Total Depletion', + cell: (props) => props.row.original.total_depletion, }, { header: 'Aksi', - cell: (props: CellContext) => { + cell: (props: CellContext) => { const currentPageSize = props.table.getPaginationRowModel().rows.length; const currentPageRows = diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 41d9401e..f6b037a2 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -334,7 +334,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { label='On Time' name='ontime' checked={formik.values.ontime || false} - onChange={(e) => { + onChange={(e: React.ChangeEvent) => { formik.setFieldValue('ontime', e.target.checked); }} disabled={type === 'detail'} @@ -363,7 +363,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { selectedBodyWeights.length && formik.values.body_weights?.length > 0 } - onChange={(e) => { + onChange={(e: React.ChangeEvent) => { if (e.target.checked) { setSelectedBodyWeights( formik.values.body_weights?.map( @@ -374,8 +374,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setSelectedBodyWeights([]); } }} - naked={true} - size='sm' />
@@ -411,7 +409,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { + onChange={(e: React.ChangeEvent) => { if (e.target.checked) { setSelectedBodyWeights([ ...selectedBodyWeights, @@ -423,8 +421,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); } }} - naked={true} - size='sm' />
@@ -558,7 +554,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { selectedStocks.length && formik.values.stocks?.length > 0 } - onChange={(e) => { + onChange={(e: React.ChangeEvent) => { if (e.target.checked) { setSelectedStocks( formik.values.stocks?.map((_, idx) => idx) ?? @@ -568,8 +564,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setSelectedStocks([]); } }} - naked={true} - size='sm' /> @@ -599,7 +593,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { + onChange={(e: React.ChangeEvent) => { if (e.target.checked) { setSelectedStocks([...selectedStocks, idx]); } else { @@ -608,8 +602,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); } }} - naked={true} - size='sm' /> @@ -796,7 +788,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { selectedDepletions.length && formik.values.depletions?.length > 0 } - onChange={(e) => { + onChange={(e: React.ChangeEvent) => { if (e.target.checked) { setSelectedDepletions( formik.values.depletions?.map( @@ -807,8 +799,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setSelectedDepletions([]); } }} - naked={true} - size='sm' /> @@ -853,7 +843,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { + onChange={(e: React.ChangeEvent) => { if (e.target.checked) { setSelectedDepletions([ ...selectedDepletions, @@ -865,8 +855,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); } }} - naked={true} - size='sm' /> From 392e211181f04b4d0b1ee5334761f2c4af15eec6 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 19:54:17 +0700 Subject: [PATCH 004/174] refactor(FE-Storyless): replace img with Image component for optimized loading --- src/components/Card.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/Card.tsx b/src/components/Card.tsx index ba573dfb..82f90ef5 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -6,6 +6,7 @@ import { } from 'react'; import { cn } from '@/lib/helper'; +import Image from 'next/image'; export interface CardProps extends Omit, 'className'> { title?: string; @@ -108,7 +109,7 @@ const Card = ({ return (
- {imageAlt {image && (
- {imageAlt Date: Thu, 23 Oct 2025 20:44:59 +0700 Subject: [PATCH 005/174] refactor(FE-114): replace button elements with Button component for consistency and improved styling --- src/components/helper/FieldMessage.tsx | 65 ----- .../recording/form/RecordingForm.tsx | 252 ++++++++++-------- 2 files changed, 140 insertions(+), 177 deletions(-) delete mode 100644 src/components/helper/FieldMessage.tsx diff --git a/src/components/helper/FieldMessage.tsx b/src/components/helper/FieldMessage.tsx deleted file mode 100644 index e43d0a63..00000000 --- a/src/components/helper/FieldMessage.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -import { ReactNode } from 'react'; - -import { cn } from '@/lib/helper'; - -type FieldMessageTone = 'error' | 'info' | 'success'; - -export interface FieldMessageProps { - message?: ReactNode; - tone?: FieldMessageTone; - isVisible?: boolean; - persistent?: boolean; - className?: string; - ariaLive?: 'off' | 'polite' | 'assertive'; -} - -const toneClassName: Record = { - error: 'text-error', - info: 'text-base-content/60', - success: 'text-success', -}; - -/** - * Shared helper to render bottom field feedback without causing layout shift. - * Keeps a minimal slot height, but expands when the content wraps onto multiple lines. - */ -export const FieldMessage = ({ - message, - tone = 'info', - isVisible, - persistent = true, - className, - ariaLive, -}: FieldMessageProps) => { - const hasMessage = Boolean(message); - const visible = isVisible ?? hasMessage; - const liveRegion = ariaLive ?? (tone === 'error' ? 'assertive' : 'polite'); - - return ( -
- - {visible || persistent ? (message ?? '\u00A0') : message} - -
- ); -}; - -export default FieldMessage; diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index f6b037a2..a6b9f23c 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'; import { useFormik } from 'formik'; import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; import CheckboxInput from '@/components/input/CheckboxInput'; @@ -262,7 +263,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { body: 'flex flex-col gap-6', }} > -
+
{ {type !== 'detail' && ( -
{ setSelectedBodyWeights([]); } }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} /> -
)} @@ -404,29 +407,31 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {formik.values.body_weights?.map((bw, idx) => ( {type !== 'detail' && ( - -
- ) => { - if (e.target.checked) { - setSelectedBodyWeights([ - ...selectedBodyWeights, - idx, - ]); - } else { - setSelectedBodyWeights( - selectedBodyWeights.filter((i) => i !== idx) - ); - } - }} - /> -
+ + ) => { + if (e.target.checked) { + setSelectedBodyWeights([ + ...selectedBodyWeights, + idx, + ]); + } else { + setSelectedBodyWeights( + selectedBodyWeights.filter((i) => i !== idx), + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> )} - { {type !== 'detail' && ( - +
+ +
)} @@ -509,25 +516,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{type !== 'detail' && ( -
+
{selectedBodyWeights.length > 0 && ( - + )} - +
)} @@ -546,7 +556,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {type !== 'detail' && ( -
{ setSelectedStocks([]); } }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} /> -
)} @@ -588,26 +600,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {formik.values.stocks?.map((stock, idx) => ( {type !== 'detail' && ( - -
- ) => { - if (e.target.checked) { - setSelectedStocks([...selectedStocks, idx]); - } else { - setSelectedStocks( - selectedStocks.filter((i) => i !== idx) - ); - } - }} - /> -
+ + ) => { + if (e.target.checked) { + setSelectedStocks([...selectedStocks, idx]); + } else { + setSelectedStocks( + selectedStocks.filter((i) => i !== idx), + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> )} - { {type !== 'detail' && ( - +
+ +
)} @@ -743,25 +759,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{type !== 'detail' && ( -
+
{selectedStocks.length > 0 && ( - + )} - +
)} @@ -780,7 +799,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {type !== 'detail' && ( -
{ setSelectedDepletions([]); } }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} /> -
)} @@ -838,29 +859,31 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {formik.values.depletions?.map((depletion, idx) => ( {type !== 'detail' && ( - -
- ) => { - if (e.target.checked) { - setSelectedDepletions([ - ...selectedDepletions, - idx, - ]); - } else { - setSelectedDepletions( - selectedDepletions.filter((i) => i !== idx) - ); - } - }} - /> -
+ + ) => { + if (e.target.checked) { + setSelectedDepletions([ + ...selectedDepletions, + idx, + ]); + } else { + setSelectedDepletions( + selectedDepletions.filter((i) => i !== idx), + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> )} - { {type !== 'detail' && ( - +
+ +
)} @@ -969,25 +994,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{type !== 'detail' && ( -
+
{selectedDepletions.length > 0 && ( - + )} - +
)} From d61c0ab84458515942151f849b36676d0f8b8e3e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 20:59:20 +0700 Subject: [PATCH 006/174] feat(FE-114): integrate date time handling in RecordingForm for on-time status --- .../recording/form/RecordingForm.tsx | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index a6b9f23c..26879c91 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { useFormik } from 'formik'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; @@ -119,6 +119,45 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('project_flock_kandang_id', projectFlockId, false); }; + // EVENT HANDLERS - Date Time + const recordDateTimeChangeHandler = (datetime: Date | null) => { + formik.setFieldValue('record_datetime', datetime, false); + + // Auto-set ontime based on date difference + if (datetime) { + const today = new Date(); + const recordDate = new Date(datetime); + + // Reset time to compare only dates + const todayDateOnly = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const recordDateOnly = new Date(recordDate.getFullYear(), recordDate.getMonth(), recordDate.getDate()); + + // Set ontime to true if recording date is today, false otherwise + const isOnTime = todayDateOnly.getTime() === recordDateOnly.getTime(); + formik.setFieldValue('ontime', isOnTime, false); + } + }; + + // Set initial ontime value when form loads or record_datetime changes + useEffect(() => { + if (formik.values.record_datetime) { + const today = new Date(); + const recordDate = new Date(formik.values.record_datetime); + + // Reset time to compare only dates + const todayDateOnly = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const recordDateOnly = new Date(recordDate.getFullYear(), recordDate.getMonth(), recordDate.getDate()); + + // Set ontime to true if recording date is today, false otherwise + const isOnTime = todayDateOnly.getTime() === recordDateOnly.getTime(); + + // Only update if ontime is not set or different from calculated value + if (formik.values.ontime !== isOnTime) { + formik.setFieldValue('ontime', isOnTime, false); + } + } + }, [formik.values.record_datetime]); + // EVENT HANDLERS - Body Weights const addBodyWeight = () => { const newBodyWeights = [ @@ -304,7 +343,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const datetime = e.target.value ? new Date(e.target.value) : null; - formik.setFieldValue('record_datetime', datetime); + recordDateTimeChangeHandler(datetime); }} onBlur={formik.handleBlur} isError={ @@ -332,13 +371,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { />
From 71df86c8df4998984b5904cfce135b9568624c46 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 21:34:40 +0700 Subject: [PATCH 007/174] feat(FE-114): integrate location and project flock selection in RecordingForm --- .../recording/form/RecordingForm.tsx | 142 ++++++++++++++---- 1 file changed, 109 insertions(+), 33 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 26879c91..944b979a 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -2,10 +2,12 @@ import { useMemo, useState, useEffect } from 'react'; import { useFormik } from 'formik'; +import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { FormHeader } from '@/components/helper/form/FormHeader'; @@ -21,6 +23,9 @@ import { UpdateRecordingFormSchema, } from './RecordingForm.schema'; import { useRecordingFormHandlers } from './useRecordingFormHandlers'; +import { ProjectFlockApi } from '@/services/api/production'; +import { LocationApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; import Card from '@/components/Card'; @@ -34,6 +39,62 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [selectedStocks, setSelectedStocks] = useState([]); const [selectedDepletions, setSelectedDepletions] = useState([]); + // State for Location search and selection + const [locationSearchValue, setLocationSearchValue] = useState(''); + const [selectedLocation, setSelectedLocation] = useState(null); + + // State for Project Flock search + const [projectFlockSearchValue, setProjectFlockSearchValue] = useState(''); + + // Fetch Locations data + const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ + search: locationSearchValue || '', + }).toString()}`; + + const { data: locations, isLoading: isLoadingLocations } = useSWR( + locationsUrl, + LocationApi.getAllFetcher + ); + + // Fetch Project Flocks data with location filter + const projectFlocksUrl = `${ProjectFlockApi.basePath}?${new URLSearchParams({ + search: projectFlockSearchValue || '', + ...(selectedLocation ? { location_id: selectedLocation.value.toString() } : {}), + }).toString()}`; + + const { data: projectFlocks, isLoading: isLoadingProjectFlocks } = useSWR( + projectFlocksUrl, + ProjectFlockApi.getAllFetcher + ); + + // Extract location options from locations data + const locationOptions = useMemo(() => { + if (!isResponseSuccess(locations)) return []; + + return locations?.data.map((location) => ({ + value: location.id, + label: location.name, + })) || []; + }, [locations]); + + // Extract kandang options from project_flocks data + const projectFlockKandangOptions = useMemo(() => { + if (!isResponseSuccess(projectFlocks)) return []; + + const options: OptionType[] = []; + + projectFlocks?.data.forEach((projectFlock) => { + projectFlock.kandangs.forEach((kandang) => { + options.push({ + value: kandang.id, + label: `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name}`, + }); + }); + }); + + return options; + }, [projectFlocks]); + const { deleteModal, recordingFormErrorMessage, @@ -114,9 +175,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }); // EVENT HANDLERS - Select Inputs - const projectFlockChangeHandler = (value: string) => { - const projectFlockId = parseInt(value) || 0; - formik.setFieldValue('project_flock_kandang_id', projectFlockId, false); + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation(val as OptionType); + + // Reset project flock selection when location changes + formik.setFieldValue('project_flock_kandang', null); + formik.setFieldValue('project_flock_kandang_id', 0); + }; + + const projectFlockKandangChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('project_flock_kandang', true); + formik.setFieldValue('project_flock_kandang', val); + formik.setFieldTouched('project_flock_kandang_id', true); + formik.setFieldValue('project_flock_kandang_id', (val as OptionType)?.value || 0); }; // EVENT HANDLERS - Date Time @@ -303,28 +374,41 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }} >
- { - const value = e.target.value; - formik.setFieldValue( - 'project_flock_kandang_id', - parseInt(value) || 0 - ); - projectFlockChangeHandler(value); - }} - onBlur={formik.handleBlur} + label='Lokasi' + value={selectedLocation} + onChange={locationChangeHandler} + options={locationOptions} + onInputChange={setLocationSearchValue} + isLoading={isLoadingLocations} + isDisabled={type === 'detail'} + placeholder='Pilih Lokasi' + isClearable + isSearchable + /> + + { readOnly={type === 'detail'} placeholder='Masukkan status (0-3)' /> - -
+ + {/* Body Weights Table */} { {/* Stocks Table */} { )} - Product Warehouse ID + Persediaan { {/* Depletions Table */} Date: Thu, 23 Oct 2025 21:54:06 +0700 Subject: [PATCH 008/174] feat(FE-114): add average weight calculation and input handling in RecordingForm --- .../recording/form/RecordingForm.schema.ts | 7 + .../recording/form/RecordingForm.tsx | 165 ++++++++++++++++-- 2 files changed, 154 insertions(+), 18 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index beffa26a..b1113ae2 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -39,6 +39,11 @@ export const RecordingFormSchema = Yup.object({ .min(1, 'Jumlah ayam minimal 1 ekor!') .typeError('Jumlah ayam harus berupa angka!') .default(1), + average_weight: Yup.number() + .optional() + .min(0, 'Rata-rata berat tidak boleh negatif!') + .typeError('Rata-rata berat harus berupa angka!') + .default(0), notes: Yup.string().optional(), }) ) @@ -123,12 +128,14 @@ export const getRecordingFormInitialValues = ( (bw: NonNullable[0]) => ({ weight: bw.weight, qty: bw.qty, + average_weight: bw.qty > 0 ? Math.round(bw.weight / bw.qty) : 0, notes: bw.notes || '', }) ) ?? [ { weight: 0, qty: 1, + average_weight: 0, notes: '', }, ], diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 944b979a..f387d45e 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -7,6 +7,7 @@ import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; +import NumberInput from '@/components/input/NumberInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; @@ -135,6 +136,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ? bw.qty : parseFloat(String(bw.qty)) || 0, notes: bw.notes, + // average_weight is not included in payload as it's calculated field only })), stocks: (values.stocks ?? []).map((stock) => ({ product_warehouse_id: stock.product_warehouse_id, @@ -229,6 +231,33 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } }, [formik.values.record_datetime]); + // Auto-calculate average weight when weight or qty changes + useEffect(() => { + if (formik.values.body_weights) { + const updatedBodyWeights = formik.values.body_weights.map((weight) => ({ + ...weight, + average_weight: + weight.qty > 0 && weight.weight > 0 + ? Math.round(weight.weight / weight.qty) + : 0, + })); + + // Only update if values are different to avoid infinite loops + const hasChanges = updatedBodyWeights.some( + (updated, idx) => + updated.average_weight !== + (formik.values.body_weights[idx]?.average_weight || 0) + ); + + if (hasChanges) { + formik.setFieldValue('body_weights', updatedBodyWeights, false); + } + } + }, [ + formik.values.body_weights?.map((w) => w.weight), + formik.values.body_weights?.map((w) => w.qty), + ]); + // EVENT HANDLERS - Body Weights const addBodyWeight = () => { const newBodyWeights = [ @@ -237,11 +266,76 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { weight: 0, qty: 1, notes: '', + average_weight: 0, }, ]; formik.setFieldValue('body_weights', newBodyWeights); }; + // Handle calculation when weight changes + const handleWeightChange = (idx: number, value: number) => { + formik.setFieldValue(`body_weights.${idx}.weight`, value); + + const currentWeight = formik.values.body_weights?.[idx]; + if (currentWeight) { + const qty = currentWeight.qty; + if (qty > 0 && value > 0) { + const averageWeight = Math.round(value / qty); + formik.setFieldValue(`body_weights.${idx}.average_weight`, averageWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.average_weight`, 0); + } + } + }; + + // Handle calculation when qty changes + const handleQtyChange = (idx: number, value: number) => { + formik.setFieldValue(`body_weights.${idx}.qty`, value); + + const currentWeight = formik.values.body_weights?.[idx]; + if (currentWeight) { + const weight = currentWeight.weight; + if (value > 0 && weight > 0) { + const averageWeight = Math.round(weight / value); + formik.setFieldValue(`body_weights.${idx}.average_weight`, averageWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.average_weight`, 0); + } + } + }; + + // Handle calculation when average_weight changes + const handleAverageWeightChange = (idx: number, value: number) => { + formik.setFieldValue(`body_weights.${idx}.average_weight`, value); + + const currentWeight = formik.values.body_weights?.[idx]; + if (currentWeight) { + const qty = currentWeight.qty; + if (qty > 0 && value > 0) { + const totalWeight = value * qty; + formik.setFieldValue(`body_weights.${idx}.weight`, totalWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.weight`, 0); + } + } + }; + + // Create wrapper handlers that match NumberInput's onChange signature + const handleWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0; + handleWeightChange(idx, value); + }; + + const handleQtyChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0; + handleQtyChange(idx, value); + }; + + const handleAverageWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0; + handleAverageWeightChange(idx, value); + }; + const removeBodyWeight = (idx: number) => { const updatedBodyWeights = formik.values.body_weights?.filter( (_, i) => i !== idx @@ -353,6 +447,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; + return ( <>
@@ -514,6 +609,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { * + + Rata-rata Berat Ayam (gram) + + + + Catatan {type !== 'detail' && Action} @@ -546,17 +650,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} - { className={{ wrapper: 'w-full min-w-32', }} - placeholder='Berat ayam (gram)' /> - { className={{ wrapper: 'w-full min-w-24', }} - placeholder='Jumlah ayam' + /> + + + From 6060ec0f7e5f36b22f0bdde41d67f9d70a0caf3d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 22:02:12 +0700 Subject: [PATCH 009/174] feat(FE-114): prevent auto-calculation override during manual average weight editing in RecordingForm --- .../recording/form/RecordingForm.tsx | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index f387d45e..c8adef19 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -40,6 +40,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [selectedStocks, setSelectedStocks] = useState([]); const [selectedDepletions, setSelectedDepletions] = useState([]); + // Track which average weight field is being edited to prevent auto-calculation override + const [editingAverageIndex, setEditingAverageIndex] = useState(null); + // State for Location search and selection const [locationSearchValue, setLocationSearchValue] = useState(''); const [selectedLocation, setSelectedLocation] = useState(null); @@ -231,20 +234,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } }, [formik.values.record_datetime]); - // Auto-calculate average weight when weight or qty changes + // Auto-calculate average weight when weight or qty changes (but not when editing average weight manually) useEffect(() => { - if (formik.values.body_weights) { - const updatedBodyWeights = formik.values.body_weights.map((weight) => ({ - ...weight, - average_weight: - weight.qty > 0 && weight.weight > 0 - ? Math.round(weight.weight / weight.qty) - : 0, - })); + if (formik.values.body_weights && editingAverageIndex === null) { + const updatedBodyWeights = formik.values.body_weights.map((weight, idx) => { + // Skip auto-calculation for the field being manually edited + if (idx === editingAverageIndex) { + return weight; + } + + return { + ...weight, + average_weight: + weight.qty > 0 && weight.weight > 0 + ? Math.round(weight.weight / weight.qty) + : 0, + }; + }); // Only update if values are different to avoid infinite loops const hasChanges = updatedBodyWeights.some( (updated, idx) => + idx !== editingAverageIndex && // Skip the field being edited updated.average_weight !== (formik.values.body_weights[idx]?.average_weight || 0) ); @@ -256,6 +267,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, [ formik.values.body_weights?.map((w) => w.weight), formik.values.body_weights?.map((w) => w.qty), + editingAverageIndex, // Include editing index in dependencies ]); // EVENT HANDLERS - Body Weights @@ -332,10 +344,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; const handleAverageWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { + // Set focus state to prevent auto-calculation override + setEditingAverageIndex(idx); + const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0; handleAverageWeightChange(idx, value); }; + const handleAverageWeightBlur = () => { + // Clear focus state when user leaves the field to re-enable auto-calculation + setEditingAverageIndex(null); + }; + const removeBodyWeight = (idx: number) => { const updatedBodyWeights = formik.values.body_weights?.filter( (_, i) => i !== idx @@ -707,7 +727,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { name={`body_weights.${idx}.average_weight`} value={bw.average_weight || 0} onChange={handleAverageWeightChangeWrapper(idx)} - onBlur={formik.handleBlur} + onBlur={(e) => { + handleAverageWeightBlur(); + formik.handleBlur(e); + }} maskType='weight' weightUnit='gram' decimals={2} From 7f5ae947065fd6a653d854cd90106ae0ec04f46c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 22:59:41 +0700 Subject: [PATCH 010/174] feat(FE-114): integrate product stock fetching and selection in RecordingForm --- .../recording/form/RecordingForm.tsx | 1210 +++++++++-------- 1 file changed, 646 insertions(+), 564 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index c8adef19..382d609a 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState, useEffect } from 'react'; +import { useMemo, useState, useEffect, useCallback } from 'react'; import { useFormik } from 'formik'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; @@ -26,6 +26,7 @@ import { import { useRecordingFormHandlers } from './useRecordingFormHandlers'; import { ProjectFlockApi } from '@/services/api/production'; import { LocationApi } from '@/services/api/master-data'; +import { ProductWarehouseApi } from '@/services/api/inventory'; import { isResponseSuccess } from '@/lib/api-helper'; import Card from '@/components/Card'; @@ -71,6 +72,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ProjectFlockApi.getAllFetcher ); + // Fetch Products with location filter (both PAKAN and OVK) - using selectedLocation for now + const stockProductsUrl = useMemo(() => { + if (!selectedLocation) return null; + const params = new URLSearchParams({ + flags: 'PAKAN,OVK', // Fetch both flags in one request + search: '', + location_id: selectedLocation.value.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [selectedLocation]); + + const { data: stockProducts, isLoading: isLoadingStockProducts } = useSWR( + stockProductsUrl, + ProductWarehouseApi.getAllFetcher + ); + // Extract location options from locations data const locationOptions = useMemo(() => { if (!isResponseSuccess(locations)) return []; @@ -99,6 +116,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return options; }, [projectFlocks]); + const { deleteModal, recordingFormErrorMessage, @@ -179,6 +197,26 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, }); + // Get location from selected project flock for stock filtering + const getProjectFlockLocation = useCallback((): OptionType | null => { + if (!formik.values.project_flock_kandang || !isResponseSuccess(projectFlocks)) { + return selectedLocation; // Fallback to manual location selection + } + + const kandangId = formik.values.project_flock_kandang.value; + for (const projectFlock of projectFlocks.data) { + const kandang = projectFlock.kandangs.find(k => k.id === kandangId); + if (kandang && projectFlock.location) { + return { + value: projectFlock.location.id, + label: projectFlock.location.name + }; + } + } + + return selectedLocation; // Fallback to manual location selection + }, [formik.values.project_flock_kandang, projectFlocks, selectedLocation]); + // EVENT HANDLERS - Select Inputs const locationChangeHandler = (val: OptionType | OptionType[] | null) => { setSelectedLocation(val as OptionType); @@ -386,6 +424,40 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('stocks', newStocks); }; + // Memoized unified products for stock selection + const unifiedStockProducts = useMemo(() => { + if (!isResponseSuccess(stockProducts)) return []; + const options: OptionType[] = []; + + stockProducts.data.forEach((product) => { + const warehouse = product.warehouse; + const stockText = product.quantity.toLocaleString('id-ID'); + + // Check if product has any of the flags + const hasPakanFlag = product.product.flags?.includes('PAKAN'); + const hasOvkFlag = product.product.flags?.includes('OVK'); + + // Add products with warehouse and location grouping in label (similar to projectFlockKandangOptions pattern) + if (hasPakanFlag) { + options.push({ + value: product.id, + label: `[PAKAN] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` + }); + } + + if (hasOvkFlag) { + options.push({ + value: product.id, + label: `[OVK] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` + }); + } + }); + + return options; + }, [stockProducts, getProjectFlockLocation()]); + + + // Unified Stock remove handlers const removeStock = (idx: number) => { const updatedStocks = formik.values.stocks?.filter((_, i) => i !== idx); formik.setFieldValue('stocks', updatedStocks); @@ -467,7 +539,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; - + return ( <>
@@ -534,8 +606,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={ formik.values.record_datetime instanceof Date ? formik.values.record_datetime - .toISOString() - .substring(0, 16) + .toISOString() + .substring(0, 16) : '' } onChange={(e) => { @@ -583,202 +655,202 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
- - {type !== 'detail' && ( - - )} + + {type !== 'detail' && ( + )} + - + - + - - {type !== 'detail' && } - + + + {type !== 'detail' && } + - {formik.values.body_weights?.map((bw, idx) => ( - - {type !== 'detail' && ( - - )} - + {type !== 'detail' && ( + + )} + + required + name={`body_weights.${idx}.weight`} + value={bw.weight} + onChange={handleWeightChangeWrapper(idx)} + onBlur={formik.handleBlur} + maskType='weight' + weightUnit='gram' + decimals={2} + min={0} + thousandSeparator=',' + decimalSeparator='.' + isError={ + isRepeaterInputError('body_weights', 'weight', idx) + .isError + } + errorMessage={ + isRepeaterInputError('body_weights', 'weight', idx) + .errorMessage + } + readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-32', + }} + /> + + + + + {type !== 'detail' && ( - - - {type !== 'detail' && ( - - )} - - ))} + )} + + ))}
- 0 - } - onChange={(e: React.ChangeEvent) => { - if (e.target.checked) { - setSelectedBodyWeights( - formik.values.body_weights?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedBodyWeights([]); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> -
- Berat Ayam (gram) - + 0 + } + onChange={(e: React.ChangeEvent) => { + if (e.target.checked) { + setSelectedBodyWeights( + formik.values.body_weights?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedBodyWeights([]); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Berat Ayam (gram) + * - - Jumlah Ayam - + + Jumlah Ayam + * - - Rata-rata Berat Ayam (gram) - + + Rata-rata Berat Ayam (gram) + - CatatanAction
CatatanAction
- ) => { - if (e.target.checked) { - setSelectedBodyWeights([ - ...selectedBodyWeights, - idx, - ]); - } else { - setSelectedBodyWeights( - selectedBodyWeights.filter((i) => i !== idx), - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - + {formik.values.body_weights?.map((bw, idx) => ( +
+ ) => { + if (e.target.checked) { + setSelectedBodyWeights([ + ...selectedBodyWeights, + idx, + ]); + } else { + setSelectedBodyWeights( + selectedBodyWeights.filter((i) => i !== idx), + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + - + + + { + handleAverageWeightBlur(); + formik.handleBlur(e); + }} + maskType='weight' + weightUnit='gram' + decimals={2} + min={0} + thousandSeparator=',' + decimalSeparator='.' + isError={ + isRepeaterInputError('body_weights', 'average_weight', idx) + .isError + } + errorMessage={ + isRepeaterInputError('body_weights', 'average_weight', idx) + .errorMessage + } + readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-32', + }} + /> + + + - +
+ +
- { - handleAverageWeightBlur(); - formik.handleBlur(e); - }} - maskType='weight' - weightUnit='gram' - decimals={2} - min={0} - thousandSeparator=',' - decimalSeparator='.' - isError={ - isRepeaterInputError('body_weights', 'average_weight', idx) - .isError - } - errorMessage={ - isRepeaterInputError('body_weights', 'average_weight', idx) - .errorMessage - } - readOnly={type === 'detail'} - className={{ - wrapper: 'w-full min-w-32', - }} - /> - - - -
- -
-
@@ -820,208 +892,218 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
- - {type !== 'detail' && ( - - )} + + {type !== 'detail' && ( + )} + - - - - - {type !== 'detail' && } - + + + + + + {type !== 'detail' && } + - {formik.values.stocks?.map((stock, idx) => ( - - {type !== 'detail' && ( - - )} - + {type !== 'detail' && ( + + )} + + + + + + name={`stocks.${idx}.notes`} + value={stock.notes || ''} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + placeholder='Catatan...' + readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-32', + }} + /> + + {type !== 'detail' && ( - - - - {type !== 'detail' && ( - - )} - - ))} + )} + + ))}
- 0 - } - onChange={(e: React.ChangeEvent) => { - if (e.target.checked) { - setSelectedStocks( - formik.values.stocks?.map((_, idx) => idx) ?? - [] - ); - } else { - setSelectedStocks([]); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> -
- Persediaan - + 0 + } + onChange={(e: React.ChangeEvent) => { + if (e.target.checked) { + setSelectedStocks( + formik.values.stocks?.map((_, idx) => idx) ?? + [] + ); + } else { + setSelectedStocks([]); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Persediaan + * - PenambahanPenguranganJumlah PakaiCatatanAction
PenambahanPenguranganJumlah PakaiCatatanAction
- ) => { - if (e.target.checked) { - setSelectedStocks([...selectedStocks, idx]); - } else { - setSelectedStocks( - selectedStocks.filter((i) => i !== idx), - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - + {formik.values.stocks?.map((stock, idx) => ( +
+ ) => { + if (e.target.checked) { + setSelectedStocks([...selectedStocks, idx]); + } else { + setSelectedStocks( + selectedStocks.filter((i) => i !== idx), + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + + product.value === stock.product_warehouse_id + ) || null + } + onChange={(selectedOption) => { + const option = selectedOption as OptionType | null; + formik.setFieldValue( + `stocks.${idx}.product_warehouse_id`, + option?.value || 0 + ); + }} + options={unifiedStockProducts} + placeholder='Pilih Produk' + isLoading={isLoadingStockProducts} + isError={ + isRepeaterInputError( + 'stocks', + 'product_warehouse_id', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'stocks', + 'product_warehouse_id', + idx + ).errorMessage + } + className={{ + wrapper: 'w-full min-w-48', + }} + isSearchable + /> + + + + + + + - - +
+ +
- - - - - - -
- -
-
@@ -1063,200 +1145,200 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
- - {type !== 'detail' && ( - - )} + + {type !== 'detail' && ( + )} + - + - + - - {type !== 'detail' && } - + + + {type !== 'detail' && } + - {formik.values.depletions?.map((depletion, idx) => ( - - {type !== 'detail' && ( - - )} - + {type !== 'detail' && ( + + )} + + required + name={`depletions.${idx}.product_warehouse_id`} + type='number' + value={ + depletion.product_warehouse_id?.toString() || '' + } + onChange={formik.handleChange} + onBlur={formik.handleBlur} + isError={ + isRepeaterInputError( + 'depletions', + 'product_warehouse_id', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'depletions', + 'product_warehouse_id', + idx + ).errorMessage + } + readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-32', + }} + placeholder='Product Warehouse ID' + /> + + + + + {type !== 'detail' && ( - - - {type !== 'detail' && ( - - )} - - ))} + )} + + ))}
- 0 - } - onChange={(e: React.ChangeEvent) => { - if (e.target.checked) { - setSelectedDepletions( - formik.values.depletions?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedDepletions([]); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> -
- Product Warehouse ID - + 0 + } + onChange={(e: React.ChangeEvent) => { + if (e.target.checked) { + setSelectedDepletions( + formik.values.depletions?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedDepletions([]); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Product Warehouse ID + * - - Kondisi - + + Kondisi + * - - Total - + + Total + * - CatatanAction
CatatanAction
- ) => { - if (e.target.checked) { - setSelectedDepletions([ - ...selectedDepletions, - idx, - ]); - } else { - setSelectedDepletions( - selectedDepletions.filter((i) => i !== idx), - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - + {formik.values.depletions?.map((depletion, idx) => ( +
+ ) => { + if (e.target.checked) { + setSelectedDepletions([ + ...selectedDepletions, + idx, + ]); + } else { + setSelectedDepletions( + selectedDepletions.filter((i) => i !== idx), + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + - + + + + + + - +
+ +
- - - - -
- -
-
From c30fcd81b2e4b9459a87ffe433fc563a28765031 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 08:53:41 +0700 Subject: [PATCH 011/174] refactor(FE-114): simplify CreateRecordingPayload structure and update validation in RecordingForm --- .../recording/form/RecordingForm.schema.ts | 43 +-- .../recording/form/RecordingForm.tsx | 260 ++---------------- src/types/api/production/recording.d.ts | 17 +- 3 files changed, 31 insertions(+), 289 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index b1113ae2..597a24e1 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -19,14 +19,6 @@ export const RecordingFormSchema = Yup.object({ (value) => value !== undefined && value !== null && value > 0 ) .required('Project Flock Kandang wajib diisi!'), - record_datetime: Yup.date() - .required('Tanggal dan waktu recording wajib diisi') - .typeError('Format tanggal dan waktu tidak valid'), - status: Yup.number() - .optional() - .oneOf([0, 1, 2, 3], 'Status tidak valid') - .typeError('Status harus berupa angka!'), - ontime: Yup.boolean().optional(), body_weights: Yup.array() .of( Yup.object({ @@ -44,7 +36,6 @@ export const RecordingFormSchema = Yup.object({ .min(0, 'Rata-rata berat tidak boleh negatif!') .typeError('Rata-rata berat harus berupa angka!') .default(0), - notes: Yup.string().optional(), }) ) .min(1, 'Minimal harus ada 1 data bobot badan!') @@ -56,14 +47,6 @@ export const RecordingFormSchema = Yup.object({ .required('Produk wajib diisi!') .min(1, 'Produk wajib diisi!') .typeError('Produk harus berupa angka!'), - increase: Yup.number() - .optional() - .min(0, 'Penambahan tidak boleh negatif!') - .typeError('Penambahan harus berupa angka!'), - decrease: Yup.number() - .optional() - .min(0, 'Pengurangan tidak boleh negatif!') - .typeError('Pengurangan harus berupa angka!'), usage_amount: Yup.number() .optional() .min(0, 'Jumlah penggunaan tidak boleh negatif!') @@ -77,10 +60,14 @@ export const RecordingFormSchema = Yup.object({ .of( Yup.object({ product_warehouse_id: Yup.number() - .required('Produk wajib diisi!') + .optional() .min(1, 'Produk wajib diisi!') .typeError('Produk harus berupa angka!'), - condition: Yup.string() + total: Yup.number() + .required('Jumlah depletions wajib diisi!') + .min(1, 'Jumlah depletions minimal 1!') + .typeError('Jumlah depletions harus berupa angka!'), + notes: Yup.string() .required('Kondisi depletions wajib diisi!') .oneOf( RECORDING_FLAG_OPTIONS.map((option) => option.value), @@ -88,11 +75,6 @@ export const RecordingFormSchema = Yup.object({ ) .typeError('Kondisi depletions harus berupa teks!') .min(1, 'Kondisi depletions wajib diisi!'), - total: Yup.number() - .required('Jumlah depletions wajib diisi!') - .min(1, 'Jumlah depletions minimal 1!') - .typeError('Jumlah depletions harus berupa angka!'), - notes: Yup.string().optional(), }) ) .min(1, 'Minimal harus ada 1 data depletions!') @@ -119,39 +101,28 @@ export const getRecordingFormInitialValues = ( } : null, project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0, - record_datetime: initialValues?.record_datetime - ? new Date(initialValues.record_datetime) - : new Date(), - status: initialValues?.status ?? 1, - ontime: initialValues?.ontime ?? true, body_weights: initialValues?.body_weights?.map( (bw: NonNullable[0]) => ({ weight: bw.weight, qty: bw.qty, average_weight: bw.qty > 0 ? Math.round(bw.weight / bw.qty) : 0, - notes: bw.notes || '', }) ) ?? [ { weight: 0, qty: 1, average_weight: 0, - notes: '', }, ], stocks: initialValues?.stocks?.map( (stock: NonNullable[0]) => ({ product_warehouse_id: stock.product_warehouse_id, - increase: stock.increase, - decrease: stock.decrease, usage_amount: stock.usage_amount, notes: stock.notes, }) ) ?? [ { product_warehouse_id: 0, - increase: 0, - decrease: 0, usage_amount: 0, notes: '', }, @@ -159,14 +130,12 @@ export const getRecordingFormInitialValues = ( depletions: initialValues?.depletions?.map( (depletion: NonNullable[0]) => ({ product_warehouse_id: depletion.product_warehouse_id, - condition: depletion.condition, total: depletion.total, notes: depletion.notes, }) ) ?? [ { product_warehouse_id: 0, - condition: '', total: 0, notes: '', }, diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 382d609a..3b98ae3a 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -28,6 +28,7 @@ import { ProjectFlockApi } from '@/services/api/production'; import { LocationApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { isResponseSuccess } from '@/lib/api-helper'; +import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import Card from '@/components/Card'; @@ -141,12 +142,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { onSubmit: async (values) => { const payload: CreateRecordingPayload = { project_flock_kandang_id: values.project_flock_kandang_id, - record_datetime: - values.record_datetime instanceof Date - ? values.record_datetime.toISOString() - : '', - status: values.status, - ontime: values.ontime, body_weights: (values.body_weights ?? []).map((bw) => ({ weight: typeof bw.weight === 'number' @@ -156,28 +151,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { typeof bw.qty === 'number' ? bw.qty : parseFloat(String(bw.qty)) || 0, - notes: bw.notes, - // average_weight is not included in payload as it's calculated field only })), stocks: (values.stocks ?? []).map((stock) => ({ product_warehouse_id: stock.product_warehouse_id, - increase: - typeof stock.increase === 'number' - ? stock.increase - : parseFloat(String(stock.increase)) || 0, - decrease: - typeof stock.decrease === 'number' - ? stock.decrease - : parseFloat(String(stock.decrease)) || 0, usage_amount: typeof stock.usage_amount === 'number' ? stock.usage_amount : parseFloat(String(stock.usage_amount)) || 0, - notes: stock.notes, + notes: stock.notes || '', })), depletions: (values.depletions ?? []).map((depletion) => ({ - product_warehouse_id: depletion.product_warehouse_id, - condition: depletion.condition, total: typeof depletion.total === 'number' ? depletion.total @@ -236,42 +219,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // EVENT HANDLERS - Date Time const recordDateTimeChangeHandler = (datetime: Date | null) => { formik.setFieldValue('record_datetime', datetime, false); - - // Auto-set ontime based on date difference - if (datetime) { - const today = new Date(); - const recordDate = new Date(datetime); - - // Reset time to compare only dates - const todayDateOnly = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - const recordDateOnly = new Date(recordDate.getFullYear(), recordDate.getMonth(), recordDate.getDate()); - - // Set ontime to true if recording date is today, false otherwise - const isOnTime = todayDateOnly.getTime() === recordDateOnly.getTime(); - formik.setFieldValue('ontime', isOnTime, false); - } }; - // Set initial ontime value when form loads or record_datetime changes - useEffect(() => { - if (formik.values.record_datetime) { - const today = new Date(); - const recordDate = new Date(formik.values.record_datetime); - - // Reset time to compare only dates - const todayDateOnly = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - const recordDateOnly = new Date(recordDate.getFullYear(), recordDate.getMonth(), recordDate.getDate()); - - // Set ontime to true if recording date is today, false otherwise - const isOnTime = todayDateOnly.getTime() === recordDateOnly.getTime(); - - // Only update if ontime is not set or different from calculated value - if (formik.values.ontime !== isOnTime) { - formik.setFieldValue('ontime', isOnTime, false); - } - } - }, [formik.values.record_datetime]); - // Auto-calculate average weight when weight or qty changes (but not when editing average weight manually) useEffect(() => { if (formik.values.body_weights && editingAverageIndex === null) { @@ -315,7 +264,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { weight: 0, qty: 1, - notes: '', average_weight: 0, }, ]; @@ -415,8 +363,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...(formik.values.stocks || []), { product_warehouse_id: 0, - increase: 0, - decrease: 0, usage_amount: 0, notes: '', }, @@ -476,8 +422,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const newDepletions = [ ...(formik.values.depletions || []), { - product_warehouse_id: 0, - condition: '', total: 0, notes: '', }, @@ -597,49 +541,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isClearable isSearchable /> - - { - const datetime = e.target.value - ? new Date(e.target.value) - : null; - recordDateTimeChangeHandler(datetime); - }} - onBlur={formik.handleBlur} - isError={ - formik.touched.record_datetime && - Boolean(formik.errors.record_datetime) - } - errorMessage={formik.errors.record_datetime as string} - readOnly={type === 'detail'} - /> - - { - const value = e.target.value; - formik.setFieldValue('status', parseInt(value) || 0); - }} - onBlur={formik.handleBlur} - isError={formik.touched.status && Boolean(formik.errors.status)} - errorMessage={formik.errors.status as string} - readOnly={type === 'detail'} - placeholder='Masukkan status (0-3)' - />
@@ -710,7 +611,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { - Catatan {type !== 'detail' && Action} @@ -823,19 +723,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }} /> - - - {type !== 'detail' && (
@@ -928,8 +815,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { * - Penambahan - Pengurangan Jumlah Pakai Catatan {type !== 'detail' && Action} @@ -998,59 +883,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isSearchable /> - - - - - - - + { /> )} - - Product Warehouse ID - - * - - Kondisi - - * - Total @@ -1198,9 +1016,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { data-tip='required' > * - + - Catatan {type !== 'detail' && Action} @@ -1232,56 +1049,32 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} - option.value === depletion.notes + ) || null} + onChange={(selectedOption) => { + const option = selectedOption as OptionType | null; + formik.setFieldValue( + `depletions.${idx}.notes`, + option?.value || '' + ); }} - placeholder='Product Warehouse ID' - /> - - - @@ -1311,19 +1104,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { placeholder='Total' /> - - - {type !== 'detail' && (
diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index f276562a..dcbbeed6 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -27,26 +27,19 @@ export type Recording = BaseMetadata & BaseRecording; export type CreateRecordingPayload = { project_flock_kandang_id: number; - record_datetime: string; - status?: number; - ontime?: boolean; - body_weights?: { + body_weights: { weight: number; qty: number; - notes?: string; }[]; stocks?: { product_warehouse_id: number; - increase?: number; - decrease?: number; - usage_amount?: number; - notes?: string; + usage_amount: number; + notes: string; }[]; depletions?: { - product_warehouse_id: number; - condition: string; + product_warehouse_id?: number; total: number; - notes?: string; + notes: string; }[]; }; From a9f0696b38cd4013f1fb6aa8cb6a86e2e9145c75 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 09:50:12 +0700 Subject: [PATCH 012/174] refactor(FE-114): auto-populate notes with product name and enhance tooltip visibility in RecordingForm --- .../recording/form/RecordingForm.tsx | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 3b98ae3a..15f7fcdf 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -815,8 +815,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { * - Jumlah Pakai - Catatan + Jumlah Pakai + + * + + {type !== 'detail' && Action} @@ -859,6 +865,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { `stocks.${idx}.product_warehouse_id`, option?.value || 0 ); + // Auto-populate notes with product name by finding it in stockProducts data + if (option?.value && isResponseSuccess(stockProducts)) { + const selectedProduct = stockProducts.data.find( + (product) => product.id === option.value + ); + if (selectedProduct) { + formik.setFieldValue( + `stocks.${idx}.notes`, + selectedProduct.product.name + ); + } + } }} options={unifiedStockProducts} placeholder='Pilih Produk' @@ -909,20 +927,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { placeholder='Jumlah Pakai' /> - - - - {type !== 'detail' && ( + {type !== 'detail' && (
)} @@ -251,7 +248,7 @@ const RecordingTable = () => { { { columns={[ { id: 'select', - accessorKey: 'id', header: ({ table }) => ( - 0 && - table - .getRowModel() - .rows.every((row) => selectedRecordings.includes(row.index)) - } - onChange={(e) => { - if (e.target.checked) { - setSelectedRecordings( - table.getRowModel().rows.map((row) => row.index) - ); - } else { - setSelectedRecordings([]); - } - }} - /> +
+ +
), cell: ({ row }) => ( - { - if (e.target.checked) { - setSelectedRecordings([...selectedRecordings, row.index]); - } else { - setSelectedRecordings( - selectedRecordings.filter((i) => i !== row.index) - ); - } - }} - /> +
+ +
), }, { @@ -403,6 +383,8 @@ const RecordingTable = () => { isLoading={false} sorting={sorting} setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} className={{ containerClassName: cn({ 'mb-20': paginatedData.length === 0, From adc995dbe7dd19cdc4dcda9af19de3c30d063285 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 11:00:14 +0700 Subject: [PATCH 016/174] feat(US-114): enhance auto-calculation logic in RecordingForm to handle manual edits --- .../recording/form/RecordingForm.tsx | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index e5a2531c..10d3aa30 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -45,6 +45,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Track which average weight field is being edited to prevent auto-calculation override const [editingAverageIndex, setEditingAverageIndex] = useState(null); + // Track which rows have been manually edited to prevent auto-calculation override + const [manuallyEditedRows, setManuallyEditedRows] = useState>(new Set()); + // State for Location search and selection const [locationSearchValue, setLocationSearchValue] = useState(''); const [selectedLocation, setSelectedLocation] = useState(null); @@ -223,10 +226,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Auto-calculate average weight when weight or qty changes (but not when editing average weight manually) useEffect(() => { + // Only run auto-calculation if no field is being edited if (formik.values.body_weights && editingAverageIndex === null) { const updatedBodyWeights = formik.values.body_weights.map((weight, idx) => { - // Skip auto-calculation for the field being manually edited - if (idx === editingAverageIndex) { + // Skip the field that's being edited or has been manually edited + if (idx === editingAverageIndex || manuallyEditedRows.has(idx)) { return weight; } @@ -234,7 +238,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...weight, average_weight: weight.qty > 0 && weight.weight > 0 - ? Math.round(weight.weight / weight.qty) + ? parseFloat((weight.weight / weight.qty).toFixed(2)) : 0, }; }); @@ -243,11 +247,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const hasChanges = updatedBodyWeights.some( (updated, idx) => idx !== editingAverageIndex && // Skip the field being edited + !manuallyEditedRows.has(idx) && // Skip manually edited rows updated.average_weight !== (formik.values.body_weights[idx]?.average_weight || 0) ); if (hasChanges) { + // Use false to prevent triggering validation and other side effects formik.setFieldValue('body_weights', updatedBodyWeights, false); } } @@ -255,6 +261,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.values.body_weights?.map((w) => w.weight), formik.values.body_weights?.map((w) => w.qty), editingAverageIndex, // Include editing index in dependencies + manuallyEditedRows, // Include manually edited rows in dependencies ]); // EVENT HANDLERS - Body Weights @@ -274,11 +281,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const handleWeightChange = (idx: number, value: number) => { formik.setFieldValue(`body_weights.${idx}.weight`, value); + // Reset manual edit flag when weight changes (user wants auto-calculation) + setManuallyEditedRows(prev => { + const newSet = new Set(prev); + newSet.delete(idx); + return newSet; + }); + const currentWeight = formik.values.body_weights?.[idx]; if (currentWeight) { const qty = currentWeight.qty; if (qty > 0 && value > 0) { - const averageWeight = Math.round(value / qty); + const averageWeight = parseFloat((value / qty).toFixed(2)); formik.setFieldValue(`body_weights.${idx}.average_weight`, averageWeight); } else { formik.setFieldValue(`body_weights.${idx}.average_weight`, 0); @@ -290,11 +304,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const handleQtyChange = (idx: number, value: number) => { formik.setFieldValue(`body_weights.${idx}.qty`, value); + // Reset manual edit flag when qty changes (user wants auto-calculation) + setManuallyEditedRows(prev => { + const newSet = new Set(prev); + newSet.delete(idx); + return newSet; + }); + const currentWeight = formik.values.body_weights?.[idx]; if (currentWeight) { const weight = currentWeight.weight; if (value > 0 && weight > 0) { - const averageWeight = Math.round(weight / value); + const averageWeight = parseFloat((weight / value).toFixed(2)); formik.setFieldValue(`body_weights.${idx}.average_weight`, averageWeight); } else { formik.setFieldValue(`body_weights.${idx}.average_weight`, 0); @@ -320,12 +341,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Create wrapper handlers that match NumberInput's onChange signature const handleWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { - const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0; + // Parse the value more carefully to handle decimal numbers properly + const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); + // Convert comma thousand separator to nothing, but keep decimal point + const normalizedValue = rawValue.replace(/,/g, ''); + const value = parseFloat(normalizedValue) || 0; handleWeightChange(idx, value); }; const handleQtyChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { - const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0; + // Parse the value more carefully to handle decimal numbers properly + const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); + // Convert comma thousand separator to nothing, but keep decimal point + const normalizedValue = rawValue.replace(/,/g, ''); + const value = parseFloat(normalizedValue) || 0; handleQtyChange(idx, value); }; @@ -333,11 +362,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Set focus state to prevent auto-calculation override setEditingAverageIndex(idx); - const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0; + // Mark this row as manually edited + setManuallyEditedRows(prev => new Set(prev).add(idx)); + + // Parse the value more carefully to handle decimal numbers properly + const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); + // Convert comma thousand separator to nothing, but keep decimal point + const normalizedValue = rawValue.replace(/,/g, ''); + const value = parseFloat(normalizedValue) || 0; handleAverageWeightChange(idx, value); }; - const handleAverageWeightBlur = () => { + const handleAverageWeightBlur = (idx: number) => { // Clear focus state when user leaves the field to re-enable auto-calculation setEditingAverageIndex(null); }; @@ -700,7 +736,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bw.average_weight || 0} onChange={handleAverageWeightChangeWrapper(idx)} onBlur={(e) => { - handleAverageWeightBlur(); + handleAverageWeightBlur(idx); formik.handleBlur(e); }} maskType='weight' From b148a09e843b0d8a585975ad896e88f881ef6d92 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 11:27:32 +0700 Subject: [PATCH 017/174] feat(US-137): update API endpoints and default values in RecordingForm for production environment --- .../pages/production/recording/form/RecordingForm.tsx | 1 + .../production/recording/form/useRecordingFormHandlers.ts | 6 +++--- src/services/api/production.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 10d3aa30..e9dfb39f 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -164,6 +164,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { notes: stock.notes || '', })), depletions: (values.depletions ?? []).map((depletion) => ({ + product_warehouse_id: 1, total: typeof depletion.total === 'number' ? depletion.total diff --git a/src/components/pages/production/recording/form/useRecordingFormHandlers.ts b/src/components/pages/production/recording/form/useRecordingFormHandlers.ts index 334b791d..58893ce1 100644 --- a/src/components/pages/production/recording/form/useRecordingFormHandlers.ts +++ b/src/components/pages/production/recording/form/useRecordingFormHandlers.ts @@ -24,7 +24,7 @@ export const useRecordingFormHandlers = (initialValuesId?: number) => { return; } toast.success(res?.message as string); - router.push('/flock/recording'); + router.push('/production/recording'); }, [router] ); @@ -38,7 +38,7 @@ export const useRecordingFormHandlers = (initialValuesId?: number) => { } toast.success(res?.message as string); router.refresh(); - router.push('/flock/recording'); + router.push('/production/recording'); }, [router] ); @@ -55,7 +55,7 @@ export const useRecordingFormHandlers = (initialValuesId?: number) => { deleteModal.closeModal(); toast.success('Successfully delete Recording!'); setIsDeleteLoading(false); - router.push('/flock/recording'); + router.push('/production/recording'); }, [deleteModal, initialValuesId, router]); return { diff --git a/src/services/api/production.ts b/src/services/api/production.ts index 6d33af07..5c2754c8 100644 --- a/src/services/api/production.ts +++ b/src/services/api/production.ts @@ -24,7 +24,7 @@ export const RecordingApi = new BaseApiService< Recording, CreateRecordingPayload, UpdateRecordingPayload ->('/flock/recordings'); +>('/production/recordings'); export const ChickinApi = new BaseApiService< Chickin, CreateChickinPayload, From 12a69b7c6cf0e9293c3983424be7ffcf839e0df4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 11:35:11 +0700 Subject: [PATCH 018/174] feat(FE-137): integrate SWR for fetching recordings and update table to display API data --- .../production/recording/RecordingTable.tsx | 97 ++++++++----------- 1 file changed, 38 insertions(+), 59 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 744ee6d8..4828cdd8 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useMemo, useState } from 'react'; +import useSWR from 'swr'; import { Icon } from '@iconify/react'; import { SortingState } from '@tanstack/react-table'; import { cn } from '@/lib/helper'; @@ -18,52 +19,21 @@ import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import { type CellContext } from '@tanstack/react-table'; import { type Recording } from '@/types/api/production/recording'; import { type ProjectFlock } from '@/types/api/production/project-flock'; +import { RecordingApi } from '@/services/api/production'; +import { type BaseApiResponse } from '@/types/api/api-general'; // Extended type that includes related data type RecordingWithRelations = Recording & { project_flock?: ProjectFlock; }; -const dummyRecordings: RecordingWithRelations[] = [ - { - id: 1, - project_flock_kandang_id: 1, - record_date: '2024-01-01', - ontime: true, - day: 10, - status: 1, - total_depletion: 10, - cum_depletion_rate: 1.0, - daily_gain: 50, - avg_daily_gain: 5.0, - cum_intake: 200, - fcr_value: 1.5, - total_chick: 1000, - daily_depletion_rate: 0.5, - cum_depletion: 20, - record_datetime: '2024-01-01T08:00:00Z', - created_at: '2024-01-01', - updated_at: '2024-01-01', - created_user: { - id: 1, - id_user: 1, - email: 'admin@example.com', - name: 'Admin', - image: null, - npk: '0001', - created_at: '2024-01-01', - updated_at: '2024-01-01', - }, - }, -]; - const RowOptionsMenu = ({ type = 'dropdown', props, deleteClickHandler, }: { type: 'dropdown' | 'collapse'; - props: CellContext; + props: CellContext; deleteClickHandler: () => void; }) => { return ( @@ -119,7 +89,7 @@ const RecordingTable = () => { const [pageSize, setPageSize] = useState(10); const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); - const [, setSelectedRecording] = useState(undefined); + const [, setSelectedRecording] = useState(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false); const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); @@ -128,6 +98,16 @@ const RecordingTable = () => { const bulkApproveModal = useModal(); const bulkRejectModal = useModal(); + // Fetch recordings using SWR + const { + data: recordings, + isLoading, + mutate: refreshRecordings, + } = useSWR( + `${RecordingApi.basePath}?page=${page}&limit=${pageSize}`, + RecordingApi.getAllFetcher + ); + const searchChangeHandler = useCallback( (e: React.ChangeEvent) => { setSearch(e.target.value); @@ -146,20 +126,18 @@ const RecordingTable = () => { ); const paginatedData = useMemo(() => { - const filteredData = dummyRecordings.filter( - (recording: RecordingWithRelations) => { - const projectName = recording.project_flock?.name || ''; - const locationName = recording.project_flock?.location?.name || ''; - const coopName = recording.project_flock?.kandangs?.[0]?.name || ''; + if (!recordings || recordings.status !== 'success') return []; - return projectName.toLowerCase().includes(search.toLowerCase()) || - locationName.toLowerCase().includes(search.toLowerCase()) || - coopName.toLowerCase().includes(search.toLowerCase()); + return recordings.data.filter( + (recording: Recording) => { + // For now, we don't have project_flock relation data in the API response + // So we'll filter by basic recording data + return recording.project_flock_kandang_id.toString().includes(search.toLowerCase()) || + recording.record_date.includes(search.toLowerCase()) || + recording.created_user.name.toLowerCase().includes(search.toLowerCase()); } ); - const start = (page - 1) * pageSize; - return filteredData.slice(start, start + pageSize); - }, [page, pageSize, search]); + }, [recordings, search]); const selectedRowIds = Object.keys(rowSelection).map((item) => parseInt(item)); @@ -308,8 +286,8 @@ const RecordingTable = () => { cell: (props) => pageSize * (page - 1) + props.row.index + 1, }, { - header: 'Flock', - cell: (props) => props.row.original.project_flock?.name || '-', + header: 'Flock Kandang ID', + cell: (props) => props.row.original.project_flock_kandang_id, }, { accessorKey: 'record_date', @@ -318,24 +296,25 @@ const RecordingTable = () => { new Date(props.row.original.record_date).toLocaleDateString(), }, { - header: 'Lokasi', - cell: (props) => props.row.original.project_flock?.location?.name || '-', + header: 'Day', + cell: (props) => props.row.original.day, }, { - header: 'Kandang', - cell: (props) => { - const coopName = props.row.original.project_flock?.kandangs?.[0]?.name; - return coopName || '-'; - }, + header: 'Status', + cell: (props) => props.row.original.status, }, { accessorKey: 'total_depletion', header: 'Total Depletion', cell: (props) => props.row.original.total_depletion, }, + { + header: 'Created By', + cell: (props) => props.row.original.created_user.name, + }, { header: 'Aksi', - cell: (props: CellContext) => { + cell: (props: CellContext) => { const currentPageSize = props.table.getPaginationRowModel().rows.length; const currentPageRows = @@ -377,10 +356,10 @@ const RecordingTable = () => { }, ]} pageSize={pageSize} - page={page} - totalItems={dummyRecordings.length} + page={recordings?.status === 'success' ? recordings.meta?.page : page} + totalItems={recordings?.status === 'success' ? recordings.meta?.total_results : 0} onPageChange={setPage} - isLoading={false} + isLoading={isLoading} sorting={sorting} setSorting={setSorting} rowSelection={rowSelection} From 258324f092845935fc840276e3f04e73217cca3d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 11:36:14 +0700 Subject: [PATCH 019/174] feat(US-137): update RecordingTable to enhance data display and add new columns for project details --- .../production/recording/RecordingTable.tsx | 88 ++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 4828cdd8..d0b3bce9 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -286,31 +286,99 @@ const RecordingTable = () => { cell: (props) => pageSize * (page - 1) + props.row.index + 1, }, { - header: 'Flock Kandang ID', - cell: (props) => props.row.original.project_flock_kandang_id, + header: 'ID', + cell: (props) => props.row.original.id, + }, + { + header: 'Nama Project', + cell: (props) => `Project ${props.row.original.project_flock_kandang_id}`, + }, + { + header: 'Periode', + cell: (props) => props.row.original.day, + }, + { + header: 'Umur (hari)', + cell: (props) => props.row.original.day, }, { accessorKey: 'record_date', - header: 'Tanggal Recording', + header: 'Waktu Recording', cell: (props) => new Date(props.row.original.record_date).toLocaleDateString(), }, { - header: 'Day', - cell: (props) => props.row.original.day, + header: 'Populasi Awal', + cell: (props) => props.row.original.total_chick?.toLocaleString() || '-', }, { - header: 'Status', - cell: (props) => props.row.original.status, + header: 'Ekor Panen', + cell: (props) => '-', + }, + { + header: 'KG Panen', + cell: (props) => '-', + }, + { + header: 'BW', + cell: (props) => props.row.original.avg_daily_gain?.toFixed(2) || '-', + }, + { + header: 'Pakan', + cell: (props) => props.row.original.cum_intake?.toLocaleString() || '-', + }, + { + header: 'FCR', + cell: (props) => props.row.original.fcr_value?.toFixed(2) || '-', + }, + { + header: 'Deplesi Culling', + cell: (props) => '-', + }, + { + header: 'Deplesi Mati', + cell: (props) => '-', + }, + { + header: 'Deplesi Afkir', + cell: (props) => '-', }, { accessorKey: 'total_depletion', - header: 'Total Depletion', + header: 'Total Deplesi', cell: (props) => props.row.original.total_depletion, }, { - header: 'Created By', - cell: (props) => props.row.original.created_user.name, + header: 'Deplesi (%)', + cell: (props) => props.row.original.daily_depletion_rate?.toFixed(2) || '-', + }, + { + header: 'Populasi Akhir', + cell: (props) => (props.row.original.total_chick - props.row.original.total_depletion)?.toLocaleString() || '-', + }, + { + header: 'IP', + cell: (props) => '-', + }, + { + header: 'Status Recording', + cell: (props) => { + const status = props.row.original.status; + return status === 1 ? 'Menunggu Persetujuan' : 'Disetujui'; + }, + }, + { + header: 'Ketepatan Waktu', + cell: (props) => props.row.original.ontime ? 'Tepat Waktu' : 'Terlambat', + }, + { + header: 'Status Perubahan', + cell: (props) => 'Tidak ada perubahan', + }, + { + header: 'Tanggal Submit', + cell: (props) => + new Date(props.row.original.created_at).toLocaleString(), }, { header: 'Aksi', From c546bd6b3c34d714942c663099c8824fc571a558 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 11:37:25 +0700 Subject: [PATCH 020/174] feat(FE-137): refactor RecordingTable to remove unused types and streamline data fetching --- .../pages/production/recording/RecordingTable.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index d0b3bce9..833f8500 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -18,14 +18,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import { type CellContext } from '@tanstack/react-table'; import { type Recording } from '@/types/api/production/recording'; -import { type ProjectFlock } from '@/types/api/production/project-flock'; import { RecordingApi } from '@/services/api/production'; -import { type BaseApiResponse } from '@/types/api/api-general'; - -// Extended type that includes related data -type RecordingWithRelations = Recording & { - project_flock?: ProjectFlock; -}; const RowOptionsMenu = ({ type = 'dropdown', @@ -98,11 +91,9 @@ const RecordingTable = () => { const bulkApproveModal = useModal(); const bulkRejectModal = useModal(); - // Fetch recordings using SWR const { data: recordings, isLoading, - mutate: refreshRecordings, } = useSWR( `${RecordingApi.basePath}?page=${page}&limit=${pageSize}`, RecordingApi.getAllFetcher From 00de4782e765c622c6cbcd3cb3a2adcd108c49a9 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 12:14:47 +0700 Subject: [PATCH 021/174] feat(FE-137): simplify RecordingTable by removing unused columns and enhancing data clarity --- .../production/recording/RecordingTable.tsx | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 833f8500..832cdbcd 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -302,14 +302,6 @@ const RecordingTable = () => { header: 'Populasi Awal', cell: (props) => props.row.original.total_chick?.toLocaleString() || '-', }, - { - header: 'Ekor Panen', - cell: (props) => '-', - }, - { - header: 'KG Panen', - cell: (props) => '-', - }, { header: 'BW', cell: (props) => props.row.original.avg_daily_gain?.toFixed(2) || '-', @@ -322,18 +314,6 @@ const RecordingTable = () => { header: 'FCR', cell: (props) => props.row.original.fcr_value?.toFixed(2) || '-', }, - { - header: 'Deplesi Culling', - cell: (props) => '-', - }, - { - header: 'Deplesi Mati', - cell: (props) => '-', - }, - { - header: 'Deplesi Afkir', - cell: (props) => '-', - }, { accessorKey: 'total_depletion', header: 'Total Deplesi', @@ -347,10 +327,6 @@ const RecordingTable = () => { header: 'Populasi Akhir', cell: (props) => (props.row.original.total_chick - props.row.original.total_depletion)?.toLocaleString() || '-', }, - { - header: 'IP', - cell: (props) => '-', - }, { header: 'Status Recording', cell: (props) => { From 0c4997803342718702be5891006410a5cb36f8b4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 12:26:33 +0700 Subject: [PATCH 022/174] feat(FE-114,137): enhance RecordingForm to handle stock usage and depletion total changes with improved input handling --- .../recording/form/RecordingForm.tsx | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index e9dfb39f..766cfe1d 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -440,6 +440,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, [stockProducts, getProjectFlockLocation()]); + // Handle stock usage amount change + const handleStockUsageAmountChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; + formik.setFieldValue(`stocks.${idx}.usage_amount`, value); + }, + [formik] + ); + // Unified Stock remove handlers const removeStock = (idx: number) => { const updatedStocks = formik.values.stocks?.filter((_, i) => i !== idx); @@ -466,6 +475,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('depletions', newDepletions); }; + // Handle depletion total change + const handleDepletionTotalChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; + formik.setFieldValue(`depletions.${idx}.total`, value); + }, + [formik] + ); + const removeDepletion = (idx: number) => { const updatedDepletions = formik.values.depletions?.filter( (_, i) => i !== idx @@ -942,14 +960,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { /> - Date: Fri, 24 Oct 2025 12:45:07 +0700 Subject: [PATCH 023/174] feat(FE-114,137): implement stock validation in RecordingForm to manage usage limits and enhance user feedback --- .../recording/form/RecordingForm.tsx | 89 +++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 766cfe1d..f974bdcd 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -6,7 +6,6 @@ import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; -import TextInput from '@/components/input/TextInput'; import NumberInput from '@/components/input/NumberInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import CheckboxInput from '@/components/input/CheckboxInput'; @@ -265,6 +264,79 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { manuallyEditedRows, // Include manually edited rows in dependencies ]); + // Stock validation functions - Following MovementForm pattern + const getAvailableStock = useCallback( + (productWarehouseId: number) => { + if (type === 'detail') return 0; + + if (!isResponseSuccess(stockProducts)) return 0; + + const productWarehouse = stockProducts.data.find( + (pw) => pw.id === productWarehouseId + ); + + return productWarehouse?.quantity ?? 0; + }, + [stockProducts, type] + ); + + const getStockUsageError = useCallback( + (stockIdx: number) => { + if (type === 'detail') return null; + + const stock = formik.values.stocks?.[stockIdx]; + if (!stock || !stock.product_warehouse_id) return null; + + const availableStock = getAvailableStock(stock.product_warehouse_id); + const requestedUsage = Number(stock.usage_amount) || 0; + + if (requestedUsage > availableStock) { + return `Jumlah pakai melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`; + } + + return null; + }, + [formik.values.stocks, getAvailableStock, type] + ); + + const getStockUsageAdornment = useCallback( + (stockIdx: number) => { + if (type === 'detail') return null; + + const stock = formik.values.stocks?.[stockIdx]; + if (!stock || !stock.product_warehouse_id) return null; + + const availableStock = getAvailableStock(stock.product_warehouse_id); + const requestedUsage = Number(stock.usage_amount) || 0; + const remainingStock = availableStock - requestedUsage; + + if (requestedUsage > 0) { + return ( + + (sisa: {remainingStock.toLocaleString('id-ID')}) + + ); + } + + return ( + + (tersedia: {availableStock.toLocaleString('id-ID')}) + + ); + }, + [formik.values.stocks, getAvailableStock, type] + ); + + const hasExceededStock = useMemo(() => { + if (type === 'detail') return false; + + return ( + formik.values.stocks?.some((stock, idx) => { + return getStockUsageError(idx) !== null; + }) ?? false + ); + }, [formik.values.stocks, getStockUsageError, type]); + // EVENT HANDLERS - Body Weights const addBodyWeight = () => { const newBodyWeights = [ @@ -440,7 +512,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, [stockProducts, getProjectFlockLocation()]); - // Handle stock usage amount change + // Handle stock usage amount change - simplified following MovementForm pattern const handleStockUsageAmountChangeWrapper = useCallback( (idx: number) => (e: React.ChangeEvent) => { const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; @@ -920,6 +992,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { `stocks.${idx}.product_warehouse_id`, option?.value || 0 ); + // Auto-populate notes with product name by finding it in stockProducts data if (option?.value && isResponseSuccess(stockProducts)) { const selectedProduct = stockProducts.data.find( @@ -957,6 +1030,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { /> +
{ decimalSeparator='' isError={ isRepeaterInputError('stocks', 'usage_amount', idx) - .isError + .isError || Boolean(getStockUsageError(idx)) } errorMessage={ isRepeaterInputError('stocks', 'usage_amount', idx) - .errorMessage + .errorMessage || + getStockUsageError(idx) || + undefined } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', }} - placeholder='Jumlah Pakai' + placeholder="Jumlah Pakai" /> + {type !== 'detail' && getStockUsageAdornment(idx)} +
{type !== 'detail' && ( @@ -1226,6 +1304,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { : undefined } onDelete={deleteRecordingClickHandler} + disableSubmit={hasExceededStock} /> {recordingFormErrorMessage && (
From 7a6a35568f486d1aa7bb04e8894ce2b45bfb75a8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 13:32:46 +0700 Subject: [PATCH 024/174] feat(FE-137): enhance RecordingTable to support recording deletion with user feedback and refresh functionality --- .../production/recording/RecordingTable.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 832cdbcd..2c5ecdc6 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -19,6 +19,7 @@ import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import { type CellContext } from '@tanstack/react-table'; import { type Recording } from '@/types/api/production/recording'; import { RecordingApi } from '@/services/api/production'; +import toast from 'react-hot-toast'; const RowOptionsMenu = ({ type = 'dropdown', @@ -82,7 +83,7 @@ const RecordingTable = () => { const [pageSize, setPageSize] = useState(10); const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); - const [, setSelectedRecording] = useState(undefined); + const [selectedRecording, setSelectedRecording] = useState(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false); const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); @@ -94,6 +95,7 @@ const RecordingTable = () => { const { data: recordings, isLoading, + mutate: refreshRecordings, } = useSWR( `${RecordingApi.basePath}?page=${page}&limit=${pageSize}`, RecordingApi.getAllFetcher @@ -154,10 +156,13 @@ const RecordingTable = () => { const singleDeleteHandler = async () => { setIsDeleteLoading(true); - setTimeout(() => { - setIsDeleteLoading(false); - singleDeleteModal.closeModal(); - }, 1000); + + await RecordingApi.delete(selectedRecording?.id as number); + refreshRecordings(); + + singleDeleteModal.closeModal(); + toast.success('Successfully delete Recording!'); + setIsDeleteLoading(false); }; return ( @@ -338,10 +343,6 @@ const RecordingTable = () => { header: 'Ketepatan Waktu', cell: (props) => props.row.original.ontime ? 'Tepat Waktu' : 'Terlambat', }, - { - header: 'Status Perubahan', - cell: (props) => 'Tidak ada perubahan', - }, { header: 'Tanggal Submit', cell: (props) => @@ -417,7 +418,7 @@ const RecordingTable = () => { Date: Fri, 24 Oct 2025 13:40:27 +0700 Subject: [PATCH 025/174] feat(FE-137): implement bulk approval and rejection functionality in RecordingTable with user feedback --- .../production/recording/RecordingTable.tsx | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 2c5ecdc6..15c891bb 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -18,7 +18,9 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import { type CellContext } from '@tanstack/react-table'; import { type Recording } from '@/types/api/production/recording'; +import { type BaseApiResponse } from '@/types/api/api-general'; import { RecordingApi } from '@/services/api/production'; +import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import toast from 'react-hot-toast'; const RowOptionsMenu = ({ @@ -136,22 +138,56 @@ const RecordingTable = () => { const bulkApproveHandler = async () => { setIsBulkApproveLoading(true); - console.log('Approved recordings:', selectedRowIds); - setTimeout(() => { - setIsBulkApproveLoading(false); + + const approveResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'APPROVED', + approvable_ids: selectedRowIds, + notes: 'Bulk Approved', + }, + }); + + if (isResponseSuccess(approveResponse)) { + await refreshRecordings(); setRowSelection({}); bulkApproveModal.closeModal(); - }, 1000); + toast.success(`Successfully approved ${selectedRowIds.length} recordings!`); + } + if (isResponseError(approveResponse)) { + toast.error(approveResponse?.message as string); + bulkApproveModal.closeModal(); + } + setIsBulkApproveLoading(false); }; const bulkRejectHandler = async () => { setIsBulkRejectLoading(true); - console.log('Rejected recordings:', selectedRowIds); - setTimeout(() => { - setIsBulkRejectLoading(false); + + const rejectResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'REJECTED', + approvable_ids: selectedRowIds, + notes: 'Bulk Rejected', + }, + }); + + if (isResponseSuccess(rejectResponse)) { + refreshRecordings(); setRowSelection({}); bulkRejectModal.closeModal(); - }, 1000); + toast.success(`Successfully rejected ${selectedRowIds.length} recordings!`); + } + if (isResponseError(rejectResponse)) { + toast.error(rejectResponse?.message as string); + bulkRejectModal.closeModal(); + } + setIsBulkRejectLoading(false); }; const singleDeleteHandler = async () => { From d14fa2ed2b11f756e3241f68744bf3b2638c4208 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 13:53:20 +0700 Subject: [PATCH 026/174] feat(FE-137): integrate advanced filtering options in RecordingTable with dropdowns for area, location, and kandang --- .../production/recording/RecordingTable.tsx | 291 ++++++++++++++++-- 1 file changed, 269 insertions(+), 22 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 15c891bb..f7cc451c 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -9,6 +9,7 @@ import { useModal } from '@/components/Modal'; import Button from '@/components/Button'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { OptionType } from '@/components/input/SelectInput'; +import SelectInput from '@/components/input/SelectInput'; import { ROWS_OPTIONS } from '@/config/constant'; import CheckboxInput from '@/components/input/CheckboxInput'; import { TableToolbar } from '@/components/table/TableToolbar'; @@ -20,7 +21,11 @@ import { type CellContext } from '@tanstack/react-table'; import { type Recording } from '@/types/api/production/recording'; import { type BaseApiResponse } from '@/types/api/api-general'; import { RecordingApi } from '@/services/api/production'; +import { AreaApi } from '@/services/api/master-data'; +import { LocationApi } from '@/services/api/master-data'; +import { KandangApi } from '@/services/api/master-data'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; import toast from 'react-hot-toast'; const RowOptionsMenu = ({ @@ -80,9 +85,31 @@ const RowOptionsMenu = ({ }; const RecordingTable = () => { - const [search, setSearch] = useState(''); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + areaFilter: '', + locationFilter: '', + kandangFilter: '', + periodFilter: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + search: 'search', + areaFilter: 'area_id', + locationFilter: 'location_id', + kandangFilter: 'kandang_id', + periodFilter: 'period', + }, + }); + const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); const [selectedRecording, setSelectedRecording] = useState(undefined); @@ -94,21 +121,81 @@ const RecordingTable = () => { const bulkApproveModal = useModal(); const bulkRejectModal = useModal(); + // State for dropdown search + const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); + const [areaSelectInputValue, setAreaSelectInputValue] = useState(''); + const [kandangSelectInputValue, setKandangSelectInputValue] = useState(''); + + const [selectedArea, setSelectedArea] = useState(null); + const [selectedLocation, setSelectedLocation] = useState(null); + const [selectedKandang, setSelectedKandang] = useState(null); + const { data: recordings, isLoading, mutate: refreshRecordings, } = useSWR( - `${RecordingApi.basePath}?page=${page}&limit=${pageSize}`, + `${RecordingApi.basePath}${getTableFilterQueryString()}`, RecordingApi.getAllFetcher ); + // Fetch data for dropdowns + const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({ + search: areaSelectInputValue, + limit: '100', + }).toString()}`; + const { + data: areas, + isLoading: isLoadingAreas, + } = useSWR(areaUrl, AreaApi.getAllFetcher); + + const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({ + search: locationSelectInputValue, + area_id: selectedArea != null ? selectedArea.value.toString() : '', + limit: '100', + }).toString()}`; + const { + data: locations, + isLoading: isLoadingLocations, + } = useSWR(locationUrl, LocationApi.getAllFetcher); + + const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ + search: kandangSelectInputValue, + location_id: + selectedLocation != null ? selectedLocation.value.toString() : '', + limit: '100', + }).toString()}`; + const { + data: kandangs, + isLoading: isLoadingKandang, + } = useSWR(kandangUrl, KandangApi.getAllFetcher); + + // Data to Options Mapping + const optionsArea = isResponseSuccess(areas) + ? areas?.data.map((area) => ({ + value: area.id, + label: area.name, + })) + : []; + const optionsLocation = isResponseSuccess(locations) + ? locations?.data.map((location) => ({ + value: location.id, + label: location.name, + })) + : []; + const optionsKandang = isResponseSuccess(kandangs) + ? kandangs?.data.map((kandang) => ({ + value: kandang.id, + label: kandang.name, + })) + : []; + const searchChangeHandler = useCallback( (e: React.ChangeEvent) => { - setSearch(e.target.value); + updateFilter('search', e.target.value); setPage(1); }, - [] + [updateFilter, setPage] ); const pageSizeChangeHandler = useCallback( @@ -117,22 +204,14 @@ const RecordingTable = () => { setPageSize(newVal.value as number); setPage(1); }, - [] + [setPageSize, setPage] ); const paginatedData = useMemo(() => { if (!recordings || recordings.status !== 'success') return []; - return recordings.data.filter( - (recording: Recording) => { - // For now, we don't have project_flock relation data in the API response - // So we'll filter by basic recording data - return recording.project_flock_kandang_id.toString().includes(search.toLowerCase()) || - recording.record_date.includes(search.toLowerCase()) || - recording.created_user.name.toLowerCase().includes(search.toLowerCase()); - } - ); - }, [recordings, search]); + return recordings.data; + }, [recordings]); const selectedRowIds = Object.keys(rowSelection).map((item) => parseInt(item)); @@ -210,16 +289,184 @@ const RecordingTable = () => { label: 'Tambah Recording', }} search={{ - value: search, + value: tableFilterState.search, onChange: searchChangeHandler, placeholder: 'Cari Recording', }} /> + + {/* Filter Dropdowns - Desktop */} +
+ { + const selectedValue = selected as OptionType | null; + setSelectedArea(selectedValue); + setSelectedLocation(null); + setSelectedKandang(null); + updateFilter('areaFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter('locationFilter', ''); + updateFilter('kandangFilter', ''); + setPage(1); + }} + className={{ wrapper: 'w-full' }} + onInputChange={(value) => setAreaSelectInputValue(value)} + isLoading={isLoadingAreas} + isClearable + /> + + { + const selectedValue = selected as OptionType | null; + setSelectedLocation(selectedValue); + setSelectedKandang(null); + updateFilter('locationFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter('kandangFilter', ''); + setPage(1); + }} + className={{ wrapper: 'w-full' }} + onInputChange={(value) => setLocationSelectInputValue(value)} + isLoading={isLoadingLocations} + isClearable + isDisabled={!selectedArea} + /> + + { + const selectedValue = selected as OptionType | null; + setSelectedKandang(selectedValue); + updateFilter('kandangFilter', selectedValue ? selectedValue.value.toString() : ''); + setPage(1); + }} + className={{ wrapper: 'w-full' }} + onInputChange={(value) => setKandangSelectInputValue(value)} + isLoading={isLoadingKandang} + isClearable + isDisabled={!selectedLocation} + /> + + { + const selectedValue = selected as OptionType | null; + updateFilter('periodFilter', selectedValue ? selectedValue.value.toString() : ''); + setPage(1); + }} + className={{ wrapper: 'w-full' }} + isClearable + /> +
+ + {/* Filter Dropdowns - Mobile */} +
+ { + const selectedValue = selected as OptionType | null; + setSelectedArea(selectedValue); + setSelectedLocation(null); + setSelectedKandang(null); + updateFilter('areaFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter('locationFilter', ''); + updateFilter('kandangFilter', ''); + setPage(1); + }} + className={{ wrapper: 'w-full' }} + onInputChange={(value) => setAreaSelectInputValue(value)} + isLoading={isLoadingAreas} + isClearable + /> + + { + const selectedValue = selected as OptionType | null; + setSelectedLocation(selectedValue); + setSelectedKandang(null); + updateFilter('locationFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter('kandangFilter', ''); + setPage(1); + }} + className={{ wrapper: 'w-full' }} + onInputChange={(value) => setLocationSelectInputValue(value)} + isLoading={isLoadingLocations} + isClearable + isDisabled={!selectedArea} + /> + + { + const selectedValue = selected as OptionType | null; + setSelectedKandang(selectedValue); + updateFilter('kandangFilter', selectedValue ? selectedValue.value.toString() : ''); + setPage(1); + }} + className={{ wrapper: 'w-full' }} + onInputChange={(value) => setKandangSelectInputValue(value)} + isLoading={isLoadingKandang} + isClearable + isDisabled={!selectedLocation} + /> + + { + const selectedValue = selected as OptionType | null; + updateFilter('periodFilter', selectedValue ? selectedValue.value.toString() : ''); + setPage(1); + }} + className={{ wrapper: 'w-full' }} + isClearable + /> +
{/* Bulk action buttons */} @@ -315,7 +562,7 @@ const RecordingTable = () => { }, { header: '#', - cell: (props) => pageSize * (page - 1) + props.row.index + 1, + cell: (props) => tableFilterState.pageSize * (tableFilterState.page - 1) + props.row.index + 1, }, { header: 'ID', @@ -427,8 +674,8 @@ const RecordingTable = () => { }, }, ]} - pageSize={pageSize} - page={recordings?.status === 'success' ? recordings.meta?.page : page} + pageSize={tableFilterState.pageSize} + page={recordings?.status === 'success' ? recordings.meta?.page : tableFilterState.page} totalItems={recordings?.status === 'success' ? recordings.meta?.total_results : 0} onPageChange={setPage} isLoading={isLoading} From 6114d706ad05d2148edd307ceb5a247a0da5f5e1 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 14:13:21 +0700 Subject: [PATCH 027/174] feat(FE-137): disable input field in RecordingForm when type is 'detail' --- src/components/pages/production/recording/form/RecordingForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index f974bdcd..e8853545 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1027,6 +1027,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { wrapper: 'w-full min-w-48', }} isSearchable + isDisabled={type === 'detail'} /> From 17e6eef0c5e86a2c4bbacb4b3d8e5ae26f848d49 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 18:02:41 +0700 Subject: [PATCH 028/174] feat(FE-137): add approve and reject functionality in RecordingForm with confirmation modals --- src/components/helper/form/FormActions.tsx | 80 ++++++++-- .../recording/form/RecordingForm.tsx | 142 ++++++++++++++++-- 2 files changed, 193 insertions(+), 29 deletions(-) diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 92c2a92c..111b052f 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -9,6 +9,11 @@ interface FormActionsProps { editUrl?: string; onDelete?: () => void; disableSubmit?: boolean; + onApprove?: () => void; + onReject?: () => void; + isApproveLoading?: boolean; + isRejectLoading?: boolean; + showApproveReject?: boolean; } export const FormActions = ({ @@ -17,25 +22,32 @@ export const FormActions = ({ editUrl, onDelete, disableSubmit = false, + onApprove, + onReject, + isApproveLoading = false, + isRejectLoading = false, + showApproveReject = false, }: FormActionsProps) => { return (
- {type !== 'add' && onDelete && ( + {type !== 'add' && (
- + {onDelete && ( + + )} {type !== 'edit' && editUrl && ( )} + {type === 'detail' && showApproveReject && (onApprove || onReject) && ( + <> + {onApprove && ( + + )} + {onReject && ( + + )} + + )}
)} {type !== 'detail' && ( diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index e8853545..f63d6298 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -12,10 +12,12 @@ import CheckboxInput from '@/components/input/CheckboxInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { FormHeader } from '@/components/helper/form/FormHeader'; import { FormActions } from '@/components/helper/form/FormActions'; +import { RecordingApi } from '@/services/api/production'; import { CreateRecordingPayload, Recording, } from '@/types/api/production/recording'; +import { type BaseApiResponse } from '@/types/api/api-general'; import { RecordingFormSchema, RecordingFormValues, @@ -28,6 +30,8 @@ import { LocationApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { isResponseSuccess } from '@/lib/api-helper'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; +import { useModal } from '@/components/Modal'; +import toast from 'react-hot-toast'; import Card from '@/components/Card'; @@ -130,6 +134,73 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { confirmationModalDeleteClickHandler, } = useRecordingFormHandlers(initialValues?.id); + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + + // Modals for approve/reject actions + const approveModal = useModal(); + const rejectModal = useModal(); + + // Approve handler + const approveHandler = async () => { + setIsApproveLoading(true); + + const approveResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'APPROVED', + approvable_ids: [initialValues?.id as number], + notes: 'Approved via Form', + }, + }); + + if (isResponseSuccess(approveResponse)) { + toast.success('Recording berhasil disetujui!'); + approveModal.closeModal(); + // Optional: redirect or refresh data + if (typeof window !== 'undefined') { + window.location.href = '/production/recording'; + } + } else { + toast.error(approveResponse?.message as string || 'Gagal menyetujui recording'); + approveModal.closeModal(); + } + + setIsApproveLoading(false); + }; + + // Reject handler + const rejectHandler = async () => { + setIsRejectLoading(true); + + const rejectResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'REJECTED', + approvable_ids: [initialValues?.id as number], + notes: 'Rejected via Form', + }, + }); + + if (isResponseSuccess(rejectResponse)) { + toast.success('Recording berhasil ditolak!'); + rejectModal.closeModal(); + // Optional: redirect or refresh data + if (typeof window !== 'undefined') { + window.location.href = '/production/recording'; + } + } else { + toast.error(rejectResponse?.message as string || 'Gagal menolak recording'); + rejectModal.closeModal(); + } + + setIsRejectLoading(false); + }; + const formikInitialValues = useMemo( () => getRecordingFormInitialValues(initialValues), [initialValues] @@ -1305,6 +1376,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { : undefined } onDelete={deleteRecordingClickHandler} + onApprove={() => approveModal.openModal()} + onReject={() => rejectModal.openModal()} + isApproveLoading={isApproveLoading} + isRejectLoading={isRejectLoading} + showApproveReject={type === 'detail'} disableSubmit={hasExceededStock} /> {recordingFormErrorMessage && ( @@ -1321,20 +1397,58 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {type !== 'add' && ( - + <> + + + {/* Approve Confirmation Modal */} + {type === 'detail' && ( + + )} + + {/* Reject Confirmation Modal */} + {type === 'detail' && ( + + )} + )} ); From e322e0d078a3f61505de24144c8e5c4e8a424a51 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 20:29:33 +0700 Subject: [PATCH 029/174] feat(FE-137): update RECORDING_FLAG_OPTIONS values for consistency in constant.ts --- src/config/constant.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/constant.ts b/src/config/constant.ts index 2b87c4d7..8e706f73 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -216,7 +216,7 @@ export const SUPPLIER_FLAG_OPTIONS = [ ]; export const RECORDING_FLAG_OPTIONS = [ - { label: 'Ayam Afkir', value: 'Ayam Afkir' }, - { label: 'Ayam Culling', value: 'Ayam Culling' }, - { label: 'Ayam Mati', value: 'Ayam Mati' }, + { label: 'Ayam Afkir', value: 'Afkir' }, + { label: 'Ayam Culling', value: 'Culling' }, + { label: 'Ayam Mati', value: 'Mati' }, ]; From 81003eac63a2bd8008f50b1986f98c7055bdbd5b Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 20:37:11 +0700 Subject: [PATCH 030/174] feat(FE-137): enhance stock product selection in RecordingForm with initial values support --- .../recording/form/RecordingForm.tsx | 76 +++++++++++++------ 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index f63d6298..2e1a5952 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -552,35 +552,67 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Memoized unified products for stock selection const unifiedStockProducts = useMemo(() => { - if (!isResponseSuccess(stockProducts)) return []; const options: OptionType[] = []; - stockProducts.data.forEach((product) => { - const warehouse = product.warehouse; - const stockText = product.quantity.toLocaleString('id-ID'); + // Add products from API stockProducts + if (isResponseSuccess(stockProducts)) { + stockProducts.data.forEach((product) => { + const warehouse = product.warehouse; + const stockText = product.quantity.toLocaleString('id-ID'); - // Check if product has any of the flags - const hasPakanFlag = product.product.flags?.includes('PAKAN'); - const hasOvkFlag = product.product.flags?.includes('OVK'); + // Check if product has any of the flags + const hasPakanFlag = product.product.flags?.includes('PAKAN'); + const hasOvkFlag = product.product.flags?.includes('OVK'); - // Add products with warehouse and location grouping in label (similar to projectFlockKandangOptions pattern) - if (hasPakanFlag) { - options.push({ - value: product.id, - label: `[PAKAN] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` - }); - } + // Add products with warehouse and location grouping in label (similar to projectFlockKandangOptions pattern) + if (hasPakanFlag) { + options.push({ + value: product.id, + label: `[PAKAN] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` + }); + } - if (hasOvkFlag) { - options.push({ - value: product.id, - label: `[OVK] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` - }); - } - }); + if (hasOvkFlag) { + options.push({ + value: product.id, + label: `[OVK] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` + }); + } + }); + } + + // Add existing stock products from initialValues (for detail/edit mode) + if (initialValues && 'stocks' in initialValues && initialValues.stocks && type !== 'add') { + const initialValuesWithStocks = initialValues as Recording & { + stocks?: Array<{ + product_warehouse_id: number; + usage_amount: number; + notes: string; + product_warehouse?: { + id: number; + product_id: number; + product_name: string; + warehouse_id: number; + warehouse_name: string; + }; + }>; + }; + + initialValuesWithStocks.stocks?.forEach((stock) => { + if (stock.product_warehouse && stock.product_warehouse.product_name) { + const existingOption = options.find(opt => opt.value === stock.product_warehouse_id); + if (!existingOption) { + options.push({ + value: stock.product_warehouse_id, + label: `${stock.product_warehouse.product_name} - ${stock.product_warehouse.warehouse_name}` + }); + } + } + }); + } return options; - }, [stockProducts, getProjectFlockLocation()]); + }, [stockProducts, getProjectFlockLocation(), initialValues, type]); // Handle stock usage amount change - simplified following MovementForm pattern From 9c5dc0dbb53229bc85e9b3e6bee85c5903932f78 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 20:44:15 +0700 Subject: [PATCH 031/174] refactor(FE-137): integrate approve and reject functionality in RecordingForm with loading states and modal confirmations --- .../recording/form/RecordingForm.tsx | 792 ++++++++---------- 1 file changed, 357 insertions(+), 435 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 2e1a5952..ca4a696d 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -45,45 +45,41 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [selectedStocks, setSelectedStocks] = useState([]); const [selectedDepletions, setSelectedDepletions] = useState([]); - // Track which average weight field is being edited to prevent auto-calculation override const [editingAverageIndex, setEditingAverageIndex] = useState(null); - - // Track which rows have been manually edited to prevent auto-calculation override const [manuallyEditedRows, setManuallyEditedRows] = useState>(new Set()); - // State for Location search and selection const [locationSearchValue, setLocationSearchValue] = useState(''); const [selectedLocation, setSelectedLocation] = useState(null); - - // State for Project Flock search const [projectFlockSearchValue, setProjectFlockSearchValue] = useState(''); - // Fetch Locations data + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + + const approveModal = useModal(); + const rejectModal = useModal(); + + // ===== API DATA FETCHING ===== const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSearchValue || '', }).toString()}`; - const { data: locations, isLoading: isLoadingLocations } = useSWR( locationsUrl, LocationApi.getAllFetcher ); - // Fetch Project Flocks data with location filter const projectFlocksUrl = `${ProjectFlockApi.basePath}?${new URLSearchParams({ search: projectFlockSearchValue || '', ...(selectedLocation ? { location_id: selectedLocation.value.toString() } : {}), }).toString()}`; - const { data: projectFlocks, isLoading: isLoadingProjectFlocks } = useSWR( projectFlocksUrl, ProjectFlockApi.getAllFetcher ); - // Fetch Products with location filter (both PAKAN and OVK) - using selectedLocation for now const stockProductsUrl = useMemo(() => { if (!selectedLocation) return null; const params = new URLSearchParams({ - flags: 'PAKAN,OVK', // Fetch both flags in one request + flags: 'PAKAN,OVK', search: '', location_id: selectedLocation.value.toString(), }); @@ -95,22 +91,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ProductWarehouseApi.getAllFetcher ); - // Extract location options from locations data + // ===== DATA PROCESSING ===== const locationOptions = useMemo(() => { if (!isResponseSuccess(locations)) return []; - return locations?.data.map((location) => ({ value: location.id, label: location.name, })) || []; }, [locations]); - // Extract kandang options from project_flocks data const projectFlockKandangOptions = useMemo(() => { if (!isResponseSuccess(projectFlocks)) return []; const options: OptionType[] = []; - projectFlocks?.data.forEach((projectFlock) => { projectFlock.kandangs.forEach((kandang) => { options.push({ @@ -119,11 +112,68 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }); }); }); - return options; }, [projectFlocks]); + const unifiedStockProducts = useMemo(() => { + const options: OptionType[] = []; + if (isResponseSuccess(stockProducts)) { + stockProducts.data.forEach((product) => { + const warehouse = product.warehouse; + const stockText = product.quantity.toLocaleString('id-ID'); + const hasPakanFlag = product.product.flags?.includes('PAKAN'); + const hasOvkFlag = product.product.flags?.includes('OVK'); + + if (hasPakanFlag) { + options.push({ + value: product.id, + label: `[PAKAN] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` + }); + } + + if (hasOvkFlag) { + options.push({ + value: product.id, + label: `[OVK] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` + }); + } + }); + } + + if (initialValues && 'stocks' in initialValues && initialValues.stocks && type !== 'add') { + const initialValuesWithStocks = initialValues as Recording & { + stocks?: Array<{ + product_warehouse_id: number; + usage_amount: number; + notes: string; + product_warehouse?: { + id: number; + product_id: number; + product_name: string; + warehouse_id: number; + warehouse_name: string; + }; + }>; + }; + + initialValuesWithStocks.stocks?.forEach((stock) => { + if (stock.product_warehouse && stock.product_warehouse.product_name) { + const existingOption = options.find(opt => opt.value === stock.product_warehouse_id); + if (!existingOption) { + options.push({ + value: stock.product_warehouse_id, + label: `${stock.product_warehouse.product_name} - ${stock.product_warehouse.warehouse_name}` + }); + } + } + }); + } + + return options; + }, [stockProducts, initialValues, type]); + + // ===== FORM HANDLERS ===== const { deleteModal, recordingFormErrorMessage, @@ -134,73 +184,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { confirmationModalDeleteClickHandler, } = useRecordingFormHandlers(initialValues?.id); - const [isApproveLoading, setIsApproveLoading] = useState(false); - const [isRejectLoading, setIsRejectLoading] = useState(false); - - // Modals for approve/reject actions - const approveModal = useModal(); - const rejectModal = useModal(); - - // Approve handler - const approveHandler = async () => { - setIsApproveLoading(true); - - const approveResponse = await RecordingApi.customRequest< - BaseApiResponse - >('approvals', { - method: 'POST', - payload: { - action: 'APPROVED', - approvable_ids: [initialValues?.id as number], - notes: 'Approved via Form', - }, - }); - - if (isResponseSuccess(approveResponse)) { - toast.success('Recording berhasil disetujui!'); - approveModal.closeModal(); - // Optional: redirect or refresh data - if (typeof window !== 'undefined') { - window.location.href = '/production/recording'; - } - } else { - toast.error(approveResponse?.message as string || 'Gagal menyetujui recording'); - approveModal.closeModal(); - } - - setIsApproveLoading(false); - }; - - // Reject handler - const rejectHandler = async () => { - setIsRejectLoading(true); - - const rejectResponse = await RecordingApi.customRequest< - BaseApiResponse - >('approvals', { - method: 'POST', - payload: { - action: 'REJECTED', - approvable_ids: [initialValues?.id as number], - notes: 'Rejected via Form', - }, - }); - - if (isResponseSuccess(rejectResponse)) { - toast.success('Recording berhasil ditolak!'); - rejectModal.closeModal(); - // Optional: redirect or refresh data - if (typeof window !== 'undefined') { - window.location.href = '/production/recording'; - } - } else { - toast.error(rejectResponse?.message as string || 'Gagal menolak recording'); - rejectModal.closeModal(); - } - - setIsRejectLoading(false); - }; - const formikInitialValues = useMemo( () => getRecordingFormInitialValues(initialValues), [initialValues] @@ -254,12 +237,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, }); - // Get location from selected project flock for stock filtering - const getProjectFlockLocation = useCallback((): OptionType | null => { + // ===== HELPER FUNCTIONS ===== + useCallback((): OptionType | null => { if (!formik.values.project_flock_kandang || !isResponseSuccess(projectFlocks)) { - return selectedLocation; // Fallback to manual location selection + return selectedLocation; } - const kandangId = formik.values.project_flock_kandang.value; for (const projectFlock of projectFlocks.data) { const kandang = projectFlock.kandangs.find(k => k.id === kandangId); @@ -270,82 +252,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; } } - - return selectedLocation; // Fallback to manual location selection + return selectedLocation; }, [formik.values.project_flock_kandang, projectFlocks, selectedLocation]); - // EVENT HANDLERS - Select Inputs - const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedLocation(val as OptionType); - - // Reset project flock selection when location changes - formik.setFieldValue('project_flock_kandang', null); - formik.setFieldValue('project_flock_kandang_id', 0); - }; - - const projectFlockKandangChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('project_flock_kandang', true); - formik.setFieldValue('project_flock_kandang', val); - formik.setFieldTouched('project_flock_kandang_id', true); - formik.setFieldValue('project_flock_kandang_id', (val as OptionType)?.value || 0); - }; - - // EVENT HANDLERS - Date Time - const recordDateTimeChangeHandler = (datetime: Date | null) => { - formik.setFieldValue('record_datetime', datetime, false); - }; - - // Auto-calculate average weight when weight or qty changes (but not when editing average weight manually) - useEffect(() => { - // Only run auto-calculation if no field is being edited - if (formik.values.body_weights && editingAverageIndex === null) { - const updatedBodyWeights = formik.values.body_weights.map((weight, idx) => { - // Skip the field that's being edited or has been manually edited - if (idx === editingAverageIndex || manuallyEditedRows.has(idx)) { - return weight; - } - - return { - ...weight, - average_weight: - weight.qty > 0 && weight.weight > 0 - ? parseFloat((weight.weight / weight.qty).toFixed(2)) - : 0, - }; - }); - - // Only update if values are different to avoid infinite loops - const hasChanges = updatedBodyWeights.some( - (updated, idx) => - idx !== editingAverageIndex && // Skip the field being edited - !manuallyEditedRows.has(idx) && // Skip manually edited rows - updated.average_weight !== - (formik.values.body_weights[idx]?.average_weight || 0) - ); - - if (hasChanges) { - // Use false to prevent triggering validation and other side effects - formik.setFieldValue('body_weights', updatedBodyWeights, false); - } - } - }, [ - formik.values.body_weights?.map((w) => w.weight), - formik.values.body_weights?.map((w) => w.qty), - editingAverageIndex, // Include editing index in dependencies - manuallyEditedRows, // Include manually edited rows in dependencies - ]); - - // Stock validation functions - Following MovementForm pattern const getAvailableStock = useCallback( (productWarehouseId: number) => { if (type === 'detail') return 0; - if (!isResponseSuccess(stockProducts)) return 0; - const productWarehouse = stockProducts.data.find( (pw) => pw.id === productWarehouseId ); - return productWarehouse?.quantity ?? 0; }, [stockProducts, type] @@ -354,17 +270,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const getStockUsageError = useCallback( (stockIdx: number) => { if (type === 'detail') return null; - const stock = formik.values.stocks?.[stockIdx]; if (!stock || !stock.product_warehouse_id) return null; - const availableStock = getAvailableStock(stock.product_warehouse_id); const requestedUsage = Number(stock.usage_amount) || 0; - if (requestedUsage > availableStock) { return `Jumlah pakai melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`; } - return null; }, [formik.values.stocks, getAvailableStock, type] @@ -373,14 +285,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const getStockUsageAdornment = useCallback( (stockIdx: number) => { if (type === 'detail') return null; - const stock = formik.values.stocks?.[stockIdx]; if (!stock || !stock.product_warehouse_id) return null; - const availableStock = getAvailableStock(stock.product_warehouse_id); const requestedUsage = Number(stock.usage_amount) || 0; const remainingStock = availableStock - requestedUsage; - if (requestedUsage > 0) { return ( @@ -388,7 +297,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); } - return ( (tersedia: {availableStock.toLocaleString('id-ID')}) @@ -400,7 +308,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const hasExceededStock = useMemo(() => { if (type === 'detail') return false; - return ( formik.values.stocks?.some((stock, idx) => { return getStockUsageError(idx) !== null; @@ -408,273 +315,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); }, [formik.values.stocks, getStockUsageError, type]); - // EVENT HANDLERS - Body Weights - const addBodyWeight = () => { - const newBodyWeights = [ - ...(formik.values.body_weights || []), - { - weight: 0, - qty: 1, - average_weight: 0, - }, - ]; - formik.setFieldValue('body_weights', newBodyWeights); - }; - - // Handle calculation when weight changes - const handleWeightChange = (idx: number, value: number) => { - formik.setFieldValue(`body_weights.${idx}.weight`, value); - - // Reset manual edit flag when weight changes (user wants auto-calculation) - setManuallyEditedRows(prev => { - const newSet = new Set(prev); - newSet.delete(idx); - return newSet; - }); - - const currentWeight = formik.values.body_weights?.[idx]; - if (currentWeight) { - const qty = currentWeight.qty; - if (qty > 0 && value > 0) { - const averageWeight = parseFloat((value / qty).toFixed(2)); - formik.setFieldValue(`body_weights.${idx}.average_weight`, averageWeight); - } else { - formik.setFieldValue(`body_weights.${idx}.average_weight`, 0); - } - } - }; - - // Handle calculation when qty changes - const handleQtyChange = (idx: number, value: number) => { - formik.setFieldValue(`body_weights.${idx}.qty`, value); - - // Reset manual edit flag when qty changes (user wants auto-calculation) - setManuallyEditedRows(prev => { - const newSet = new Set(prev); - newSet.delete(idx); - return newSet; - }); - - const currentWeight = formik.values.body_weights?.[idx]; - if (currentWeight) { - const weight = currentWeight.weight; - if (value > 0 && weight > 0) { - const averageWeight = parseFloat((weight / value).toFixed(2)); - formik.setFieldValue(`body_weights.${idx}.average_weight`, averageWeight); - } else { - formik.setFieldValue(`body_weights.${idx}.average_weight`, 0); - } - } - }; - - // Handle calculation when average_weight changes - const handleAverageWeightChange = (idx: number, value: number) => { - formik.setFieldValue(`body_weights.${idx}.average_weight`, value); - - const currentWeight = formik.values.body_weights?.[idx]; - if (currentWeight) { - const qty = currentWeight.qty; - if (qty > 0 && value > 0) { - const totalWeight = value * qty; - formik.setFieldValue(`body_weights.${idx}.weight`, totalWeight); - } else { - formik.setFieldValue(`body_weights.${idx}.weight`, 0); - } - } - }; - - // Create wrapper handlers that match NumberInput's onChange signature - const handleWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { - // Parse the value more carefully to handle decimal numbers properly - const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); - // Convert comma thousand separator to nothing, but keep decimal point - const normalizedValue = rawValue.replace(/,/g, ''); - const value = parseFloat(normalizedValue) || 0; - handleWeightChange(idx, value); - }; - - const handleQtyChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { - // Parse the value more carefully to handle decimal numbers properly - const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); - // Convert comma thousand separator to nothing, but keep decimal point - const normalizedValue = rawValue.replace(/,/g, ''); - const value = parseFloat(normalizedValue) || 0; - handleQtyChange(idx, value); - }; - - const handleAverageWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { - // Set focus state to prevent auto-calculation override - setEditingAverageIndex(idx); - - // Mark this row as manually edited - setManuallyEditedRows(prev => new Set(prev).add(idx)); - - // Parse the value more carefully to handle decimal numbers properly - const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); - // Convert comma thousand separator to nothing, but keep decimal point - const normalizedValue = rawValue.replace(/,/g, ''); - const value = parseFloat(normalizedValue) || 0; - handleAverageWeightChange(idx, value); - }; - - const handleAverageWeightBlur = (idx: number) => { - // Clear focus state when user leaves the field to re-enable auto-calculation - setEditingAverageIndex(null); - }; - - const removeBodyWeight = (idx: number) => { - const updatedBodyWeights = formik.values.body_weights?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('body_weights', updatedBodyWeights); - }; - - const removeSelectedBodyWeights = () => { - const updatedBodyWeights = formik.values.body_weights?.filter( - (_, idx) => !selectedBodyWeights.includes(idx) - ); - formik.setFieldValue('body_weights', updatedBodyWeights); - setSelectedBodyWeights([]); - }; - - // EVENT HANDLERS - Stocks - const addStock = () => { - const newStocks = [ - ...(formik.values.stocks || []), - { - product_warehouse_id: 0, - usage_amount: 0, - notes: '', - }, - ]; - formik.setFieldValue('stocks', newStocks); - }; - - // Memoized unified products for stock selection - const unifiedStockProducts = useMemo(() => { - const options: OptionType[] = []; - - // Add products from API stockProducts - if (isResponseSuccess(stockProducts)) { - stockProducts.data.forEach((product) => { - const warehouse = product.warehouse; - const stockText = product.quantity.toLocaleString('id-ID'); - - // Check if product has any of the flags - const hasPakanFlag = product.product.flags?.includes('PAKAN'); - const hasOvkFlag = product.product.flags?.includes('OVK'); - - // Add products with warehouse and location grouping in label (similar to projectFlockKandangOptions pattern) - if (hasPakanFlag) { - options.push({ - value: product.id, - label: `[PAKAN] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` - }); - } - - if (hasOvkFlag) { - options.push({ - value: product.id, - label: `[OVK] ${product.product.name} - ${warehouse?.name || ''} (${stockText})` - }); - } - }); - } - - // Add existing stock products from initialValues (for detail/edit mode) - if (initialValues && 'stocks' in initialValues && initialValues.stocks && type !== 'add') { - const initialValuesWithStocks = initialValues as Recording & { - stocks?: Array<{ - product_warehouse_id: number; - usage_amount: number; - notes: string; - product_warehouse?: { - id: number; - product_id: number; - product_name: string; - warehouse_id: number; - warehouse_name: string; - }; - }>; - }; - - initialValuesWithStocks.stocks?.forEach((stock) => { - if (stock.product_warehouse && stock.product_warehouse.product_name) { - const existingOption = options.find(opt => opt.value === stock.product_warehouse_id); - if (!existingOption) { - options.push({ - value: stock.product_warehouse_id, - label: `${stock.product_warehouse.product_name} - ${stock.product_warehouse.warehouse_name}` - }); - } - } - }); - } - - return options; - }, [stockProducts, getProjectFlockLocation(), initialValues, type]); - - - // Handle stock usage amount change - simplified following MovementForm pattern - const handleStockUsageAmountChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; - formik.setFieldValue(`stocks.${idx}.usage_amount`, value); - }, - [formik] - ); - - // Unified Stock remove handlers - const removeStock = (idx: number) => { - const updatedStocks = formik.values.stocks?.filter((_, i) => i !== idx); - formik.setFieldValue('stocks', updatedStocks); - }; - - const removeSelectedStocks = () => { - const updatedStocks = formik.values.stocks?.filter( - (_, idx) => !selectedStocks.includes(idx) - ); - formik.setFieldValue('stocks', updatedStocks); - setSelectedStocks([]); - }; - - // EVENT HANDLERS - Depletions - const addDepletion = () => { - const newDepletions = [ - ...(formik.values.depletions || []), - { - total: 0, - notes: '', - }, - ]; - formik.setFieldValue('depletions', newDepletions); - }; - - // Handle depletion total change - const handleDepletionTotalChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; - formik.setFieldValue(`depletions.${idx}.total`, value); - }, - [formik] - ); - - const removeDepletion = (idx: number) => { - const updatedDepletions = formik.values.depletions?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('depletions', updatedDepletions); - }; - - const removeSelectedDepletions = () => { - const updatedDepletions = formik.values.depletions?.filter( - (_, idx) => !selectedDepletions.includes(idx) - ); - formik.setFieldValue('depletions', updatedDepletions); - setSelectedDepletions([]); - }; - - // HELPER FUNCTIONS const isRepeaterInputError = < T extends 'body_weights' | 'stocks' | 'depletions', >( @@ -713,6 +353,290 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; + // ===== EVENT HANDLERS ===== + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation(val as OptionType); + formik.setFieldValue('project_flock_kandang', null); + formik.setFieldValue('project_flock_kandang_id', 0); + }; + + const projectFlockKandangChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('project_flock_kandang', true); + formik.setFieldValue('project_flock_kandang', val); + formik.setFieldTouched('project_flock_kandang_id', true); + formik.setFieldValue('project_flock_kandang_id', (val as OptionType)?.value || 0); + }; + + const approveHandler = async () => { + setIsApproveLoading(true); + + const approveResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'APPROVED', + approvable_ids: [initialValues?.id as number], + notes: 'Approved via Form', + }, + }); + + if (isResponseSuccess(approveResponse)) { + toast.success('Recording berhasil disetujui!'); + approveModal.closeModal(); + if (typeof window !== 'undefined') { + window.location.href = '/production/recording'; + } + } else { + toast.error(approveResponse?.message as string || 'Gagal menyetujui recording'); + approveModal.closeModal(); + } + + setIsApproveLoading(false); + }; + + const rejectHandler = async () => { + setIsRejectLoading(true); + + const rejectResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'REJECTED', + approvable_ids: [initialValues?.id as number], + notes: 'Rejected via Form', + }, + }); + + if (isResponseSuccess(rejectResponse)) { + toast.success('Recording berhasil ditolak!'); + rejectModal.closeModal(); + if (typeof window !== 'undefined') { + window.location.href = '/production/recording'; + } + } else { + toast.error(rejectResponse?.message as string || 'Gagal menolak recording'); + rejectModal.closeModal(); + } + + setIsRejectLoading(false); + }; + + // Body Weights Handlers + const addBodyWeight = () => { + const newBodyWeights = [ + ...(formik.values.body_weights || []), + { + weight: 0, + qty: 1, + average_weight: 0, + }, + ]; + formik.setFieldValue('body_weights', newBodyWeights); + }; + + const handleWeightChange = (idx: number, value: number) => { + formik.setFieldValue(`body_weights.${idx}.weight`, value); + + setManuallyEditedRows(prev => { + const newSet = new Set(prev); + newSet.delete(idx); + return newSet; + }); + + const currentWeight = formik.values.body_weights?.[idx]; + if (currentWeight) { + const qty = currentWeight.qty; + if (qty > 0 && value > 0) { + const averageWeight = parseFloat((value / qty).toFixed(2)); + formik.setFieldValue(`body_weights.${idx}.average_weight`, averageWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.average_weight`, 0); + } + } + }; + + const handleQtyChange = (idx: number, value: number) => { + formik.setFieldValue(`body_weights.${idx}.qty`, value); + + setManuallyEditedRows(prev => { + const newSet = new Set(prev); + newSet.delete(idx); + return newSet; + }); + + const currentWeight = formik.values.body_weights?.[idx]; + if (currentWeight) { + const weight = currentWeight.weight; + if (value > 0 && weight > 0) { + const averageWeight = parseFloat((weight / value).toFixed(2)); + formik.setFieldValue(`body_weights.${idx}.average_weight`, averageWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.average_weight`, 0); + } + } + }; + + const handleAverageWeightChange = (idx: number, value: number) => { + formik.setFieldValue(`body_weights.${idx}.average_weight`, value); + + const currentWeight = formik.values.body_weights?.[idx]; + if (currentWeight) { + const qty = currentWeight.qty; + if (qty > 0 && value > 0) { + const totalWeight = value * qty; + formik.setFieldValue(`body_weights.${idx}.weight`, totalWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.weight`, 0); + } + } + }; + + const handleWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { + const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); + const normalizedValue = rawValue.replace(/,/g, ''); + const value = parseFloat(normalizedValue) || 0; + handleWeightChange(idx, value); + }; + + const handleQtyChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { + const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); + const normalizedValue = rawValue.replace(/,/g, ''); + const value = parseFloat(normalizedValue) || 0; + handleQtyChange(idx, value); + }; + + const handleAverageWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { + setEditingAverageIndex(idx); + setManuallyEditedRows(prev => new Set(prev).add(idx)); + + const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); + const normalizedValue = rawValue.replace(/,/g, ''); + const value = parseFloat(normalizedValue) || 0; + handleAverageWeightChange(idx, value); + }; + + const handleAverageWeightBlur = (idx: number) => { + setEditingAverageIndex(null); + }; + + const removeBodyWeight = (idx: number) => { + const updatedBodyWeights = formik.values.body_weights?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('body_weights', updatedBodyWeights); + }; + + const removeSelectedBodyWeights = () => { + const updatedBodyWeights = formik.values.body_weights?.filter( + (_, idx) => !selectedBodyWeights.includes(idx) + ); + formik.setFieldValue('body_weights', updatedBodyWeights); + setSelectedBodyWeights([]); + }; + + // Stocks Handlers + const addStock = () => { + const newStocks = [ + ...(formik.values.stocks || []), + { + product_warehouse_id: 0, + usage_amount: 0, + notes: '', + }, + ]; + formik.setFieldValue('stocks', newStocks); + }; + + const handleStockUsageAmountChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; + formik.setFieldValue(`stocks.${idx}.usage_amount`, value); + }, + [formik] + ); + + const removeStock = (idx: number) => { + const updatedStocks = formik.values.stocks?.filter((_, i) => i !== idx); + formik.setFieldValue('stocks', updatedStocks); + }; + + const removeSelectedStocks = () => { + const updatedStocks = formik.values.stocks?.filter( + (_, idx) => !selectedStocks.includes(idx) + ); + formik.setFieldValue('stocks', updatedStocks); + setSelectedStocks([]); + }; + + // Depletions Handlers + const addDepletion = () => { + const newDepletions = [ + ...(formik.values.depletions || []), + { + total: 0, + notes: '', + }, + ]; + formik.setFieldValue('depletions', newDepletions); + }; + + const handleDepletionTotalChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; + formik.setFieldValue(`depletions.${idx}.total`, value); + }, + [formik] + ); + + const removeDepletion = (idx: number) => { + const updatedDepletions = formik.values.depletions?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('depletions', updatedDepletions); + }; + + const removeSelectedDepletions = () => { + const updatedDepletions = formik.values.depletions?.filter( + (_, idx) => !selectedDepletions.includes(idx) + ); + formik.setFieldValue('depletions', updatedDepletions); + setSelectedDepletions([]); + }; + + // ===== EFFECTS ===== + useEffect(() => { + if (formik.values.body_weights && editingAverageIndex === null) { + const updatedBodyWeights = formik.values.body_weights.map((weight, idx) => { + if (idx === editingAverageIndex || manuallyEditedRows.has(idx)) { + return weight; + } + return { + ...weight, + average_weight: + weight.qty > 0 && weight.weight > 0 + ? parseFloat((weight.weight / weight.qty).toFixed(2)) + : 0, + }; + }); + const hasChanges = updatedBodyWeights.some( + (updated, idx) => + idx !== editingAverageIndex && + !manuallyEditedRows.has(idx) && + updated.average_weight !== + (formik.values.body_weights[idx]?.average_weight || 0) + ); + if (hasChanges) { + formik.setFieldValue('body_weights', updatedBodyWeights, false); + } + } + }, [ + formik.values.body_weights?.map((w) => w.weight), + formik.values.body_weights?.map((w) => w.qty), + editingAverageIndex, + manuallyEditedRows, + ]); return ( <> @@ -774,7 +698,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
- {/* Body Weights Table */} { data-tip='Otomatis dihitung: Total Berat ÷ Jumlah Ayam' > - + {type !== 'detail' && Action} @@ -1096,7 +1019,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { option?.value || 0 ); - // Auto-populate notes with product name by finding it in stockProducts data if (option?.value && isResponseSuccess(stockProducts)) { const selectedProduct = stockProducts.data.find( (product) => product.id === option.value @@ -1486,4 +1408,4 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); }; -export default RecordingForm; +export default RecordingForm; \ No newline at end of file From 896a0c6de274d78426f13521cabe560960808ddb Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 24 Oct 2025 21:10:03 +0700 Subject: [PATCH 032/174] refactor(FE-64): integrate product and supplier selection with API data fetching in MovementForm --- .../inventory/movement/form/MovementForm.tsx | 639 +++++++++--------- 1 file changed, 323 insertions(+), 316 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index d2c91168..6d6317da 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -37,24 +37,102 @@ interface MovementFormProps { } const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + // ===== STATE MANAGEMENT ===== const [, setMovementFormErrorMessage] = useState(''); - const [ - productWarehouseSelectInputValue, - setProductWarehouseSelectInputValue, - ] = useState(''); + const [productWarehouseSelectInputValue, setProductWarehouseSelectInputValue] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); + const [warehouseSelectInputValue, setWarehouseSelectInputValue] = useState(''); + const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); + // ===== FORM HANDLERS ===== const { - deleteModal, movementFormErrorMessage, - isDeleteLoading, createMovementHandler, updateMovementHandler, - deleteMovementClickHandler, - confirmationModalDeleteClickHandler, } = useMovementFormHandlers(initialValues?.id); + // ===== INTERFACES ===== + interface WarehouseOptionType extends OptionType { + area?: string; + location?: string; + } + + interface ProductWarehouseOptionType extends OptionType { + product_id: number; + warehouse_id: number; + warehouse_name: string; + quantity: number; + } + + // ===== API DATA FETCHING ===== + const allProductWarehousesUrl = `${ProductWarehouseApi.basePath}`; + const { data: allProductWarehouses } = useSWR( + allProductWarehousesUrl, + ProductWarehouseApi.getAllFetcher + ); + + const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; + const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( + warehousesUrl, + WarehouseApi.getAllFetcher + ); + + const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`; + const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( + suppliersUrl, + SupplierApi.getAllFetcher + ); + + // ===== DATA PROCESSING ===== + const warehouseStockMap = useMemo(() => { + if (!isResponseSuccess(allProductWarehouses)) return new Map(); + + const stockMap = new Map< + number, + { totalQty: number; productCount: number } + >(); + + allProductWarehouses.data.forEach((pw) => { + const warehouseId = pw.warehouse.id; + const existing = stockMap.get(warehouseId) || { + totalQty: 0, + productCount: 0, + }; + + stockMap.set(warehouseId, { + totalQty: existing.totalQty + pw.quantity, + productCount: existing.productCount + 1, + }); + }); + + return stockMap; + }, [allProductWarehouses]); + + const warehouseOptions = isResponseSuccess(warehouses) + ? warehouses?.data.map((w) => { + const stockInfo = warehouseStockMap.get(w.id); + const stockLabel = stockInfo + ? ` (Stock: ${stockInfo.totalQty.toLocaleString('id-ID')} items, ${stockInfo.productCount} produk)` + : ' (Kosong)'; + + return { + value: w.id, + label: `${w.name}${stockLabel}`, + area: w.area?.name, + location: + 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') + ? w.location?.name + : undefined, + }; + }) + : []; + + const supplierOptions = isResponseSuccess(suppliers) + ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) + : []; + + // ===== FORM INITIALIZATION ===== const formikInitialValues = useMemo( () => getMovementFormInitialValues(initialValues), [initialValues] @@ -77,7 +155,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { if (d.document && d.document instanceof File) { documents.push(d.document); documentIndex = documents.length - 1; - } else { } return { @@ -122,91 +199,39 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }, }); - const addProduct = () => { - const newProducts = [ - ...(formik.values.products || []), - { - product: null, - product_id: 0, - product_qty: 0, - }, - ]; - formik.setFieldValue('products', newProducts); - }; + // ===== PRODUCT WAREHOUSE FETCHING (after form initialization) ===== + const getProductWarehousesUrl = useCallback(() => { + const productWarehouseParams = new URLSearchParams({ + search: productWarehouseSelectInputValue, + }); + if (formik.values.source_warehouse_id) { + productWarehouseParams.append( + 'warehouse_id', + formik.values.source_warehouse_id.toString() + ); + } + return `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`; + }, [formik.values.source_warehouse_id, productWarehouseSelectInputValue]); - const removeProduct = useCallback( - (i: number) => { - const updatedProducts = - formik.values.products?.reduce((acc: ProductSchema[], item, index) => { - if (index !== i) { - acc.push(item); - } - return acc; - }, []) ?? []; + const productWarehousesUrl = getProductWarehousesUrl(); + const { data: productWarehouses, isLoading: isLoadingProductWarehouses } = + useSWR( + formik.values.source_warehouse_id ? productWarehousesUrl : null, + ProductWarehouseApi.getAllFetcher + ); - formik.setFieldValue('products', updatedProducts); - }, - [formik] - ); - - const bulkRemoveProduct = useCallback(() => { - const updatedProducts = - formik.values.products?.filter( - (_, idx) => !selectedProducts.includes(idx) - ) ?? []; - formik.setFieldValue('products', updatedProducts); - setSelectedProducts([]); - }, [formik, selectedProducts]); - - const addDelivery = () => { - formik.setFieldValue('deliveries', [ - ...(formik.values.deliveries || []), - { - delivery_cost: undefined, - delivery_cost_per_item: undefined, - document: null, - driver_name: '', - vehicle_plate: '', - supplier: null, - supplier_id: 0, - products: [ - { - product: null, - product_id: 0, - product_qty: 0, - }, - ], - }, - ]); - }; - - const removeDelivery = useCallback( - (i: number) => { - const updatedDeliveries = - formik.values.deliveries?.reduce( - (acc: DeliverySchema[], item, index) => { - if (index !== i) { - acc.push(item); - } - return acc; - }, - [] - ) ?? []; - - formik.setFieldValue('deliveries', updatedDeliveries); - }, - [formik] - ); - - const bulkRemoveDelivery = useCallback(() => { - const updatedDeliveries = - formik.values.deliveries?.filter( - (_, idx) => !selectedDeliveries.includes(idx) - ) ?? []; - formik.setFieldValue('deliveries', updatedDeliveries); - setSelectedDeliveries([]); - }, [formik, selectedDeliveries]); + const productWarehouseOptions = isResponseSuccess(productWarehouses) + ? productWarehouses?.data.map((pw) => ({ + value: pw.product.id, + label: `${pw.product.name} - ${pw.warehouse.name} (Stock: ${pw.quantity.toLocaleString('id-ID')})`, + product_id: pw.product.id, + warehouse_id: pw.warehouse.id, + warehouse_name: pw.warehouse.name, + quantity: pw.quantity, + })) + : []; + // ===== HELPER FUNCTIONS ===== const isRepeaterInputError = ( arrayName: T, column: T extends 'products' ? keyof ProductSchema : keyof DeliverySchema, @@ -263,118 +288,98 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }; }; - interface WarehouseOptionType extends OptionType { - area?: string; - location?: string; - } + // ===== EVENT HANDLERS ===== + // Product Handlers + const addProduct = () => { + const newProducts = [ + ...(formik.values.products || []), + { + product: null, + product_id: 0, + product_qty: 0, + }, + ]; + formik.setFieldValue('products', newProducts); + }; - interface ProductWarehouseOptionType extends OptionType { - product_id: number; - warehouse_id: number; - warehouse_name: string; - quantity: number; - } + const removeProduct = useCallback( + (i: number) => { + const updatedProducts = + formik.values.products?.reduce((acc: ProductSchema[], item, index) => { + if (index !== i) { + acc.push(item); + } + return acc; + }, []) ?? []; - const allProductWarehousesUrl = `${ProductWarehouseApi.basePath}`; - const { data: allProductWarehouses } = useSWR( - allProductWarehousesUrl, - ProductWarehouseApi.getAllFetcher + formik.setFieldValue('products', updatedProducts); + }, + [formik] ); - const warehouseStockMap = useMemo(() => { - if (!isResponseSuccess(allProductWarehouses)) return new Map(); + const bulkRemoveProduct = useCallback(() => { + const updatedProducts = + formik.values.products?.filter( + (_, idx) => !selectedProducts.includes(idx) + ) ?? []; + formik.setFieldValue('products', updatedProducts); + setSelectedProducts([]); + }, [formik, selectedProducts]); - const stockMap = new Map< - number, - { totalQty: number; productCount: number } - >(); + // Delivery Handlers + const addDelivery = () => { + formik.setFieldValue('deliveries', [ + ...(formik.values.deliveries || []), + { + delivery_cost: undefined, + delivery_cost_per_item: undefined, + document: null, + driver_name: '', + vehicle_plate: '', + supplier: null, + supplier_id: 0, + products: [ + { + product: null, + product_id: 0, + product_qty: 0, + }, + ], + }, + ]); + }; - allProductWarehouses.data.forEach((pw) => { - const warehouseId = pw.warehouse.id; - const existing = stockMap.get(warehouseId) || { - totalQty: 0, - productCount: 0, - }; + const removeDelivery = useCallback( + (i: number) => { + const updatedDeliveries = + formik.values.deliveries?.reduce( + (acc: DeliverySchema[], item, index) => { + if (index !== i) { + acc.push(item); + } + return acc; + }, + [] + ) ?? []; - stockMap.set(warehouseId, { - totalQty: existing.totalQty + pw.quantity, - productCount: existing.productCount + 1, - }); - }); - - return stockMap; - }, [allProductWarehouses]); - - // Warehouse selection - const [warehouseSelectInputValue, setWarehouseSelectInputValue] = - useState(''); - const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; - const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( - warehousesUrl, - WarehouseApi.getAllFetcher + formik.setFieldValue('deliveries', updatedDeliveries); + }, + [formik] ); - const warehouseOptions = isResponseSuccess(warehouses) - ? warehouses?.data.map((w) => { - const stockInfo = warehouseStockMap.get(w.id); - const stockLabel = stockInfo - ? ` (Stock: ${stockInfo.totalQty.toLocaleString('id-ID')} items, ${stockInfo.productCount} produk)` - : ' (Kosong)'; - return { - value: w.id, - label: `${w.name}${stockLabel}`, - area: w.area?.name, - location: - 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') - ? w.location?.name - : undefined, - }; - }) - : []; + const bulkRemoveDelivery = useCallback(() => { + const updatedDeliveries = + formik.values.deliveries?.filter( + (_, idx) => !selectedDeliveries.includes(idx) + ) ?? []; + formik.setFieldValue('deliveries', updatedDeliveries); + setSelectedDeliveries([]); + }, [formik, selectedDeliveries]); - // Product Warehouse selection - Filter by source warehouse - const productWarehouseParams = new URLSearchParams({ - search: productWarehouseSelectInputValue, - }); - if (formik.values.source_warehouse_id) { - productWarehouseParams.append( - 'warehouse_id', - formik.values.source_warehouse_id.toString() - ); - } - const productWarehousesUrl = `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`; - const { data: productWarehouses, isLoading: isLoadingProductWarehouses } = - useSWR( - formik.values.source_warehouse_id ? productWarehousesUrl : null, - ProductWarehouseApi.getAllFetcher - ); - const productWarehouseOptions = isResponseSuccess(productWarehouses) - ? productWarehouses?.data.map((pw) => ({ - value: pw.product.id, - label: `${pw.product.name} - ${pw.warehouse.name} (Stock: ${pw.quantity.toLocaleString('id-ID')})`, - product_id: pw.product.id, - warehouse_id: pw.warehouse.id, - warehouse_name: pw.warehouse.name, - quantity: pw.quantity, - })) - : []; - - // Supplier selection - const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); - const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`; - const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( - suppliersUrl, - SupplierApi.getAllFetcher - ); - const supplierOptions = isResponseSuccess(suppliers) - ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) - : []; - - // Handle cost calculation when delivery_cost changes + // Cost Calculation Handlers const handleDeliveryCostChange = useCallback( - (idx: number, value: string) => { - const numValue = parseFloat(value) || 0; - formik.setFieldValue(`deliveries.${idx}.delivery_cost`, numValue); + (idx: number, value: number) => { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value); const delivery = formik.values.deliveries?.[idx]; if (delivery) { @@ -382,13 +387,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { (sum, p) => sum + p.product_qty, 0 ); - if (productQty > 0 && numValue > 0) { - const perItem = numValue / productQty; + if (productQty > 0 && value > 0) { + const perItem = value / productQty; formik.setFieldValue( `deliveries.${idx}.delivery_cost_per_item`, perItem ); - } else if (numValue === 0) { + } else if (value === 0) { formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0); } } @@ -396,13 +401,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik] ); - // Handle cost calculation when delivery_cost_per_item changes const handleDeliveryCostPerItemChange = useCallback( - (idx: number, value: string) => { - const numValue = parseFloat(value) || 0; + (idx: number, value: number) => { formik.setFieldValue( `deliveries.${idx}.delivery_cost_per_item`, - numValue + value ); const delivery = formik.values.deliveries?.[idx]; @@ -411,10 +414,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { (sum, p) => sum + p.product_qty, 0 ); - if (productQty > 0 && numValue > 0) { - const totalCost = numValue * productQty; + if (productQty > 0 && value > 0) { + const totalCost = value * productQty; formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); - } else if (numValue === 0) { + } else if (value === 0) { formik.setFieldValue(`deliveries.${idx}.delivery_cost`, 0); } } @@ -422,57 +425,27 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik] ); - // Auto-recalculate when product quantity changes - useEffect(() => { - formik.values.deliveries?.forEach((delivery, idx) => { - const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, - 0 - ); + const handleDeliveryCostChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); + const normalizedValue = rawValue.replace(/,/g, ''); + const value = parseFloat(normalizedValue) || 0; + handleDeliveryCostChange(idx, value); + }, + [handleDeliveryCostChange] + ); - // If delivery_cost is set, recalculate delivery_cost_per_item - if ( - delivery.delivery_cost && - delivery.delivery_cost > 0 && - productQty > 0 - ) { - const perItem = delivery.delivery_cost / productQty; - if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { - formik.setFieldValue( - `deliveries.${idx}.delivery_cost_per_item`, - perItem - ); - } - } - // If delivery_cost_per_item is set, recalculate delivery_cost - else if ( - delivery.delivery_cost_per_item && - delivery.delivery_cost_per_item > 0 && - productQty > 0 - ) { - const totalCost = delivery.delivery_cost_per_item * productQty; - if (Math.abs((delivery.delivery_cost || 0) - totalCost) > 0.01) { - formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); - } - } - }); - }, [ - formik.values.deliveries - ?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0)) - .join(','), - ]); - - useEffect(() => { - if ( - formik.values.source_warehouse_id && - type !== 'edit' && - type !== 'detail' - ) { - formik.setFieldValue('products', []); - formik.setFieldValue('deliveries', []); - } - }, [formik.values.source_warehouse_id]); + const handleDeliveryCostPerItemChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); + const normalizedValue = rawValue.replace(/,/g, ''); + const value = parseFloat(normalizedValue) || 0; + handleDeliveryCostPerItemChange(idx, value); + }, + [handleDeliveryCostPerItemChange] + ); + // UTILITY FUNCTIONS const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products @@ -618,6 +591,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik.values.deliveries, formik.values.products, type] ); + // ===== COMPUTED VALUES ===== const invalidQtyRows = useMemo( () => type === 'detail' @@ -650,6 +624,54 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); }, [formik.values.products, getProductQtyError, type]); + // ===== EFFECTS ===== + useEffect(() => { + formik.values.deliveries?.forEach((delivery, idx) => { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + + if ( + delivery.delivery_cost && + delivery.delivery_cost > 0 && + productQty > 0 + ) { + const perItem = delivery.delivery_cost / productQty; + if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + perItem + ); + } + } else if ( + delivery.delivery_cost_per_item && + delivery.delivery_cost_per_item > 0 && + productQty > 0 + ) { + const totalCost = delivery.delivery_cost_per_item * productQty; + if (Math.abs((delivery.delivery_cost || 0) - totalCost) > 0.01) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); + } + } + }); + }, [ + formik.values.deliveries + ?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0)) + .join(','), + ]); + + useEffect(() => { + if ( + formik.values.source_warehouse_id && + type !== 'edit' && + type !== 'detail' + ) { + formik.setFieldValue('products', []); + formik.setFieldValue('deliveries', []); + } + }, [formik.values.source_warehouse_id]); + return ( <>
@@ -839,7 +861,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {type !== 'detail' && ( -
{ checkbox: 'checkbox checkbox-sm', }} /> -
)} @@ -893,31 +913,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {formik.values.products?.map((product, idx) => ( {type !== 'detail' && ( - -
- - ) => { - if (e.target.checked) { - setSelectedProducts([ - ...selectedProducts, - idx, - ]); - } else { - setSelectedProducts( - selectedProducts.filter((i) => i !== idx) - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> -
+ + , + ) => { + if (e.target.checked) { + setSelectedProducts([ + ...selectedProducts, + idx, + ]); + } else { + setSelectedProducts( + selectedProducts.filter((i) => i !== idx), + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> )} @@ -1061,7 +1079,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {type !== 'detail' && ( -
{ checkbox: 'checkbox checkbox-sm', }} /> -
)} @@ -1161,33 +1177,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {formik.values.deliveries?.map((delivery, idx) => ( {type !== 'detail' && ( - -
- - ) => { - if (e.target.checked) { - setSelectedDeliveries([ - ...selectedDeliveries, - idx, - ]); - } else { - setSelectedDeliveries( - selectedDeliveries.filter( - (i) => i !== idx - ) - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> -
+ + , + ) => { + if (e.target.checked) { + setSelectedDeliveries([ + ...selectedDeliveries, + idx, + ]); + } else { + setSelectedDeliveries( + selectedDeliveries.filter( + (i) => i !== idx, + ), + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> )} @@ -1373,9 +1387,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { required name={`deliveries.${idx}.delivery_cost`} value={delivery.delivery_cost || ''} - onChange={(e) => - handleDeliveryCostChange(idx, e.target.value) - } + onChange={handleDeliveryCostChangeWrapper(idx)} onBlur={formik.handleBlur} maskType='currency' decimals={0} @@ -1397,12 +1409,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { required name={`deliveries.${idx}.delivery_cost_per_item`} value={delivery.delivery_cost_per_item || ''} - onChange={(e) => - handleDeliveryCostPerItemChange( - idx, - e.target.value - ) - } + onChange={handleDeliveryCostPerItemChangeWrapper(idx)} onBlur={formik.handleBlur} maskType='currency' decimals={0} From 6290199074bc8f56db602554850987afd49a3a41 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 25 Oct 2025 10:49:07 +0700 Subject: [PATCH 033/174] feat(FE-Storyless): integrate NumberInput and PatternInput components with react-number-format for enhanced input handling --- package-lock.json | 11 + package.json | 1 + src/components/input/NumberInput.tsx | 426 ++------------------------ src/components/input/PatternInput.tsx | 60 ++++ 4 files changed, 104 insertions(+), 394 deletions(-) create mode 100644 src/components/input/PatternInput.tsx diff --git a/package-lock.json b/package-lock.json index 4a583dbd..6f255a99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", + "react-number-format": "^5.4.4", "react-select": "^5.10.2", "swr": "^2.3.6", "tailwind-merge": "^3.3.1", @@ -5834,6 +5835,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-number-format": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", + "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-select": { "version": "5.10.2", "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", diff --git a/package.json b/package.json index 70e5737f..b371e4e7 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", + "react-number-format": "^5.4.4", "react-select": "^5.10.2", "swr": "^2.3.6", "tailwind-merge": "^3.3.1", diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx index 5b2188ee..8efef51d 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -1,415 +1,53 @@ 'use client'; -import { - ChangeEvent, - ChangeEventHandler, - FocusEventHandler, - ReactNode, - useEffect, - useRef, - useState, -} from 'react'; +import { ChangeEvent } from 'react'; +import { NumericFormat, OnValueChange } from 'react-number-format'; +import TextInput, { TextInputProps } from '@/components/input/TextInput'; -import { cn } from '@/lib/helper'; -import Inputmask from 'inputmask'; - -const createInputMask = ( - maskType: MaskType, - decimals: number, - thousandSeparator: string, - decimalSeparator: string, - allowNegative: boolean, - oncomplete?: () => void, - onincomplete?: () => void, - oncleared?: () => void -): Inputmask.Instance => { - const options: Inputmask.Options = { - alias: 'numeric', - groupSeparator: thousandSeparator, - radixPoint: decimalSeparator, - digits: decimals, - allowMinus: allowNegative, - rightAlign: false, - insertMode: true, - autoUnmask: false, - clearMaskOnLostFocus: false, - digitsOptional: decimals > 0, - placeholder: '0', - numericInput: false, - positionCaretOnClick: 'radixFocus', - greedy: true, - oncomplete, - onincomplete, - oncleared - }; - - return new Inputmask(options); -}; - -export type MaskType = 'currency' | 'weight' | 'decimal' | 'number' | 'text'; - -export interface NumberInputProps { - label?: string; - bottomLabel?: string; - name: string; - value?: number | string; - placeholder?: string; - - className?: { - wrapper?: string; - label?: string; - inputWrapper?: string; - input?: string; - }; - - isError?: boolean; - isValid?: boolean; - errorMessage?: string; - disabled?: boolean; - readOnly?: boolean; - required?: boolean; - isLoading?: boolean; - - startAdornment?: ReactNode; - endAdornment?: ReactNode; - - onChange?: ChangeEventHandler; - onBlur?: FocusEventHandler; - onFocus?: FocusEventHandler; - - maskType?: MaskType; - decimals?: number; +interface NumberInputProps extends Omit { thousandSeparator?: string; decimalSeparator?: string; - currencyPrefix?: string; - weightUnit?: string; - - min?: number; - max?: number; + decimalScale?: number; allowNegative?: boolean; - - oncomplete?: () => void; - onincomplete?: () => void; - oncleared?: () => void; + prefix?: string; + suffix?: string; + fixedDecimalScale?: boolean; } const NumberInput = ({ - label, - bottomLabel, - name, - value, - placeholder, - className, - isError, - isValid, - errorMessage, - startAdornment, - endAdornment, - disabled = false, - required = false, - onChange, - onBlur, - onFocus, - readOnly = false, - isLoading = false, - maskType = 'number', - decimals = 0, thousandSeparator = ',', decimalSeparator = '.', - currencyPrefix = 'Rp ', - weightUnit = 'kg', - allowNegative = false, - oncomplete, - onincomplete, - oncleared, + decimalScale = 5, + allowNegative = true, + onChange, + ...restProps }: NumberInputProps) => { - const inputRef = useRef(null); - const inputmaskRef = useRef(null); - const [maskComplete, setMaskComplete] = useState(false); - const [maskIncomplete, setMaskIncomplete] = useState(false); - const [maskCleared, setMaskCleared] = useState(false); + const valueChangeHandler: OnValueChange = ( + numberFormatValues, + sourceInfo + ) => { + const newChangeEvent = sourceInfo.event as + | ChangeEvent + | undefined; - const getInputPrefix = (): string => { - switch (maskType) { - case 'currency': - return currencyPrefix; - default: - return ''; + if (newChangeEvent) { + newChangeEvent.target.value = numberFormatValues.value; + + onChange?.(newChangeEvent); } }; - const getInputSuffix = (): string => { - switch (maskType) { - case 'weight': - return weightUnit; - default: - return ''; - } - }; - - useEffect(() => { - if (inputRef.current && !readOnly && !disabled) { - if (inputmaskRef.current) { - try { - inputmaskRef.current.remove(); - } catch (error) { - console.warn('Error removing Inputmask:', error); - } - } - - const handleComplete = () => { - setMaskComplete(true); - setMaskIncomplete(false); - setMaskCleared(false); - if (oncomplete) oncomplete(); - }; - - const handleIncomplete = () => { - setMaskIncomplete(true); - setMaskComplete(false); - setMaskCleared(false); - if (onincomplete) onincomplete(); - }; - - const handleCleared = () => { - setMaskCleared(true); - setMaskComplete(false); - setMaskIncomplete(false); - if (oncleared) oncleared(); - }; - - const im = createInputMask( - maskType, - decimals, - ',', - '.', - allowNegative, - handleComplete, - handleIncomplete, - handleCleared - ); - - try { - im.mask(inputRef.current); - inputmaskRef.current = im; - } catch (error) { - console.warn('Error applying Inputmask:', error); - inputmaskRef.current = null; - } - } - - return () => { - if (inputmaskRef.current) { - try { - inputmaskRef.current.remove(); - } catch (error) { - console.warn('Error removing Inputmask on cleanup:', error); - } - } - }; - }, [maskType, decimals, thousandSeparator, decimalSeparator, allowNegative, readOnly, disabled, oncomplete, onincomplete, oncleared]); - - useEffect(() => { - if (inputRef.current && value !== undefined) { - if (value === null || value === '') { - inputRef.current.value = ''; - } else { - inputRef.current.value = String(value); - } - } - }, [value]); - - const handleKeyUp = (e: React.KeyboardEvent) => { - const currentValue = (e.currentTarget as HTMLInputElement).value; - console.log('✅ After format:', currentValue); - - if (onChange) { - const syntheticEvent = { - target: { - name, - value: currentValue, - }, - } as ChangeEvent; - onChange(syntheticEvent); - } - }; - - const inputPrefix = getInputPrefix(); - const inputSuffix = getInputSuffix(); - return ( -
- {label && ( - - )} - -
- {inputPrefix && ( -
- - {inputPrefix} - -
- )} - -
- {startAdornment && startAdornment} - - - - {(isLoading || endAdornment) && ( -
- {isLoading && } - {endAdornment && endAdornment} -
- )} -
- - {inputSuffix && ( -
- - {inputSuffix} - -
- )} -
- - {(maskType === 'text' || (oncomplete || onincomplete || oncleared)) && ( -
- - Complete - - - Incomplete - - - Cleared - -
- )} - - {!isError && bottomLabel && ( -

{bottomLabel}

- )} - {isError && errorMessage && ( -

{errorMessage}

- )} -
+ ); }; export default NumberInput; - diff --git a/src/components/input/PatternInput.tsx b/src/components/input/PatternInput.tsx new file mode 100644 index 00000000..1905d2e3 --- /dev/null +++ b/src/components/input/PatternInput.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { ChangeEvent } from 'react'; +import { PatternFormat, OnValueChange } from 'react-number-format'; +import TextInput, { TextInputProps } from '@/components/input/TextInput'; + +interface PatternInputProps extends Omit { + type?: 'password' | 'tel' | 'text' | undefined; + + /** Format pattern, e.g. "##/##/####", "(###) ###-####", "####-####-####" */ + format: string; + + /** Mask character for empty slots, e.g. "_" */ + mask?: string; + + /** Allow showing mask even when value is empty */ + allowEmptyFormatting?: boolean; + + patternChar?: string; +} + +const PatternInput = ({ + type = 'text', + format, + mask = '_', + allowEmptyFormatting = false, + patternChar = '#', + onChange, + ...restProps + }: PatternInputProps) => { + const valueChangeHandler: OnValueChange = ( + patternFormatValues, + sourceInfo + ) => { + const newChangeEvent = sourceInfo.event as + | ChangeEvent + | undefined; + + if (newChangeEvent) { + newChangeEvent.target.value = patternFormatValues.value; + + onChange?.(newChangeEvent); + } + }; + + return ( + + ); +}; + +export default PatternInput; From d7ce8c667a0ea5b02cab44785052c74735de41b7 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 25 Oct 2025 11:26:38 +0700 Subject: [PATCH 034/174] refactor(FE-114): simplify input handling in MovementForm and RecordingForm by removing unnecessary value normalization --- .../inventory/movement/form/MovementForm.tsx | 22 ++++----- .../recording/form/RecordingForm.tsx | 47 +++++++------------ 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 6d6317da..7cd5bb50 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -427,9 +427,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const handleDeliveryCostChangeWrapper = useCallback( (idx: number) => (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); - const normalizedValue = rawValue.replace(/,/g, ''); - const value = parseFloat(normalizedValue) || 0; + const value = parseFloat(e.target.value) || 0; handleDeliveryCostChange(idx, value); }, [handleDeliveryCostChange] @@ -437,9 +435,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const handleDeliveryCostPerItemChangeWrapper = useCallback( (idx: number) => (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); - const normalizedValue = rawValue.replace(/,/g, ''); - const value = parseFloat(normalizedValue) || 0; + const value = parseFloat(e.target.value) || 0; handleDeliveryCostPerItemChange(idx, value); }, [handleDeliveryCostPerItemChange] @@ -1389,9 +1385,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={delivery.delivery_cost || ''} onChange={handleDeliveryCostChangeWrapper(idx)} onBlur={formik.handleBlur} - maskType='currency' - decimals={0} - min={0} + decimalScale={0} + allowNegative={false} + thousandSeparator=',' + decimalSeparator='.' {...isRepeaterInputError( 'deliveries', 'delivery_cost', @@ -1411,9 +1408,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={delivery.delivery_cost_per_item || ''} onChange={handleDeliveryCostPerItemChangeWrapper(idx)} onBlur={formik.handleBlur} - maskType='currency' - decimals={0} - min={0} + decimalScale={0} + allowNegative={false} + thousandSeparator=',' + decimalSeparator='.' {...isRepeaterInputError( 'deliveries', 'delivery_cost_per_item', diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index ca4a696d..deaffc9a 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -494,16 +494,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; const handleWeightChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); - const normalizedValue = rawValue.replace(/,/g, ''); - const value = parseFloat(normalizedValue) || 0; + const value = parseFloat(e.target.value) || 0; handleWeightChange(idx, value); }; const handleQtyChangeWrapper = (idx: number) => (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); - const normalizedValue = rawValue.replace(/,/g, ''); - const value = parseFloat(normalizedValue) || 0; + const value = parseFloat(e.target.value) || 0; handleQtyChange(idx, value); }; @@ -511,9 +507,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setEditingAverageIndex(idx); setManuallyEditedRows(prev => new Set(prev).add(idx)); - const rawValue = e.target.value.replace(/[^\d,.-]/g, ''); - const normalizedValue = rawValue.replace(/,/g, ''); - const value = parseFloat(normalizedValue) || 0; + const value = parseFloat(e.target.value) || 0; handleAverageWeightChange(idx, value); }; @@ -551,7 +545,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const handleStockUsageAmountChangeWrapper = useCallback( (idx: number) => (e: React.ChangeEvent) => { - const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; + const value = parseFloat(e.target.value) || 0; formik.setFieldValue(`stocks.${idx}.usage_amount`, value); }, [formik] @@ -584,7 +578,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const handleDepletionTotalChangeWrapper = useCallback( (idx: number) => (e: React.ChangeEvent) => { - const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; + const value = parseFloat(e.target.value) || 0; formik.setFieldValue(`depletions.${idx}.total`, value); }, [formik] @@ -801,10 +795,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bw.weight} onChange={handleWeightChangeWrapper(idx)} onBlur={formik.handleBlur} - maskType='weight' - weightUnit='gram' - decimals={2} - min={0} + decimalScale={2} + allowNegative={false} thousandSeparator=',' decimalSeparator='.' isError={ @@ -828,9 +820,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bw.qty} onChange={handleQtyChangeWrapper(idx)} onBlur={formik.handleBlur} - maskType='number' - decimals={0} - min={0} + decimalScale={0} + allowNegative={false} thousandSeparator=',' decimalSeparator='.' isError={ @@ -856,10 +847,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { handleAverageWeightBlur(idx); formik.handleBlur(e); }} - maskType='weight' - weightUnit='gram' - decimals={2} - min={0} + decimalScale={2} + allowNegative={false} thousandSeparator=',' decimalSeparator='.' isError={ @@ -1063,11 +1052,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={stock.usage_amount} onChange={handleStockUsageAmountChangeWrapper(idx)} onBlur={formik.handleBlur} - maskType='number' - decimals={0} - min={0} + decimalScale={0} + allowNegative={false} thousandSeparator=',' - decimalSeparator='' + decimalSeparator='.' isError={ isRepeaterInputError('stocks', 'usage_amount', idx) .isError || Boolean(getStockUsageError(idx)) @@ -1255,11 +1243,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={depletion.total} onChange={handleDepletionTotalChangeWrapper(idx)} onBlur={formik.handleBlur} - maskType='number' - decimals={0} - min={0} + decimalScale={0} + allowNegative={false} thousandSeparator=',' - decimalSeparator='' + decimalSeparator='.' isError={ isRepeaterInputError('depletions', 'total', idx) .isError From a0556ea1f471c51ffd9cea2a2f25564b1d89df7e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 25 Oct 2025 13:53:53 +0700 Subject: [PATCH 035/174] refactor(FE-114): add currency prefix and unit suffix to delivery cost and body weight inputs --- src/components/pages/inventory/movement/form/MovementForm.tsx | 2 ++ .../pages/production/recording/form/RecordingForm.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 7cd5bb50..2f8c762f 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -1389,6 +1389,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { allowNegative={false} thousandSeparator=',' decimalSeparator='.' + prefix="Rp " {...isRepeaterInputError( 'deliveries', 'delivery_cost', @@ -1412,6 +1413,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { allowNegative={false} thousandSeparator=',' decimalSeparator='.' + prefix="Rp " {...isRepeaterInputError( 'deliveries', 'delivery_cost_per_item', diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index deaffc9a..1e7b053f 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -799,6 +799,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { allowNegative={false} thousandSeparator=',' decimalSeparator='.' + suffix=" gram" isError={ isRepeaterInputError('body_weights', 'weight', idx) .isError @@ -851,6 +852,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { allowNegative={false} thousandSeparator=',' decimalSeparator='.' + suffix=" gram" isError={ isRepeaterInputError('body_weights', 'average_weight', idx) .isError From 189c15274535861c3529728b83b3af4a20f9078d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 25 Oct 2025 14:24:23 +0700 Subject: [PATCH 036/174] feat(FE-114): add inputPrefix and inputSuffix props for enhanced input customization --- src/components/input/NumberInput.tsx | 8 +- src/components/input/TextInput.tsx | 136 +++++++++++++++++++++------ 2 files changed, 114 insertions(+), 30 deletions(-) diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx index 8efef51d..d4d8045d 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChangeEvent } from 'react'; +import { ChangeEvent, ReactNode } from 'react'; import { NumericFormat, OnValueChange } from 'react-number-format'; import TextInput, { TextInputProps } from '@/components/input/TextInput'; @@ -12,6 +12,8 @@ interface NumberInputProps extends Omit { prefix?: string; suffix?: string; fixedDecimalScale?: boolean; + inputPrefix?: ReactNode; + inputSuffix?: ReactNode; } const NumberInput = ({ @@ -20,6 +22,8 @@ const NumberInput = ({ decimalScale = 5, allowNegative = true, onChange, + inputPrefix, + inputSuffix, ...restProps }: NumberInputProps) => { const valueChangeHandler: OnValueChange = ( @@ -45,6 +49,8 @@ const NumberInput = ({ onValueChange={valueChangeHandler} decimalScale={decimalScale} allowNegative={allowNegative} + inputPrefix={inputPrefix} + inputSuffix={inputSuffix} {...restProps} /> ); diff --git a/src/components/input/TextInput.tsx b/src/components/input/TextInput.tsx index 43797637..7d791d2f 100644 --- a/src/components/input/TextInput.tsx +++ b/src/components/input/TextInput.tsx @@ -31,6 +31,8 @@ export interface TextInputProps { errorMessage?: string; startAdornment?: ReactNode; endAdornment?: ReactNode; + inputPrefix?: ReactNode; + inputSuffix?: ReactNode; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; } @@ -48,6 +50,8 @@ const TextInput = ({ errorMessage, startAdornment, endAdornment, + inputPrefix, + inputSuffix, disabled = false, required = false, onChange, @@ -85,39 +89,113 @@ const TextInput = ({ )} -
- {startAdornment && startAdornment} + {inputPrefix || inputSuffix ? ( +
+ {inputPrefix && ( +
+ {inputPrefix} +
+ )} - +
+ {startAdornment && startAdornment} - {(isLoading || endAdornment) && ( -
- {isLoading && } + - {endAdornment && endAdornment} + {(isLoading || endAdornment) && ( +
+ {isLoading && } + + {endAdornment && endAdornment} +
+ )}
- )} -
+ + {inputSuffix && ( +
+ {inputSuffix} +
+ )} +
+ ) : ( +
+ {startAdornment && startAdornment} + + + + {(isLoading || endAdornment) && ( +
+ {isLoading && } + + {endAdornment && endAdornment} +
+ )} +
+ )} {!isError && bottomLabel && (

{bottomLabel}

From 135fc2d5d39a44e4f3f9649bfd3c658552e9c31e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 25 Oct 2025 14:24:51 +0700 Subject: [PATCH 037/174] feat(FE-114): update MovementForm and RecordingForm to use inputPrefix and inputSuffix for improved input handling --- src/components/pages/inventory/movement/form/MovementForm.tsx | 4 ++-- .../pages/production/recording/form/RecordingForm.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 2f8c762f..9a2b3b97 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -1389,7 +1389,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { allowNegative={false} thousandSeparator=',' decimalSeparator='.' - prefix="Rp " + inputPrefix="Rp" {...isRepeaterInputError( 'deliveries', 'delivery_cost', @@ -1413,7 +1413,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { allowNegative={false} thousandSeparator=',' decimalSeparator='.' - prefix="Rp " + inputPrefix="Rp" {...isRepeaterInputError( 'deliveries', 'delivery_cost_per_item', diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 1e7b053f..0a117f3a 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -799,7 +799,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { allowNegative={false} thousandSeparator=',' decimalSeparator='.' - suffix=" gram" + inputSuffix="gram" isError={ isRepeaterInputError('body_weights', 'weight', idx) .isError @@ -852,7 +852,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { allowNegative={false} thousandSeparator=',' decimalSeparator='.' - suffix=" gram" + inputSuffix="gram" isError={ isRepeaterInputError('body_weights', 'average_weight', idx) .isError From c8f596ad2af436f37ccd47eb41e3d2b28e5232fc Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 27 Oct 2025 05:54:14 +0700 Subject: [PATCH 038/174] refactor(FE-137): update RecordingForm to improve project flock handling and label formatting --- .../recording/form/RecordingForm.schema.ts | 2 +- .../recording/form/RecordingForm.tsx | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 59afbd61..4abee337 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -93,7 +93,7 @@ export const getRecordingFormInitialValues = ( project_flock_kandang: initialValues?.project_flock_kandang_id ? { value: initialValues.project_flock_kandang_id, - label: `Project Flock Kandang #${initialValues.project_flock_kandang_id}`, + label: `Project Flock #${initialValues.project_flock_kandang_id}`, } : null, project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0, diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 0a117f3a..ffee70ae 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -107,7 +107,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { projectFlocks?.data.forEach((projectFlock) => { projectFlock.kandangs.forEach((kandang) => { options.push({ - value: kandang.id, + value: projectFlock.id, label: `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name}`, }); }); @@ -242,15 +242,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (!formik.values.project_flock_kandang || !isResponseSuccess(projectFlocks)) { return selectedLocation; } - const kandangId = formik.values.project_flock_kandang.value; - for (const projectFlock of projectFlocks.data) { - const kandang = projectFlock.kandangs.find(k => k.id === kandangId); - if (kandang && projectFlock.location) { - return { - value: projectFlock.location.id, - label: projectFlock.location.name - }; - } + const projectFlockId = formik.values.project_flock_kandang.value; + const projectFlock = projectFlocks.data.find(pf => pf.id === projectFlockId); + if (projectFlock && projectFlock.location) { + return { + value: projectFlock.location.id, + label: projectFlock.location.name + }; } return selectedLocation; }, [formik.values.project_flock_kandang, projectFlocks, selectedLocation]); From 4b9d0d20645e1e9a2556cf8fee63da4cf43214e3 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 27 Oct 2025 06:18:27 +0700 Subject: [PATCH 039/174] refactor(FE-137): enhance RecordingForm validation to prevent duplicate project flock entries --- .../recording/form/RecordingForm.schema.ts | 87 ++++++++++- .../recording/form/RecordingForm.tsx | 135 +++++++++++------- 2 files changed, 166 insertions(+), 56 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 4abee337..ccb4ddd5 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -6,6 +6,91 @@ import { } from '@/types/api/production/recording'; export const RecordingFormSchema = Yup.object({ + project_flock_kandang: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + project_flock_kandang_id: Yup.number() + .default(0) + .typeError('Project Flock Kandang wajib diisi!') + .test( + 'is-valid-project-flock-kandang', + 'Project Flock Kandang wajib diisi!', + (value) => value !== undefined && value !== null && value > 0 + ) + .required('Project Flock Kandang wajib diisi!') + .test( + 'not-already-recorded', + 'Project Flock ini sudah direcord hari ini!', + function(value) { + const recordedProjectFlockIds = this.options.context?.recordedProjectFlockIds as Set; + const formType = this.options.context?.type as 'add' | 'edit' | 'detail'; + if (formType !== 'add') return true; + if (value && recordedProjectFlockIds?.has(value)) { + return false; + } + return true; + } + ), + body_weights: Yup.array() + .of( + Yup.object({ + weight: Yup.number() + .required('Berat ayam wajib diisi!') + .min(1, 'Berat ayam minimal 1 gram!') + .typeError('Berat ayam harus berupa angka!'), + qty: Yup.number() + .required('Jumlah ayam wajib diisi!') + .min(1, 'Jumlah ayam minimal 1 ekor!') + .typeError('Jumlah ayam harus berupa angka!') + .default(1), + average_weight: Yup.number() + .optional() + .min(0, 'Rata-rata berat tidak boleh negatif!') + .typeError('Rata-rata berat harus berupa angka!') + .default(0), + }) + ) + .min(1, 'Minimal harus ada 1 data bobot badan!') + .required('Data bobot badan wajib diisi!'), + stocks: Yup.array() + .of( + Yup.object({ + product_warehouse_id: Yup.number() + .required('Produk wajib diisi!') + .min(1, 'Produk wajib diisi!') + .typeError('Produk harus berupa angka!'), + usage_amount: Yup.number() + .required('Jumlah penggunaan wajib diisi!') + .min(0, 'Jumlah penggunaan tidak boleh negatif!') + .typeError('Jumlah penggunaan harus berupa angka!'), + notes: Yup.string().optional(), + }) + ) + .min(1, 'Minimal harus ada 1 data stok!') + .required('Data stok wajib diisi!'), + depletions: Yup.array() + .of( + Yup.object({ + total: Yup.number() + .required('Jumlah depletions wajib diisi!') + .min(1, 'Jumlah depletions minimal 1!') + .typeError('Jumlah depletions harus berupa angka!'), + notes: Yup.string() + .required('Kondisi depletions wajib diisi!') + .oneOf( + RECORDING_FLAG_OPTIONS.map((option) => option.value), + 'Kondisi depletions tidak valid!' + ) + .typeError('Kondisi depletions harus berupa teks!') + .min(1, 'Kondisi depletions wajib diisi!'), + }) + ) + .min(1, 'Minimal harus ada 1 data depletions!') + .required('Data depletions wajib diisi!'), +}); + +export const UpdateRecordingFormSchema = Yup.object({ project_flock_kandang: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), @@ -77,8 +162,6 @@ export const RecordingFormSchema = Yup.object({ .required('Data depletions wajib diisi!'), }); -export const UpdateRecordingFormSchema = RecordingFormSchema; - export type RecordingFormValues = Yup.InferType; type RecordingFormData = Partial & { diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index ffee70ae..ada0f019 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -86,6 +86,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return `${ProductWarehouseApi.basePath}?${params.toString()}`; }, [selectedLocation]); + const today = new Date().toISOString().split('T')[0]; + const existingRecordingsUrl = useMemo(() => { + return `${RecordingApi.basePath}?record_date=${today}`; + }, []); + + const { data: existingRecordings } = useSWR( + existingRecordingsUrl, + RecordingApi.getAllFetcher + ); + + const recordedProjectFlockIds = useMemo(() => { + if (!isResponseSuccess(existingRecordings)) return new Set(); + return new Set(existingRecordings?.data.map(rec => rec.project_flock_kandang_id) || []); + }, [existingRecordings]); + const { data: stockProducts, isLoading: isLoadingStockProducts } = useSWR( stockProductsUrl, ProductWarehouseApi.getAllFetcher @@ -106,14 +121,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const options: OptionType[] = []; projectFlocks?.data.forEach((projectFlock) => { projectFlock.kandangs.forEach((kandang) => { + const isAlreadyRecorded = recordedProjectFlockIds.has(projectFlock.id); + const label = isAlreadyRecorded + ? `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name} (Sudah Direcord)` + : `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name}`; + options.push({ value: projectFlock.id, - label: `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name}`, + label: label, }); }); }); return options; - }, [projectFlocks]); + }, [projectFlocks, recordedProjectFlockIds, type]); const unifiedStockProducts = useMemo(() => { const options: OptionType[] = []; @@ -359,6 +379,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; const projectFlockKandangChangeHandler = (val: OptionType | OptionType[] | null) => { + if (type === 'add' && val && recordedProjectFlockIds.has((val as OptionType).value as number)) { + toast.error('Project Flock ini sudah direcord hari ini!'); + return; + } + formik.setFieldTouched('project_flock_kandang', true); formik.setFieldValue('project_flock_kandang', val); formik.setFieldTouched('project_flock_kandang_id', true); @@ -665,28 +690,30 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isSearchable /> - +
+ +
@@ -1044,38 +1071,38 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isDisabled={type === 'detail'} /> - +
- - {type !== 'detail' && getStockUsageAdornment(idx)} -
+ + {type !== 'detail' && getStockUsageAdornment(idx)} +
- {type !== 'detail' && ( + {type !== 'detail' && (
- {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
{selectedBodyWeights.length > 0 && (
- {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
{selectedStocks.length > 0 && (
- {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
{selectedDepletions.length > 0 && ( + )} + +
+ )} + + )} + {/* Action buttons */} type={type} @@ -1750,7 +2117,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { /> {/* Approve Confirmation Modal */} - {type === 'detail' && ( + {(type as 'add' | 'edit' | 'detail') === 'detail' && ( { )} {/* Reject Confirmation Modal */} - {type === 'detail' && ( + {(type as 'add' | 'edit' | 'detail') === 'detail' && ( Date: Fri, 31 Oct 2025 13:08:55 +0700 Subject: [PATCH 062/174] refactor(FE-170,174): simplify daily recording form by removing unused flock period logic --- .../recording/form/RecordingForm.tsx | 76 ++----------------- 1 file changed, 6 insertions(+), 70 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 5722e726..530b1d43 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -37,7 +37,6 @@ import { LocationApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { isResponseSuccess } from '@/lib/api-helper'; import { - PeriodFlock, ProjectFlockKandangLookup, } from '@/types/api/production/project-flock'; import { useModal } from '@/components/Modal'; @@ -153,72 +152,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { RecordingApi.getAllFetcher ); - const flockPeriodsUrls = useMemo(() => { - if (!isResponseSuccess(projectFlocks)) return []; - return ( - projectFlocks?.data.map( - (pf) => `${ProjectFlockApi.basePath}/flocks/${pf.flock.id}/periods` - ) || [] - ); - }, [projectFlocks]); - - const { data: flockPeriodsData } = useSWR[]>( - flockPeriodsUrls.length > 0 ? flockPeriodsUrls : null, - (urls: string[]) => - Promise.all(urls.map((url) => fetch(url).then((res) => res.json()))), - { - revalidateOnFocus: false, - dedupingInterval: 60000, - } - ); - - const recordedProjectFlockIds = useMemo(() => { - if (!isResponseSuccess(existingRecordings)) return new Set(); - - const todayRecordings = existingRecordings?.data || []; - const recordedIds = new Set(); - - todayRecordings.forEach((recording) => { - const recordingDate = recording.record_datetime?.split('T')[0]; - - const isRecordedToday = recordingDate === today; - - let isCorrectPeriod = false; - if ( - isRecordedToday && - flockPeriodsData && - isResponseSuccess(projectFlocks) - ) { - const flockIndex = projectFlocks.data.findIndex( - (pf) => pf.id === recording.project_flock_kandangs_id - ); - if ( - flockIndex !== undefined && - flockIndex >= 0 && - flockPeriodsData[flockIndex] - ) { - const flockPeriod = flockPeriodsData[flockIndex]; - const currentProjectFlock = projectFlocks.data[flockIndex]; - - if ( - currentProjectFlock && - isResponseSuccess(flockPeriod) && - flockPeriod.data?.next_period - ) { - const expectedDay = flockPeriod.data.next_period - 1; - isCorrectPeriod = recording.day === expectedDay; - } - } - } - - if (isRecordedToday && (isCorrectPeriod || !flockPeriodsData)) { - recordedIds.add(recording.project_flock_kandangs_id); - } - }); - - return recordedIds; - }, [existingRecordings, today, flockPeriodsData, projectFlocks]); - + const { data: stockProducts, isLoading: isLoadingStockProducts } = useSWR( stockProductsUrl, ProductWarehouseApi.getAllFetcher @@ -592,11 +526,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); const getProjectFlockBadgeAdornment = useCallback(() => { - if (!isResponseSuccess(projectFlocks) || !projectFlockKandangLookup) + if (!projectFlockKandangLookup) return null; const isAlreadyRecorded = recordedProjectFlockKandangIds.has( - projectFlockKandangLookup.id + projectFlockKandangLookup.project_flock_kandang_id ); let color: 'neutral' | 'success' | 'warning' | 'error' = 'neutral'; @@ -619,7 +553,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); }, [ - projectFlocks, recordedProjectFlockKandangIds, projectFlockKandangLookup, ]); @@ -1124,6 +1057,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {(type as 'add' | 'edit' | 'detail') === 'detail' ? null : ( <> { /> { /> Date: Fri, 31 Oct 2025 13:52:36 +0700 Subject: [PATCH 063/174] feat(FE-174): add FormStepStatus type to enhance daily recording form state management --- src/types/api/api-general.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/types/api/api-general.d.ts b/src/types/api/api-general.d.ts index a42eaa3f..bdc2cffc 100644 --- a/src/types/api/api-general.d.ts +++ b/src/types/api/api-general.d.ts @@ -115,3 +115,9 @@ export type BaseApproval = { }; export type ApproveAction = 'APPROVED' | 'REJECTED'; + +export type FormStepStatus = { + name: string; + isCompleted: boolean; + isCurrent: boolean; +}; From c486d6cf817addf32a2a117d57857df6eb016550 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 13:52:54 +0700 Subject: [PATCH 064/174] feat(FE-170): implement form steps and navigation for daily recording form --- .../recording/form/RecordingForm.tsx | 278 +++++++++++++++--- 1 file changed, 234 insertions(+), 44 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 530b1d43..fe1183c7 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; import { useFormik } from 'formik'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; @@ -11,7 +12,6 @@ import SelectInput, { OptionType } from '@/components/input/SelectInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { FormHeader } from '@/components/helper/form/FormHeader'; -import { FormActions } from '@/components/helper/form/FormActions'; import { RecordingApi } from '@/services/api/production'; import { CreateGrowingRecordingPayload, @@ -20,7 +20,7 @@ import { UpdateLayingRecordingPayload, Recording, } from '@/types/api/production/recording'; -import { type BaseApiResponse } from '@/types/api/api-general'; +import { type BaseApiResponse, FormStepStatus } from '@/types/api/api-general'; import { RecordingGrowingFormSchema, RecordingLayingFormSchema, @@ -36,14 +36,15 @@ import { ProjectFlockApi } from '@/services/api/production'; import { LocationApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { isResponseSuccess } from '@/lib/api-helper'; -import { - ProjectFlockKandangLookup, -} from '@/types/api/production/project-flock'; +import { cn } from '@/lib/helper'; +import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; import { useModal } from '@/components/Modal'; import toast from 'react-hot-toast'; import Card from '@/components/Card'; import Badge from '@/components/Badge'; +import Steps from '@/components/steps/Steps'; +import StepItem from '@/components/steps/StepItem'; import { Kandang } from '@/types/api/master-data/kandang'; interface RecordingFormProps { @@ -52,6 +53,7 @@ interface RecordingFormProps { } const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { + const router = useRouter(); const [selectedBodyWeights, setSelectedBodyWeights] = useState([]); const [selectedStocks, setSelectedStocks] = useState([]); const [selectedDepletions, setSelectedDepletions] = useState([]); @@ -75,6 +77,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [isApproveLoading, setIsApproveLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false); + const [formSteps, setFormSteps] = useState(null); const approveModal = useModal(); const rejectModal = useModal(); @@ -152,7 +155,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { RecordingApi.getAllFetcher ); - const { data: stockProducts, isLoading: isLoadingStockProducts } = useSWR( stockProductsUrl, ProductWarehouseApi.getAllFetcher @@ -526,8 +528,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); const getProjectFlockBadgeAdornment = useCallback(() => { - if (!projectFlockKandangLookup) - return null; + if (!projectFlockKandangLookup) return null; const isAlreadyRecorded = recordedProjectFlockKandangIds.has( projectFlockKandangLookup.project_flock_kandang_id @@ -552,10 +553,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { Periode {projectFlockKandangLookup.project_flock?.period} ); - }, [ - recordedProjectFlockKandangIds, - projectFlockKandangLookup, - ]); + }, [recordedProjectFlockKandangIds, projectFlockKandangLookup]); const getProductFlagBadgeAdornment = useCallback( (productWarehouseId: number) => { @@ -713,9 +711,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (isResponseSuccess(approveResponse)) { toast.success('Recording berhasil disetujui!'); approveModal.closeModal(); - if (typeof window !== 'undefined') { - window.location.href = '/production/recording'; - } + router.push('/production/recording'); } else { toast.error( (approveResponse?.message as string) || 'Gagal menyetujui recording' @@ -743,9 +739,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (isResponseSuccess(rejectResponse)) { toast.success('Recording berhasil ditolak!'); rejectModal.closeModal(); - if (typeof window !== 'undefined') { - window.location.href = '/production/recording'; - } + router.push('/production/recording'); } else { toast.error( (rejectResponse?.message as string) || 'Gagal menolak recording' @@ -978,6 +972,26 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } }, [isLayingCategory, type, formik]); + useEffect(() => { + if (isLayingCategory) { + const steps: FormStepStatus[] = [ + { + name: 'Recording', + isCompleted: type === 'detail', + isCurrent: type !== 'detail', + }, + { + name: 'Grading', + isCompleted: false, + isCurrent: type === 'detail', + }, + ]; + setFormSteps(steps); + } else { + setFormSteps(null); + } + }, [isLayingCategory, type]); + useEffect(() => { if (formik.values.body_weights && editingAverageIndex === null) { const updatedBodyWeights = formik.values.body_weights.map( @@ -1023,16 +1037,79 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {/* Project Flock Info Card */} {projectFlockKandangLookup && ( - - {projectFlockKandangLookup.project_flock.category} - +
+ + {projectFlockKandangLookup.project_flock.category} + + + {/* Form Steps for LAYING Category */} + {formSteps && ( +
+
+
    + {formSteps.map((step, idx) => ( + + ) : ( + idx + 1 + ) + } + > + {step.name} + + ))} +
+
+ + {/* Action buttons for multi-form navigation */} + {type === 'detail' && ( +
+ + +
+ )} +
+ )} +
)} { )} {/* Action buttons */} - - type={type} - formik={formik} - editUrl={ - initialValues - ? `/production/recording/detail/edit/?recordingId=${initialValues.id}` - : undefined - } - onDelete={deleteRecordingClickHandler} - onApprove={() => approveModal.openModal()} - onReject={() => rejectModal.openModal()} - isApproveLoading={isApproveLoading} - isRejectLoading={isRejectLoading} - showApproveReject={type === 'detail'} - disableSubmit={hasExceededStock} - /> +
+ {type !== 'add' && ( +
+ {deleteRecordingClickHandler && ( + + )} + {type !== 'edit' && initialValues && ( + + )} + {type === 'detail' && ( + <> + + + + )} +
+ )} + {type !== 'detail' && ( +
+ + + {isLayingCategory && ( + + )} +
+ )} +
{recordingFormErrorMessage && (
Date: Fri, 31 Oct 2025 14:01:51 +0700 Subject: [PATCH 065/174] feat(FE-174): add grading functionality to daily recording form with validation --- .../recording/form/RecordingForm.schema.ts | 49 +++++++++++++++++++ src/types/api/production/recording.d.ts | 6 +-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 7bb1cd26..fb58b048 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -4,6 +4,7 @@ import { CreateGrowingRecordingPayload, CreateLayingRecordingPayload, CreateEggPayload, + CreateGradingPayload, } from '@/types/api/production/recording'; export const RecordingGrowingFormSchema = Yup.object({ @@ -132,6 +133,35 @@ export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({ .required('Project Flock Kandang wajib diisi!'), }); +export const RecordingGradingFormSchema = Yup.object({ + recording_egg_id: Yup.number() + .required('Recording Egg ID wajib diisi!') + .min(1, 'Recording Egg ID minimal 1!') + .typeError('Recording Egg ID harus berupa angka!'), + eggs_grading: Yup.array() + .of( + Yup.object({ + grade: Yup.string() + .required('Grade telur wajib diisi!') + .typeError('Grade telur harus berupa string!'), + qty: Yup.number() + .required('Jumlah telur wajib diisi!') + .min(1, 'Jumlah telur minimal 1!') + .typeError('Jumlah telur harus berupa angka!'), + }) + ) + .min(1, 'Minimal harus ada 1 data grading telur!') + .required('Data grading telur wajib diisi!'), +}); + +export const UpdateRecordingGradingFormSchema = + RecordingGradingFormSchema.shape({ + recording_egg_id: Yup.number() + .required('Recording Egg ID wajib diisi!') + .min(1, 'Recording Egg ID minimal 1!') + .typeError('Recording Egg ID harus berupa angka!'), + }); + export type RecordingGrowingFormValues = Yup.InferType< typeof RecordingGrowingFormSchema >; @@ -140,6 +170,10 @@ export type RecordingLayingFormValues = Yup.InferType< typeof RecordingLayingFormSchema >; +export type RecordingGradingFormValues = Yup.InferType< + typeof RecordingGradingFormSchema +>; + type RecordingFormData = Partial & { body_weights?: CreateGrowingRecordingPayload['body_weights']; stocks?: CreateGrowingRecordingPayload['stocks']; @@ -211,3 +245,18 @@ export const getRecordingLayingFormInitialValues = ( }, ], }); + +export const getRecordingGradingFormInitialValues = ( + initialValues?: Partial +): RecordingGradingFormValues => ({ + recording_egg_id: initialValues?.recording_egg_id ?? 0, + eggs_grading: initialValues?.eggs_grading?.map((grading) => ({ + grade: grading.grade, + qty: grading.qty, + })) ?? [ + { + grade: '', + qty: 0, + }, + ], +}); diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index 42a02c09..55272533 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -86,9 +86,8 @@ export type CreateGrowingRecordingPayload = { }; export type CreateGradingPayload = { - recording_id: number; - grading: { - product_warehouse_id: number; + recording_egg_id: number; + eggs_grading: { grade: string; qty: number; }[]; @@ -110,5 +109,6 @@ export type CreateRecordingPayload = export type UpdateGrowingRecordingPayload = CreateGrowingRecordingPayload; export type UpdateLayingRecordingPayload = CreateLayingRecordingPayload; +export type UpdateGradingRecordingPayload = CreateGradingRecordingPayload; export type UpdateRecordingPayload = CreateRecordingPayload; From 4a1f775c8544ca5a42d4d56f69631f9484bc7097 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 15:15:32 +0700 Subject: [PATCH 066/174] feat(FE-170): update daily recording form to redirect to grading form after successful submission --- .../pages/production/recording/form/RecordingForm.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index fe1183c7..f024c3bf 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1089,7 +1089,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='button' color='primary' onClick={() => { - toast.success('Akan dialihkan ke form Grading'); + router.push(`/production/recording/grading/add?recording_id=${initialValues?.id}`); }} > Lanjut ke Grading @@ -2192,7 +2192,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { hasExceededStock || !formik.isValid || formik.isSubmitting } onClick={async () => { - await formik.submitForm(); + const result = await formik.submitForm(); if ( formik.isValid && !formik.isSubmitting && @@ -2201,8 +2201,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { toast.success( 'Recording berhasil disimpan! Mengalihkan ke form Grading...' ); - // TODO: Redirect ke grading form setelah submit berhasil - // router.push('/production/grading/add?recording_id=xxx'); + // Redirect ke grading form setelah submit berhasil + setTimeout(() => { + router.push('/production/recording/grading/add?recording_id=new'); + }, 1000); } }} > From 01db13ed6c4d207eb5474ed06bdda3641db71152 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 15:15:48 +0700 Subject: [PATCH 067/174] feat(FE-170,174): add GradingForm component for managing grading records --- .../production/recording/grading/add/page.tsx | 44 + .../recording/grading/detail/edit/page.tsx | 52 ++ .../recording/grading/detail/layout.tsx | 11 + .../recording/grading/detail/page.tsx | 51 ++ .../recording/grading/form/GradingForm.tsx | 780 ++++++++++++++++++ 5 files changed, 938 insertions(+) create mode 100644 src/app/production/recording/grading/add/page.tsx create mode 100644 src/app/production/recording/grading/detail/edit/page.tsx create mode 100644 src/app/production/recording/grading/detail/layout.tsx create mode 100644 src/app/production/recording/grading/detail/page.tsx create mode 100644 src/components/pages/production/recording/grading/form/GradingForm.tsx diff --git a/src/app/production/recording/grading/add/page.tsx b/src/app/production/recording/grading/add/page.tsx new file mode 100644 index 00000000..c09a816b --- /dev/null +++ b/src/app/production/recording/grading/add/page.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const AddGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recording_id'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId && recordingId !== 'new' ? [recordingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if ( + recordingId && + recordingId !== 'new' && + !isLoadingRecording && + (!recording || !isResponseSuccess(recording)) + ) { + router.replace('/404'); + return; + } + + return ( +
+ {recordingId && recordingId !== 'new' && isLoadingRecording && ( + + )} + {(!recordingId || + recordingId === 'new' || + (!isLoadingRecording && recording && isResponseSuccess(recording))) && ( + + )} +
+ ); +}; + +export default AddGrading; diff --git a/src/app/production/recording/grading/detail/edit/page.tsx b/src/app/production/recording/grading/detail/edit/page.tsx new file mode 100644 index 00000000..bab82777 --- /dev/null +++ b/src/app/production/recording/grading/detail/edit/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const EditGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + const gradingId = searchParams.get('gradingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId ? [recordingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && recording && isResponseSuccess(recording) && ( + egg.id === parseInt(gradingId || '0'))} + recordingData={recording.data} + /> + )} +
+ ); +}; + +export default EditGrading; diff --git a/src/app/production/recording/grading/detail/layout.tsx b/src/app/production/recording/grading/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/recording/grading/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/recording/grading/detail/page.tsx b/src/app/production/recording/grading/detail/page.tsx new file mode 100644 index 00000000..05901dec --- /dev/null +++ b/src/app/production/recording/grading/detail/page.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const DetailGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const gradingId = searchParams.get('gradingId'); + + const { data: grading, isLoading: isLoadingGrading } = useSWR( + gradingId ? [gradingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if (!gradingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingGrading && ( + + )} + {!isLoadingGrading && grading && isResponseSuccess(grading) && ( + egg.id === parseInt(gradingId))} + recordingData={grading.data} + /> + )} +
+ ); +}; + +export default DetailGrading; \ No newline at end of file diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx new file mode 100644 index 00000000..2cad7bdb --- /dev/null +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -0,0 +1,780 @@ +'use client'; + +import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useFormik } from 'formik'; +import useSWR from 'swr'; +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; + +import NumberInput from '@/components/input/NumberInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import CheckboxInput from '@/components/input/CheckboxInput'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import { RecordingApi } from '@/services/api/production'; +import { + CreateGradingPayload, + Recording, + RecordingEgg, + GradingEgg, +} from '@/types/api/production/recording'; +import { type BaseApiResponse, FormStepStatus } from '@/types/api/api-general'; +import { + RecordingGradingFormSchema, + RecordingGradingFormValues, + UpdateRecordingGradingFormSchema, + getRecordingGradingFormInitialValues, +} from '../../form/RecordingForm.schema'; +import { useRecordingFormHandlers } from '../../form/useRecordingFormHandlers'; +import { ProductWarehouseApi } from '@/services/api/inventory'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn } from '@/lib/helper'; +import { useModal } from '@/components/Modal'; +import toast from 'react-hot-toast'; + +import Card from '@/components/Card'; +import Badge from '@/components/Badge'; +import StepItem from '@/components/steps/StepItem'; + +interface GradingFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: RecordingEgg & { grading_eggs?: GradingEgg[] }; + recordingData?: Recording; +} + +const GradingForm = ({ + type = 'add', + initialValues, + recordingData, +}: GradingFormProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const recordingId = searchParams.get('recording_id'); + const [selectedGradingItems, setSelectedGradingItems] = useState( + [] + ); + + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + const [formSteps, setFormSteps] = useState(null); + + const approveModal = useModal(); + const rejectModal = useModal(); + + // ===== API DATA FETCHING ===== + const eggProductsUrl = useMemo(() => { + if (!recordingData?.project_flock_kandangs_id) return null; + const params = new URLSearchParams({ + search: '', + location_id: recordingData.project_flock_kandangs_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [recordingData]); + + const { data: eggProductsData, isLoading: isLoadingEggProducts } = useSWR( + eggProductsUrl, + ProductWarehouseApi.getAllFetcher + ); + + // ===== DATA PROCESSING ===== + 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, + recordingFormErrorMessage, + isDeleteLoading, + createRecordingHandler, + updateRecordingHandler, + deleteRecordingClickHandler, + confirmationModalDeleteClickHandler, + } = useRecordingFormHandlers(initialValues?.id); + + const formikInitialValues = useMemo(() => { + return getRecordingGradingFormInitialValues({ + recording_egg_id: initialValues?.id || parseInt(recordingId || '0') || 0, + eggs_grading: + initialValues?.grading_eggs?.map((grading: GradingEgg) => ({ + grade: grading.grade, + qty: grading.qty, + })) || [], + }); + }, [initialValues, recordingId]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: (() => { + return type === 'edit' + ? UpdateRecordingGradingFormSchema + : RecordingGradingFormSchema; + })(), + validateOnChange: true, + validateOnBlur: true, + onSubmit: async (values) => { + const gradingPayload = { + recording_egg_id: values.recording_egg_id, + eggs_grading: (values.eggs_grading ?? []).map((grading) => ({ + grade: grading.grade, + qty: grading.qty || 0, + })), + }; + + switch (type) { + case 'add': + await createRecordingHandler(gradingPayload as CreateGradingPayload); + break; + case 'edit': + await updateRecordingHandler( + initialValues?.id as number, + gradingPayload as CreateGradingPayload + ); + break; + } + }, + }); + + // ===== EVENT HANDLERS ===== + const approveHandler = async () => { + setIsApproveLoading(true); + + const approveResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'APPROVED', + approvable_ids: [initialValues?.id as number], + notes: 'Approved via Grading Form', + }, + }); + + if (isResponseSuccess(approveResponse)) { + toast.success('Grading berhasil disetujui!'); + approveModal.closeModal(); + router.push('/production/recording'); + } else { + toast.error( + (approveResponse?.message as string) || 'Gagal menyetujui grading' + ); + approveModal.closeModal(); + } + + setIsApproveLoading(false); + }; + + const rejectHandler = async () => { + setIsRejectLoading(true); + + const rejectResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'REJECTED', + approvable_ids: [initialValues?.id as number], + notes: 'Rejected via Grading Form', + }, + }); + + if (isResponseSuccess(rejectResponse)) { + toast.success('Grading berhasil ditolak!'); + rejectModal.closeModal(); + router.push('/production/recording'); + } else { + toast.error( + (rejectResponse?.message as string) || 'Gagal menolak grading' + ); + rejectModal.closeModal(); + } + + setIsRejectLoading(false); + }; + + // Grading Handlers + const addGrading = () => { + const newGrading = [ + ...(formik.values.eggs_grading || []), + { + grade: '', + qty: 0, + }, + ]; + formik.setFieldValue('eggs_grading', newGrading); + }; + + const handleGradingGradeChangeWrapper = useCallback( + (idx: number) => (selectedOption: OptionType | OptionType[] | null) => { + const option = selectedOption as OptionType | null; + formik.setFieldValue(`eggs_grading.${idx}.grade`, option?.label || ''); + }, + [formik] + ); + + const handleGradingQtyChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0; + formik.setFieldValue(`eggs_grading.${idx}.qty`, value); + }, + [formik] + ); + + const removeGrading = (idx: number) => { + const updatedGrading = formik.values.eggs_grading?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('eggs_grading', updatedGrading); + }; + + const removeSelectedGrading = () => { + const updatedGrading = formik.values.eggs_grading?.filter( + (_, idx) => !selectedGradingItems.includes(idx) + ); + formik.setFieldValue('eggs_grading', updatedGrading); + setSelectedGradingItems([]); + }; + + const isRepeaterInputError = ( + arrayName: 'eggs_grading', + column: string, + idx: number + ) => { + const touched = formik.touched as Record; + const errors = formik.errors as Record; + + if (!touched[arrayName] || !Array.isArray(touched[arrayName])) { + return { + isError: false, + errorMessage: '', + }; + } + + const touchedField = (touched[arrayName] as unknown[])?.[idx] as Record< + string, + unknown + >; + const errorField = (errors[arrayName] as unknown[])?.[idx] as Record< + string, + unknown + >; + + return { + isError: touchedField && Boolean(errorField?.[column]), + errorMessage: + touchedField && errorField?.[column] + ? (errorField[column] as string) + : '', + }; + }; + + // ===== EFFECTS ===== + useEffect(() => { + const steps: FormStepStatus[] = [ + { + name: 'Recording', + isCompleted: true, + isCurrent: false, + }, + { + name: 'Grading', + isCompleted: false, + isCurrent: true, + }, + ]; + setFormSteps(steps); + }, []); + + useEffect(() => { + if (formik.values.eggs_grading && formik.values.eggs_grading.length === 0) { + formik.setFieldValue('eggs_grading', [{ grade: '', qty: 0 }]); + } + }, [formik]); + + return ( + <> +
+ + + {/* Project Flock Info Card */} +
+ {/* Form Steps */} + {formSteps && ( +
+
+
    + {formSteps.map((step, idx) => ( + 0 ? 'step-primary' : undefined + } + icon={ + step.isCompleted ? ( + + ) : ( + idx + 1 + ) + } + > + {step.name} + + ))} +
+
+ + {/* Action buttons for multi-form navigation */} + {type === 'detail' && ( +
+ +
+ )} +
+ )} +
+ + + {/* Basic Info Card */} + +
+ {type === 'detail' && recordingData ? ( + <> + Recording ID#{recordingData.id} + + ) : ( + <> + Recording Egg ID#{formik.values.recording_egg_id} + + )} +
+
+ + {/* Grading Table */} + +
+ + + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && } + + + + {formik.values.eggs_grading?.map((grading, idx) => ( + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedGradingItems( + formik.values.eggs_grading?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedGradingItems([]); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Grade + + * + + + Jumlah + + * + + Action
+ + ) => { + if (e.target.checked) { + setSelectedGradingItems([ + ...selectedGradingItems, + idx, + ]); + } else { + setSelectedGradingItems( + selectedGradingItems.filter((i) => i !== idx) + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + + + + +
+ +
+
+
+ {type !== 'detail' && ( +
+ {selectedGradingItems.length > 0 && ( + + )} + +
+ )} +
+ + {/* Action buttons */} +
+ {type !== 'add' && ( +
+ {deleteRecordingClickHandler && ( + + )} + {type !== 'edit' && initialValues && ( + + )} + {type === 'detail' && ( + <> + + + + )} +
+ )} + {type !== 'detail' && ( +
+ + +
+ )} +
+ {recordingFormErrorMessage && ( +
+ + {recordingFormErrorMessage} +
+ )} + +
+ + {type !== 'add' && ( + <> + + + {/* Approve Confirmation Modal */} + {type === 'detail' && ( + + )} + + {/* Reject Confirmation Modal */} + {type === 'detail' && ( + + )} + + )} + + ); +}; + +export default GradingForm; From 9495742cb718d384eca7526e0c07fa14385215d6 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 17:26:24 +0700 Subject: [PATCH 068/174] feat(FE-174): add grading form handlers for creating, updating, and deleting grading records --- .../grading/form/useGradingFormHandlers.ts | 88 +++++++++++++++++++ src/types/api/production/recording.d.ts | 10 +++ 2 files changed, 98 insertions(+) create mode 100644 src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts diff --git a/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts b/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts new file mode 100644 index 00000000..c24a644b --- /dev/null +++ b/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts @@ -0,0 +1,88 @@ +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'react-hot-toast'; +import { useModal } from '@/components/Modal'; +import { RecordingApi } from '@/services/api/production'; +import { + CreateGradingPayload, + UpdateGradingPayload, +} from '@/types/api/production/recording'; +import { isResponseError } from '@/lib/api-helper'; +import { type BaseApiResponse } from '@/types/api/api-general'; + +export const useGradingFormHandlers = (gradingId?: number) => { + const router = useRouter(); + const deleteModal = useModal(); + const [recordingFormErrorMessage, setRecordingFormErrorMessage] = + useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createGradingHandler = useCallback( + async (payload: CreateGradingPayload) => { + const res = await RecordingApi.customRequest('gradings', { + method: 'POST', + payload, + }) as BaseApiResponse; + if (isResponseError(res)) { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message || 'Successfully added Grading!'); + router.push('/production/recording'); + }, + [router] + ); + + const updateGradingHandler = useCallback( + async (gradingId: number, payload: UpdateGradingPayload) => { + const res = await RecordingApi.customRequest(`gradings/${gradingId}`, { + method: 'PUT', + payload, + }) as BaseApiResponse; + if (isResponseError(res)) { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message || 'Successfully updated Grading!'); + router.refresh(); + router.push('/production/recording'); + }, + [router] + ); + + const deleteRecordingClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!gradingId) return; + + setIsDeleteLoading(true); + try { + const res = await RecordingApi.customRequest(`gradings/${gradingId}`, { + method: 'DELETE', + }) as BaseApiResponse; + if (isResponseError(res)) { + setRecordingFormErrorMessage(res.message); + return; + } + deleteModal.closeModal(); + toast.success(res?.message || 'Successfully delete Grading!'); + router.push('/production/recording'); + } catch (error) { + setRecordingFormErrorMessage('Failed to delete Grading'); + } finally { + setIsDeleteLoading(false); + } + }, [deleteModal, gradingId, router]); + + return { + deleteModal, + recordingFormErrorMessage, + isDeleteLoading, + createGradingHandler, + updateGradingHandler, + deleteRecordingClickHandler, + confirmationModalDeleteClickHandler, + }; +}; \ No newline at end of file diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index 55272533..171ba09e 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -93,6 +93,16 @@ export type CreateGradingPayload = { }[]; }; +export type UpdateGradingPayload = CreateGradingPayload; + +export type CreateGradingRecordingPayload = { + recording_egg_id: number; + eggs_grading: { + grade: string; + qty: number; + }[]; +}; + export type CreateEggPayload = { product_warehouse_id: number; qty: number; From 19afb80597ef928033ce25ab4f329ef013bbde70 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 17:26:56 +0700 Subject: [PATCH 069/174] feat(FE-170,174): refactor GradingForm to use grading form handlers and remove approval logic --- .../production/recording/grading/add/page.tsx | 2 +- .../recording/grading/detail/edit/page.tsx | 5 +- .../recording/grading/detail/page.tsx | 7 +- .../recording/grading/{detail => }/layout.tsx | 0 .../recording/grading/form/GradingForm.tsx | 220 +++--------------- 5 files changed, 43 insertions(+), 191 deletions(-) rename src/app/production/recording/grading/{detail => }/layout.tsx (100%) diff --git a/src/app/production/recording/grading/add/page.tsx b/src/app/production/recording/grading/add/page.tsx index c09a816b..2c1859cb 100644 --- a/src/app/production/recording/grading/add/page.tsx +++ b/src/app/production/recording/grading/add/page.tsx @@ -35,7 +35,7 @@ const AddGrading = () => { {(!recordingId || recordingId === 'new' || (!isLoadingRecording && recording && isResponseSuccess(recording))) && ( - + )}
); diff --git a/src/app/production/recording/grading/detail/edit/page.tsx b/src/app/production/recording/grading/detail/edit/page.tsx index bab82777..4403e04a 100644 --- a/src/app/production/recording/grading/detail/edit/page.tsx +++ b/src/app/production/recording/grading/detail/edit/page.tsx @@ -41,8 +41,9 @@ const EditGrading = () => { {!isLoadingRecording && recording && isResponseSuccess(recording) && ( egg.id === parseInt(gradingId || '0'))} - recordingData={recording.data} + initialValues={recording.data.recording_eggs?.find( + (egg) => egg.id === parseInt(gradingId || '0') + )} /> )}
diff --git a/src/app/production/recording/grading/detail/page.tsx b/src/app/production/recording/grading/detail/page.tsx index 05901dec..afe1a5bc 100644 --- a/src/app/production/recording/grading/detail/page.tsx +++ b/src/app/production/recording/grading/detail/page.tsx @@ -40,12 +40,13 @@ const DetailGrading = () => { {!isLoadingGrading && grading && isResponseSuccess(grading) && ( egg.id === parseInt(gradingId))} - recordingData={grading.data} + initialValues={grading.data.recording_eggs?.find( + (egg) => egg.id === parseInt(gradingId) + )} /> )}
); }; -export default DetailGrading; \ No newline at end of file +export default DetailGrading; diff --git a/src/app/production/recording/grading/detail/layout.tsx b/src/app/production/recording/grading/layout.tsx similarity index 100% rename from src/app/production/recording/grading/detail/layout.tsx rename to src/app/production/recording/grading/layout.tsx diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx index 2cad7bdb..d07e61b1 100644 --- a/src/components/pages/production/recording/grading/form/GradingForm.tsx +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -15,39 +15,31 @@ import { FormHeader } from '@/components/helper/form/FormHeader'; import { RecordingApi } from '@/services/api/production'; import { CreateGradingPayload, - Recording, + UpdateGradingPayload, RecordingEgg, GradingEgg, } from '@/types/api/production/recording'; -import { type BaseApiResponse, FormStepStatus } from '@/types/api/api-general'; +import { type FormStepStatus } from '@/types/api/api-general'; import { RecordingGradingFormSchema, RecordingGradingFormValues, UpdateRecordingGradingFormSchema, getRecordingGradingFormInitialValues, } from '../../form/RecordingForm.schema'; -import { useRecordingFormHandlers } from '../../form/useRecordingFormHandlers'; -import { ProductWarehouseApi } from '@/services/api/inventory'; -import { isResponseSuccess } from '@/lib/api-helper'; import { cn } from '@/lib/helper'; -import { useModal } from '@/components/Modal'; import toast from 'react-hot-toast'; import Card from '@/components/Card'; -import Badge from '@/components/Badge'; import StepItem from '@/components/steps/StepItem'; +import { useModal } from '@/components/Modal'; +import { useGradingFormHandlers } from './useGradingFormHandlers'; interface GradingFormProps { type?: 'add' | 'edit' | 'detail'; initialValues?: RecordingEgg & { grading_eggs?: GradingEgg[] }; - recordingData?: Recording; } -const GradingForm = ({ - type = 'add', - initialValues, - recordingData, -}: GradingFormProps) => { +const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { const router = useRouter(); const searchParams = useSearchParams(); const recordingId = searchParams.get('recording_id'); @@ -55,64 +47,45 @@ const GradingForm = ({ [] ); - const [isApproveLoading, setIsApproveLoading] = useState(false); - const [isRejectLoading, setIsRejectLoading] = useState(false); const [formSteps, setFormSteps] = useState(null); - const approveModal = useModal(); - const rejectModal = useModal(); - // ===== API DATA FETCHING ===== - const eggProductsUrl = useMemo(() => { - if (!recordingData?.project_flock_kandangs_id) return null; - const params = new URLSearchParams({ - search: '', - location_id: recordingData.project_flock_kandangs_id.toString(), - }); - return `${ProductWarehouseApi.basePath}?${params.toString()}`; - }, [recordingData]); + // Fetch existing gradings for the recording egg to show recorded data + // Optional: Fetch existing gradings if needed for display purposes + // const existingGradingsUrl = useMemo(() => { + // const recordingEggIdToUse = recordingId || initialValues?.id?.toString(); + // if (!recordingEggIdToUse) return null; + // return `${RecordingApi.basePath}/gradings?recording_egg_id=${recordingEggIdToUse}`; + // }, [recordingId, initialValues]); - const { data: eggProductsData, isLoading: isLoadingEggProducts } = useSWR( - eggProductsUrl, - ProductWarehouseApi.getAllFetcher - ); + // const { data: existingGradings } = useSWR( + // existingGradingsUrl, + // existingGradingsUrl ? RecordingApi.getAllFetcher : null + // ); // ===== DATA PROCESSING ===== - 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]); + // No data processing needed - grading form only needs recording_egg_id and grading data // ===== FORM HANDLERS ===== const { deleteModal, recordingFormErrorMessage, isDeleteLoading, - createRecordingHandler, - updateRecordingHandler, + createGradingHandler, + updateGradingHandler, deleteRecordingClickHandler, confirmationModalDeleteClickHandler, - } = useRecordingFormHandlers(initialValues?.id); + } = useGradingFormHandlers(initialValues?.id); const formikInitialValues = useMemo(() => { + let recordingEggId: number | undefined = initialValues?.id; + + if (!recordingEggId) { + recordingEggId = parseInt(recordingId || '0') || 0; + } + return getRecordingGradingFormInitialValues({ - recording_egg_id: initialValues?.id || parseInt(recordingId || '0') || 0, + recording_egg_id: recordingEggId, eggs_grading: initialValues?.grading_eggs?.map((grading: GradingEgg) => ({ grade: grading.grade, @@ -141,74 +114,19 @@ const GradingForm = ({ switch (type) { case 'add': - await createRecordingHandler(gradingPayload as CreateGradingPayload); + await createGradingHandler(gradingPayload as CreateGradingPayload); break; case 'edit': - await updateRecordingHandler( + await updateGradingHandler( initialValues?.id as number, - gradingPayload as CreateGradingPayload + gradingPayload as UpdateGradingPayload ); break; } }, }); - // ===== EVENT HANDLERS ===== - const approveHandler = async () => { - setIsApproveLoading(true); - - const approveResponse = await RecordingApi.customRequest< - BaseApiResponse - >('approvals', { - method: 'POST', - payload: { - action: 'APPROVED', - approvable_ids: [initialValues?.id as number], - notes: 'Approved via Grading Form', - }, - }); - - if (isResponseSuccess(approveResponse)) { - toast.success('Grading berhasil disetujui!'); - approveModal.closeModal(); - router.push('/production/recording'); - } else { - toast.error( - (approveResponse?.message as string) || 'Gagal menyetujui grading' - ); - approveModal.closeModal(); - } - - setIsApproveLoading(false); - }; - - const rejectHandler = async () => { - setIsRejectLoading(true); - - const rejectResponse = await RecordingApi.customRequest< - BaseApiResponse - >('approvals', { - method: 'POST', - payload: { - action: 'REJECTED', - approvable_ids: [initialValues?.id as number], - notes: 'Rejected via Grading Form', - }, - }); - - if (isResponseSuccess(rejectResponse)) { - toast.success('Grading berhasil ditolak!'); - rejectModal.closeModal(); - router.push('/production/recording'); - } else { - toast.error( - (rejectResponse?.message as string) || 'Gagal menolak grading' - ); - rejectModal.closeModal(); - } - - setIsRejectLoading(false); - }; + // Grading form doesn't need approval/reject handlers // Grading Handlers const addGrading = () => { @@ -309,6 +227,8 @@ const GradingForm = ({ } }, [formik]); + // Grading form doesn't need loading state since it only depends on recording_egg_id + return ( <>
@@ -394,9 +314,9 @@ const GradingForm = ({ : 'grid grid-cols-3 gap-4' } > - {type === 'detail' && recordingData ? ( + {type === 'detail' && initialValues ? ( <> - Recording ID#{recordingData.id} + Recording Egg ID#{initialValues.id} ) : ( <> @@ -641,40 +561,6 @@ const GradingForm = ({ Edit )} - {type === 'detail' && ( - <> - - - - )} )} {type !== 'detail' && ( @@ -735,42 +621,6 @@ const GradingForm = ({ onClick: confirmationModalDeleteClickHandler, }} /> - - {/* Approve Confirmation Modal */} - {type === 'detail' && ( - - )} - - {/* Reject Confirmation Modal */} - {type === 'detail' && ( - - )} )} From 1228b4504511d74636aa2c48024d9699a78d80aa Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 17:27:10 +0700 Subject: [PATCH 070/174] feat(FE-170,174): clean up RecordingForm and RecordingTable components for improved readability and maintainability --- .../production/recording/RecordingTable.tsx | 142 ++++++++++++------ .../recording/form/RecordingForm.tsx | 20 +-- 2 files changed, 105 insertions(+), 57 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 413408ab..5e018e61 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -112,7 +112,9 @@ const RecordingTable = () => { const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); - const [selectedRecording, setSelectedRecording] = useState(undefined); + const [selectedRecording, setSelectedRecording] = useState< + Recording | undefined + >(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false); const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); @@ -127,8 +129,12 @@ const RecordingTable = () => { const [kandangSelectInputValue, setKandangSelectInputValue] = useState(''); const [selectedArea, setSelectedArea] = useState(null); - const [selectedLocation, setSelectedLocation] = useState(null); - const [selectedKandang, setSelectedKandang] = useState(null); + const [selectedLocation, setSelectedLocation] = useState( + null + ); + const [selectedKandang, setSelectedKandang] = useState( + null + ); const { data: recordings, @@ -144,20 +150,20 @@ const RecordingTable = () => { search: areaSelectInputValue, limit: '100', }).toString()}`; - const { - data: areas, - isLoading: isLoadingAreas, - } = useSWR(areaUrl, AreaApi.getAllFetcher); + const { data: areas, isLoading: isLoadingAreas } = useSWR( + areaUrl, + AreaApi.getAllFetcher + ); const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue, area_id: selectedArea != null ? selectedArea.value.toString() : '', limit: '100', }).toString()}`; - const { - data: locations, - isLoading: isLoadingLocations, - } = useSWR(locationUrl, LocationApi.getAllFetcher); + const { data: locations, isLoading: isLoadingLocations } = useSWR( + locationUrl, + LocationApi.getAllFetcher + ); const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ search: kandangSelectInputValue, @@ -165,10 +171,10 @@ const RecordingTable = () => { selectedLocation != null ? selectedLocation.value.toString() : '', limit: '100', }).toString()}`; - const { - data: kandangs, - isLoading: isLoadingKandang, - } = useSWR(kandangUrl, KandangApi.getAllFetcher); + const { data: kandangs, isLoading: isLoadingKandang } = useSWR( + kandangUrl, + KandangApi.getAllFetcher + ); // Data to Options Mapping const optionsArea = isResponseSuccess(areas) @@ -213,7 +219,9 @@ const RecordingTable = () => { return recordings.data; }, [recordings]); - const selectedRowIds = Object.keys(rowSelection).map((item) => parseInt(item)); + const selectedRowIds = Object.keys(rowSelection).map((item) => + parseInt(item) + ); const bulkApproveHandler = async () => { setIsBulkApproveLoading(true); @@ -233,7 +241,9 @@ const RecordingTable = () => { await refreshRecordings(); setRowSelection({}); bulkApproveModal.closeModal(); - toast.success(`Successfully approved ${selectedRowIds.length} recordings!`); + toast.success( + `Successfully approved ${selectedRowIds.length} recordings!` + ); } if (isResponseError(approveResponse)) { toast.error(approveResponse?.message as string); @@ -260,7 +270,9 @@ const RecordingTable = () => { refreshRecordings(); setRowSelection({}); bulkRejectModal.closeModal(); - toast.success(`Successfully rejected ${selectedRowIds.length} recordings!`); + toast.success( + `Successfully rejected ${selectedRowIds.length} recordings!` + ); } if (isResponseError(rejectResponse)) { toast.error(rejectResponse?.message as string); @@ -312,7 +324,10 @@ const RecordingTable = () => { setSelectedArea(selectedValue); setSelectedLocation(null); setSelectedKandang(null); - updateFilter('areaFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter( + 'areaFilter', + selectedValue ? selectedValue.value.toString() : '' + ); updateFilter('locationFilter', ''); updateFilter('kandangFilter', ''); setPage(1); @@ -332,7 +347,10 @@ const RecordingTable = () => { const selectedValue = selected as OptionType | null; setSelectedLocation(selectedValue); setSelectedKandang(null); - updateFilter('locationFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter( + 'locationFilter', + selectedValue ? selectedValue.value.toString() : '' + ); updateFilter('kandangFilter', ''); setPage(1); }} @@ -351,7 +369,10 @@ const RecordingTable = () => { onChange={(selected) => { const selectedValue = selected as OptionType | null; setSelectedKandang(selectedValue); - updateFilter('kandangFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter( + 'kandangFilter', + selectedValue ? selectedValue.value.toString() : '' + ); setPage(1); }} className={{ wrapper: 'w-full' }} @@ -371,12 +392,18 @@ const RecordingTable = () => { ]} value={ tableFilterState.periodFilter - ? { value: tableFilterState.periodFilter, label: `Periode ${tableFilterState.periodFilter}` } + ? { + value: tableFilterState.periodFilter, + label: `Periode ${tableFilterState.periodFilter}`, + } : null } onChange={(selected) => { const selectedValue = selected as OptionType | null; - updateFilter('periodFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter( + 'periodFilter', + selectedValue ? selectedValue.value.toString() : '' + ); setPage(1); }} className={{ wrapper: 'w-full' }} @@ -396,7 +423,10 @@ const RecordingTable = () => { setSelectedArea(selectedValue); setSelectedLocation(null); setSelectedKandang(null); - updateFilter('areaFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter( + 'areaFilter', + selectedValue ? selectedValue.value.toString() : '' + ); updateFilter('locationFilter', ''); updateFilter('kandangFilter', ''); setPage(1); @@ -416,7 +446,10 @@ const RecordingTable = () => { const selectedValue = selected as OptionType | null; setSelectedLocation(selectedValue); setSelectedKandang(null); - updateFilter('locationFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter( + 'locationFilter', + selectedValue ? selectedValue.value.toString() : '' + ); updateFilter('kandangFilter', ''); setPage(1); }} @@ -435,7 +468,10 @@ const RecordingTable = () => { onChange={(selected) => { const selectedValue = selected as OptionType | null; setSelectedKandang(selectedValue); - updateFilter('kandangFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter( + 'kandangFilter', + selectedValue ? selectedValue.value.toString() : '' + ); setPage(1); }} className={{ wrapper: 'w-full' }} @@ -455,12 +491,18 @@ const RecordingTable = () => { ]} value={ tableFilterState.periodFilter - ? { value: tableFilterState.periodFilter, label: `Periode ${tableFilterState.periodFilter}` } + ? { + value: tableFilterState.periodFilter, + label: `Periode ${tableFilterState.periodFilter}`, + } : null } onChange={(selected) => { const selectedValue = selected as OptionType | null; - updateFilter('periodFilter', selectedValue ? selectedValue.value.toString() : ''); + updateFilter( + 'periodFilter', + selectedValue ? selectedValue.value.toString() : '' + ); setPage(1); }} className={{ wrapper: 'w-full' }} @@ -562,11 +604,15 @@ const RecordingTable = () => { }, { header: '#', - cell: (props) => tableFilterState.pageSize * (tableFilterState.page - 1) + props.row.index + 1, + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, }, { header: 'Nama Project', - cell: (props) => `Project ${props.row.original.project_flock_kandang_id}`, + cell: (props) => + `Project ${props.row.original.project_flock_kandangs_id}`, }, { header: 'Umur (hari)', @@ -576,19 +622,22 @@ const RecordingTable = () => { accessorKey: 'record_date', header: 'Waktu Recording', cell: (props) => - new Date(props.row.original.record_date).toLocaleDateString(), + new Date(props.row.original.record_datetime).toLocaleDateString(), }, { header: 'Populasi Awal', - cell: (props) => props.row.original.total_chick?.toLocaleString() || '-', + cell: (props) => + props.row.original.total_chick_qty?.toLocaleString() || '-', }, { header: 'BW', - cell: (props) => props.row.original.avg_daily_gain?.toFixed(2) || '-', + cell: (props) => + props.row.original.avg_daily_gain?.toFixed(2) || '-', }, { header: 'Pakan', - cell: (props) => props.row.original.cum_intake?.toLocaleString() || '-', + cell: (props) => + props.row.original.cum_intake?.toLocaleString() || '-', }, { header: 'FCR', @@ -597,19 +646,20 @@ const RecordingTable = () => { { accessorKey: 'total_depletion', header: 'Total Deplesi', - cell: (props) => props.row.original.total_depletion, + cell: (props) => props.row.original.total_depletion_qty, }, { header: 'Deplesi (%)', - cell: (props) => props.row.original.daily_depletion_rate?.toFixed(2) || '-', + cell: (props) => + props.row.original.daily_depletion_rate?.toFixed(2) || '-', }, { header: 'Populasi Akhir', - cell: (props) => (props.row.original.total_chick - props.row.original.total_depletion)?.toLocaleString() || '-', - }, - { - header: 'Ketepatan Waktu', - cell: (props) => props.row.original.ontime ? 'Tepat Waktu' : 'Terlambat', + cell: (props) => + ( + props.row.original.total_chick_qty - + props.row.original.total_depletion_qty + )?.toLocaleString() || '-', }, { header: 'Tanggal Submit', @@ -660,8 +710,14 @@ const RecordingTable = () => { }, ]} pageSize={tableFilterState.pageSize} - page={recordings?.status === 'success' ? recordings.meta?.page : tableFilterState.page} - totalItems={recordings?.status === 'success' ? recordings.meta?.total_results : 0} + page={ + recordings?.status === 'success' + ? recordings.meta?.page + : tableFilterState.page + } + totalItems={ + recordings?.status === 'success' ? recordings.meta?.total_results : 0 + } onPageChange={setPage} isLoading={isLoading} sorting={sorting} diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index f024c3bf..5dfddbdd 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -43,7 +43,6 @@ import toast from 'react-hot-toast'; import Card from '@/components/Card'; import Badge from '@/components/Badge'; -import Steps from '@/components/steps/Steps'; import StepItem from '@/components/steps/StepItem'; import { Kandang } from '@/types/api/master-data/kandang'; @@ -1038,17 +1037,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {/* Project Flock Info Card */} {projectFlockKandangLookup && (
- - {projectFlockKandangLookup.project_flock.category} - - {/* Form Steps for LAYING Category */} {formSteps && (
@@ -1089,7 +1077,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='button' color='primary' onClick={() => { - router.push(`/production/recording/grading/add?recording_id=${initialValues?.id}`); + router.push( + `/production/recording/grading/add?recording_id=${initialValues?.id}` + ); }} > Lanjut ke Grading @@ -2203,7 +2193,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); // Redirect ke grading form setelah submit berhasil setTimeout(() => { - router.push('/production/recording/grading/add?recording_id=new'); + router.push( + '/production/recording/grading/add?recording_id=new' + ); }, 1000); } }} From 09cb5f10aa5454cd6b8576bb359cb3b0ec49afe9 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 1 Nov 2025 08:35:46 +0700 Subject: [PATCH 071/174] refactor(FE-Storyless): replace FormHeader and FormActions with custom header and action buttons for improved UI --- .../inventory/movement/form/MovementForm.tsx | 86 ++++++++++++++++--- 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 883572e0..02b341e2 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -9,8 +9,6 @@ import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; import NumberInput from '@/components/input/NumberInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; -import { FormHeader } from '@/components/helper/form/FormHeader'; -import { FormActions } from '@/components/helper/form/FormActions'; import { CreateMovementPayload, Movement, @@ -730,11 +728,21 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return ( <>
- +
+ +

+ {type === 'add' && 'Tambah Movement'} + {type === 'edit' && 'Edit Movement'} + {type === 'detail' && 'Detail Movement'} +

+
{
{/* Action buttons */} - - type={type} - formik={formik} - disableSubmit={hasInvalidQty || hasExceededStock} - /> +
+ {type !== 'add' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
{movementFormErrorMessage && (
From ee4a470fd22bd41e34ea5239f3c55303f800dfda Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 1 Nov 2025 09:46:06 +0700 Subject: [PATCH 072/174] refactor(FE-Storyless): add product and warehouse filters with select inputs --- .../inventory/movement/MovementTable.tsx | 414 ++++++++++-------- 1 file changed, 242 insertions(+), 172 deletions(-) diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index 61be40f8..0da23826 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -1,24 +1,56 @@ 'use client'; -import { useState } from 'react'; +import { ChangeEventHandler, useState } from 'react'; import useSWR from 'swr'; -import { SortingState } from '@tanstack/react-table'; +import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import Table from '@/components/Table'; -import { useModal } from '@/components/Modal'; -import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { Icon } from '@iconify/react'; import { Movement } from '@/types/api/inventory/movement'; import { MovementApi } from '@/services/api/inventory'; import { cn } from '@/lib/helper'; +import { Product } from '@/types/api/master-data/product'; +import { Warehouse } from '@/types/api/master-data/warehouse'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ROWS_OPTIONS } from '@/config/constant'; -import { TableToolbar } from '@/components/table/TableToolbar'; -import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; -import { OptionType } from '@/components/input/SelectInput'; +import { OptionType, useSelect } from '@/components/input/SelectInput'; +import Button from '@/components/Button'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import SelectInput from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; -import { TableRowOptions } from '@/components/table/TableRowOptions'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; +}) => { + return ( +
+ +
+ ); +}; const MovementTable = () => { const { @@ -28,17 +60,39 @@ const MovementTable = () => { setPageSize, toQueryString: getTableFilterQueryString, } = useTableFilter({ - initial: { search: '' }, - paramMap: { page: 'page', pageSize: 'limit' }, + initial: { + search: '', + product: '', + warehouse: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + product: 'product_id', + warehouse: 'warehouse_id', + }, }); const [sorting, setSorting] = useState([]); - const [selectedMovement, setSelectedMovement] = useState< - Movement | undefined - >(undefined); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const deleteModal = useModal(); + const { + setInputValue: setProductInputValue, + options: productOptions, + isLoadingOptions: isLoadingProductOptions, + } = useSelect('/products', 'id', 'name'); + + const { + setInputValue: setWarehouseInputValue, + options: warehouseOptions, + isLoadingOptions: isLoadingWarehouseOptions, + } = useSelect('/warehouses', 'id', 'name'); + + const [selectedProduct, setSelectedProduct] = useState( + null + ); + const [selectedWarehouse, setSelectedWarehouse] = useState( + null + ); const { data: movements, @@ -49,9 +103,8 @@ const MovementTable = () => { MovementApi.getAllFetcher ); - const searchChangeHandler = (e: React.ChangeEvent) => { + const searchChangeHandler: ChangeEventHandler = (e) => { updateFilter('search', e.target.value); - setPage(1); }; const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { @@ -60,167 +113,184 @@ const MovementTable = () => { setPage(1); }; - const confirmationModalDeleteClickHandler = async () => { - setIsDeleteLoading(true); - try { - await MovementApi.delete(selectedMovement?.id as number); - refreshMovements(); - deleteModal.closeModal(); - } finally { - setIsDeleteLoading(false); - } + const productChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedProduct(val as OptionType); + updateFilter('product', val ? ((val as OptionType).value as string) : ''); }; + const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedWarehouse(val as OptionType); + updateFilter('warehouse', val ? ((val as OptionType).value as string) : ''); + }; + + const movementColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorFn: (row) => row.source_warehouse?.name, + header: 'Gudang Asal', + }, + { + accessorFn: (row) => row.destination_warehouse?.name, + header: 'Gudang Tujuan', + }, + { + accessorKey: 'transfer_reason', + header: 'Catatan', + }, + { + accessorKey: 'transfer_date', + header: 'Tanggal', + cell: (props) => + new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'), + }, + { + accessorFn: (row) => { + const totalCost = row.deliveries?.reduce( + (sum, d) => sum + (d.shipping_cost_total || 0), + 0 + ); + return totalCost?.toLocaleString('id-ID'); + }, + header: 'Biaya Pengiriman', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + return ( -
-
- +
+
+
+
+ +
+ + +
+ +
+ + + + + +
+
+ + + data={isResponseSuccess(movements) ? movements?.data : []} + columns={movementColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(movements) ? movements?.meta?.page : 0} + totalItems={ + isResponseSuccess(movements) ? movements?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(movements) && movements?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', }} - search={{ - value: tableFilterState.search, - onChange: searchChangeHandler, - placeholder: 'Cari Movement', - }} - /> -
- - - data={isResponseSuccess(movements) ? movements?.data : []} - columns={[ - { - header: '#', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, - }, - { - accessorFn: (row) => row.source_warehouse?.name, - header: 'Gudang Asal', - }, - { - accessorFn: (row) => row.destination_warehouse?.name, - header: 'Gudang Tujuan', - }, - { - accessorKey: 'transfer_reason', - header: 'Catatan', - }, - { - accessorKey: 'transfer_date', - header: 'Tanggal', - cell: (props) => - new Date(props.row.original.transfer_date).toLocaleDateString( - 'id-ID' - ), - }, - { - accessorFn: (row) => { - const totalCost = row.deliveries?.reduce( - (sum, d) => sum + (d.shipping_cost_total || 0), - 0 - ); - return totalCost?.toLocaleString('id-ID'); - }, - header: 'Biaya Pengiriman', - }, - { - header: 'Aksi', - cell: (props) => { - const currentPageSize = - props.table.getPaginationRowModel().rows.length; - const currentPageRows = - props.table.getPaginationRowModel().flatRows; - const currentRowRelativeIndex = - currentPageRows.findIndex((r) => r.id === props.row.id) + 1; - - const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; - - const deleteClickHandler = () => { - setSelectedMovement(props.row.original); - deleteModal.openModal(); - }; - - return ( - <> - {currentPageSize > 2 && ( - - - - )} - - {currentPageSize <= 2 && ( - - - - )} - - ); - }, - }, - ]} - pageSize={tableFilterState.pageSize} - page={isResponseSuccess(movements) ? movements?.meta?.page : 0} - totalItems={ - isResponseSuccess(movements) ? movements?.meta?.total_results : 0 - } - onPageChange={setPage} - isLoading={isLoading} - sorting={sorting} - setSorting={setSorting} - className={{ - containerClassName: cn({ - 'mb-20': - isResponseSuccess(movements) && movements?.data?.length === 0, - }), - tableWrapperClassName: 'overflow-x-auto min-h-full!', - tableClassName: 'font-inter w-full table-auto min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', - bodyColumnClassName: - 'px-6 py-3 last:flex last:flex-row last:justify-end', - }} - /> - - -
+ ); }; From e73d3e0823b2e04eff804b2959353d25d46c7965 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 1 Nov 2025 09:46:06 +0700 Subject: [PATCH 073/174] refactor(FE-Storyless): add product and warehouse filters with select inputs --- .../inventory/movement/MovementTable.tsx | 414 ++++++++++-------- 1 file changed, 237 insertions(+), 177 deletions(-) diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index 61be40f8..6ed4c49a 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -1,24 +1,56 @@ 'use client'; -import { useState } from 'react'; +import { ChangeEventHandler, useState } from 'react'; import useSWR from 'swr'; -import { SortingState } from '@tanstack/react-table'; +import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import Table from '@/components/Table'; -import { useModal } from '@/components/Modal'; -import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { Icon } from '@iconify/react'; import { Movement } from '@/types/api/inventory/movement'; import { MovementApi } from '@/services/api/inventory'; import { cn } from '@/lib/helper'; +import { Product } from '@/types/api/master-data/product'; +import { Warehouse } from '@/types/api/master-data/warehouse'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ROWS_OPTIONS } from '@/config/constant'; -import { TableToolbar } from '@/components/table/TableToolbar'; -import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; -import { OptionType } from '@/components/input/SelectInput'; +import { OptionType, useSelect } from '@/components/input/SelectInput'; +import Button from '@/components/Button'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import SelectInput from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; -import { TableRowOptions } from '@/components/table/TableRowOptions'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; +}) => { + return ( +
+ +
+ ); +}; const MovementTable = () => { const { @@ -28,30 +60,47 @@ const MovementTable = () => { setPageSize, toQueryString: getTableFilterQueryString, } = useTableFilter({ - initial: { search: '' }, - paramMap: { page: 'page', pageSize: 'limit' }, + initial: { + search: '', + product: '', + warehouse: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + product: 'product_id', + warehouse: 'warehouse_id', + }, }); const [sorting, setSorting] = useState([]); - const [selectedMovement, setSelectedMovement] = useState< - Movement | undefined - >(undefined); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const deleteModal = useModal(); const { - data: movements, - isLoading, - mutate: refreshMovements, - } = useSWR( + setInputValue: setProductInputValue, + options: productOptions, + isLoadingOptions: isLoadingProductOptions, + } = useSelect('/products', 'id', 'name'); + + const { + setInputValue: setWarehouseInputValue, + options: warehouseOptions, + isLoadingOptions: isLoadingWarehouseOptions, + } = useSelect('/warehouses', 'id', 'name'); + + const [selectedProduct, setSelectedProduct] = useState( + null + ); + const [selectedWarehouse, setSelectedWarehouse] = useState( + null + ); + + const { data: movements, isLoading } = useSWR( `${MovementApi.basePath}${getTableFilterQueryString()}`, MovementApi.getAllFetcher ); - const searchChangeHandler = (e: React.ChangeEvent) => { + const searchChangeHandler: ChangeEventHandler = (e) => { updateFilter('search', e.target.value); - setPage(1); }; const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { @@ -60,167 +109,178 @@ const MovementTable = () => { setPage(1); }; - const confirmationModalDeleteClickHandler = async () => { - setIsDeleteLoading(true); - try { - await MovementApi.delete(selectedMovement?.id as number); - refreshMovements(); - deleteModal.closeModal(); - } finally { - setIsDeleteLoading(false); - } + const productChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedProduct(val as OptionType); + updateFilter('product', val ? ((val as OptionType).value as string) : ''); }; + const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedWarehouse(val as OptionType); + updateFilter('warehouse', val ? ((val as OptionType).value as string) : ''); + }; + + const movementColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorFn: (row) => row.source_warehouse?.name, + header: 'Gudang Asal', + }, + { + accessorFn: (row) => row.destination_warehouse?.name, + header: 'Gudang Tujuan', + }, + { + accessorKey: 'transfer_reason', + header: 'Catatan', + }, + { + accessorKey: 'transfer_date', + header: 'Tanggal', + cell: (props) => + new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'), + }, + { + accessorFn: (row) => { + const totalCost = row.deliveries?.reduce( + (sum, d) => sum + (d.shipping_cost_total || 0), + 0 + ); + return totalCost?.toLocaleString('id-ID'); + }, + header: 'Biaya Pengiriman', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + return ( -
-
- +
+
+
+
+ +
+ + +
+ +
+ + + + + +
+
+ + + data={isResponseSuccess(movements) ? movements?.data : []} + columns={movementColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(movements) ? movements?.meta?.page : 0} + totalItems={ + isResponseSuccess(movements) ? movements?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(movements) && movements?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', }} - search={{ - value: tableFilterState.search, - onChange: searchChangeHandler, - placeholder: 'Cari Movement', - }} - /> -
- - - data={isResponseSuccess(movements) ? movements?.data : []} - columns={[ - { - header: '#', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, - }, - { - accessorFn: (row) => row.source_warehouse?.name, - header: 'Gudang Asal', - }, - { - accessorFn: (row) => row.destination_warehouse?.name, - header: 'Gudang Tujuan', - }, - { - accessorKey: 'transfer_reason', - header: 'Catatan', - }, - { - accessorKey: 'transfer_date', - header: 'Tanggal', - cell: (props) => - new Date(props.row.original.transfer_date).toLocaleDateString( - 'id-ID' - ), - }, - { - accessorFn: (row) => { - const totalCost = row.deliveries?.reduce( - (sum, d) => sum + (d.shipping_cost_total || 0), - 0 - ); - return totalCost?.toLocaleString('id-ID'); - }, - header: 'Biaya Pengiriman', - }, - { - header: 'Aksi', - cell: (props) => { - const currentPageSize = - props.table.getPaginationRowModel().rows.length; - const currentPageRows = - props.table.getPaginationRowModel().flatRows; - const currentRowRelativeIndex = - currentPageRows.findIndex((r) => r.id === props.row.id) + 1; - - const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; - - const deleteClickHandler = () => { - setSelectedMovement(props.row.original); - deleteModal.openModal(); - }; - - return ( - <> - {currentPageSize > 2 && ( - - - - )} - - {currentPageSize <= 2 && ( - - - - )} - - ); - }, - }, - ]} - pageSize={tableFilterState.pageSize} - page={isResponseSuccess(movements) ? movements?.meta?.page : 0} - totalItems={ - isResponseSuccess(movements) ? movements?.meta?.total_results : 0 - } - onPageChange={setPage} - isLoading={isLoading} - sorting={sorting} - setSorting={setSorting} - className={{ - containerClassName: cn({ - 'mb-20': - isResponseSuccess(movements) && movements?.data?.length === 0, - }), - tableWrapperClassName: 'overflow-x-auto min-h-full!', - tableClassName: 'font-inter w-full table-auto min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', - bodyColumnClassName: - 'px-6 py-3 last:flex last:flex-row last:justify-end', - }} - /> - - -
+ ); }; From f70433d901d7c8e597212c3814df4656e67346fa Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 1 Nov 2025 10:43:43 +0700 Subject: [PATCH 074/174] refactor(FE-Storyless): remove UpdateMovementPayload type and related schema, streamline MovementForm handling --- .../movement/form/MovementForm.schema.ts | 2 - .../inventory/movement/form/MovementForm.tsx | 96 +++++++------------ .../movement/form/useMovementFormHandlers.ts | 95 ------------------ src/services/api/inventory.ts | 3 +- src/types/api/inventory/movement.d.ts | 2 - 5 files changed, 36 insertions(+), 162 deletions(-) delete mode 100644 src/components/pages/inventory/movement/form/useMovementFormHandlers.ts diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index ed8fb479..20f2fb7d 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -133,8 +133,6 @@ export const MovementFormSchema = Yup.object({ .required('Pengiriman wajib diisi!'), }); -export const UpdateMovementFormSchema = MovementFormSchema; - export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 02b341e2..0bfb94c3 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -13,19 +13,19 @@ import { CreateMovementPayload, Movement, } from '@/types/api/inventory/movement'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { useRouter } from 'next/navigation'; import { MovementFormSchema, MovementFormValues, - UpdateMovementFormSchema, getMovementFormInitialValues, ProductSchema, DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; -import { useMovementFormHandlers } from './useMovementFormHandlers'; import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; +import { MovementApi } from '@/services/api/inventory'; import FileInput from '@/components/input/FileInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import Badge from '@/components/Badge'; @@ -36,8 +36,10 @@ interface MovementFormProps { } const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + const router = useRouter(); + // ===== STATE MANAGEMENT ===== - const [, setMovementFormErrorMessage] = useState(''); + const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); const [ productWarehouseSelectInputValue, setProductWarehouseSelectInputValue, @@ -49,11 +51,26 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); // ===== FORM HANDLERS ===== - const { - movementFormErrorMessage, - createMovementHandler, - updateMovementHandler, - } = useMovementFormHandlers(initialValues?.id); + const createMovementHandler = useCallback( + async (payload: CreateMovementPayload, documents: File[] = []) => { + const formData = new FormData(); + formData.append('data', JSON.stringify(payload)); + documents.forEach((file, index) => { + formData.append(`documents[${index}]`, file); + }); + + const res = await MovementApi.create( + formData as unknown as CreateMovementPayload + ); + if (isResponseError(res)) { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/inventory/movement'); + }, + [router] + ); // ===== INTERFACES ===== interface WarehouseOptionType extends OptionType { @@ -139,8 +156,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const formik = useFormik({ initialValues: formikInitialValues, - validationSchema: - type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, + validationSchema: MovementFormSchema, validateOnChange: true, validateOnBlur: true, validateOnMount: false, @@ -148,7 +164,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { onSubmit: async (values) => { setMovementFormErrorMessage(''); const documents: File[] = []; - const deliveriesPayload = values.deliveries.map((d, idx) => { + const deliveriesPayload = values.deliveries.map((d) => { let documentIndex = 0; if (d.document && d.document instanceof File) { @@ -187,13 +203,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { case 'add': await createMovementHandler(payload, documents); break; - case 'edit': - await updateMovementHandler( - initialValues?.id as number, - payload, - documents - ); - break; } }, }); @@ -1591,49 +1600,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {/* Action buttons */}
- {type !== 'add' && ( -
- - - {type !== 'edit' && ( - - )} -
- )} - {type !== 'detail' && (
- @@ -1642,7 +1611,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={hasInvalidQty || hasExceededStock || !formik.isValid || formik.isSubmitting} + disabled={ + hasInvalidQty || + hasExceededStock || + !formik.isValid || + formik.isSubmitting + } > Submit diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts deleted file mode 100644 index 0ad31e38..00000000 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { toast } from 'react-hot-toast'; -import { useModal } from '@/components/Modal'; -import { MovementApi } from '@/services/api/inventory'; -import { - CreateMovementPayload, - UpdateMovementPayload, -} from '@/types/api/inventory/movement'; -import { isResponseError } from '@/lib/api-helper'; - -export const useMovementFormHandlers = (initialValuesId?: number) => { - const router = useRouter(); - const deleteModal = useModal(); - const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const createMovementHandler = useCallback( - async (payload: CreateMovementPayload, documents: File[] = []) => { - const formData = new FormData(); - formData.append('data', JSON.stringify(payload)); - documents.forEach((file, index) => { - formData.append(`documents[${index}]`, file); - }); - - const res = await MovementApi.create( - formData as unknown as CreateMovementPayload - ); - if (isResponseError(res)) { - setMovementFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.push('/inventory/movement'); - }, - [router] - ); - - const updateMovementHandler = useCallback( - async ( - movementId: number, - payload: UpdateMovementPayload, - documents: File[] = [] - ) => { - let finalPayload: UpdateMovementPayload | FormData; - - if (documents.length > 0) { - const formData = new FormData(); - formData.append('data', JSON.stringify(payload)); - documents.forEach((file, index) => { - formData.append(`documents[${index}]`, file); - }); - - finalPayload = formData as unknown as UpdateMovementPayload; - } else { - finalPayload = payload; - } - - const res = await MovementApi.update(movementId, finalPayload); - if (res?.status === 'error') { - setMovementFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.refresh(); - router.push('/inventory/movement'); - }, - [router] - ); - - const deleteMovementClickHandler = useCallback(() => { - deleteModal.openModal(); - }, [deleteModal]); - - const confirmationModalDeleteClickHandler = useCallback(async () => { - if (!initialValuesId) return; - - setIsDeleteLoading(true); - await MovementApi.delete(initialValuesId); - deleteModal.closeModal(); - toast.success('Successfully delete Movement!'); - setIsDeleteLoading(false); - router.push('/inventory/movement'); - }, [deleteModal, initialValuesId, router]); - - return { - deleteModal, - movementFormErrorMessage, - isDeleteLoading, - createMovementHandler, - updateMovementHandler, - deleteMovementClickHandler, - confirmationModalDeleteClickHandler, - }; -}; diff --git a/src/services/api/inventory.ts b/src/services/api/inventory.ts index ec58f6f2..e5d3adfc 100644 --- a/src/services/api/inventory.ts +++ b/src/services/api/inventory.ts @@ -7,7 +7,6 @@ import { import { CreateMovementPayload, Movement, - UpdateMovementPayload, } from '@/types/api/inventory/movement'; import { CreateInventoryAdjustmentPayload, @@ -23,7 +22,7 @@ export const ProductWarehouseApi = new BaseApiService< export const MovementApi = new BaseApiService< Movement, CreateMovementPayload, - UpdateMovementPayload + unknown >('/inventory/transfers'); export const inventoryAdjustmentApi = new BaseApiService< diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index 87a03f95..53dfa61d 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -71,5 +71,3 @@ export type CreateMovementPayload = { }[]; }[]; }; - -export type UpdateMovementPayload = CreateMovementPayload; From 16db7af070e5673072abbedb44c8f8edf237326e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sun, 2 Nov 2025 20:14:22 +0700 Subject: [PATCH 075/174] chore(FE-Storyless): remove inputmask and related type definitions --- package-lock.json | 27 +++++++++++---------------- package.json | 2 -- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 56d046a8..e847e60b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -31,7 +30,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1639,13 +1637,6 @@ "@types/react": "*" } }, - "node_modules/@types/inputmask": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz", - "integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1681,6 +1672,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1750,6 +1742,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2267,6 +2260,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2800,7 +2794,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/daisyui": { "version": "5.3.10", @@ -3228,6 +3223,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3401,6 +3397,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4203,12 +4200,6 @@ "node": ">=0.8.19" } }, - "node_modules/inputmask": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz", - "integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==", - "license": "MIT" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5745,6 +5736,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5754,6 +5746,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6552,6 +6545,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6719,6 +6713,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 16d6c90c..17019b24 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -33,7 +32,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", From fc3b090da586ebc69b7242c3729723ff7c10e72d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sun, 2 Nov 2025 20:59:37 +0700 Subject: [PATCH 076/174] refactor(FE-Storyless): replace TextInput with NumberInput for price and tax fields, enhance form handling --- .../master-data/product/form/ProductForm.tsx | 196 ++++++++++++------ 1 file changed, 129 insertions(+), 67 deletions(-) diff --git a/src/components/pages/master-data/product/form/ProductForm.tsx b/src/components/pages/master-data/product/form/ProductForm.tsx index 02afbfc9..f118e52a 100644 --- a/src/components/pages/master-data/product/form/ProductForm.tsx +++ b/src/components/pages/master-data/product/form/ProductForm.tsx @@ -9,7 +9,11 @@ import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; -import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import NumberInput from '@/components/input/NumberInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; @@ -24,7 +28,12 @@ import { CreateProductPayload, UpdateProductPayload, } from '@/types/api/master-data/product'; -import { UomApi, ProductCategoryApi, SupplierApi, ProductApi } from '@/services/api/master-data'; +import { + UomApi, + ProductCategoryApi, + SupplierApi, + ProductApi, +} from '@/services/api/master-data'; import { cn } from '@/lib/helper'; import { PRODUCT_FLAG_OPTIONS } from '@/config/constant'; @@ -67,30 +76,37 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { [router] ); - const formikInitialValues = useMemo(() => ({ - name: initialValues?.name ?? '', - brand: initialValues?.brand ?? '', - sku: initialValues?.sku ?? '', - uom: initialValues?.uom - ? { value: initialValues.uom.id, label: initialValues.uom.name } - : null, - uom_id: initialValues?.uom?.id ?? 0, - product_category: initialValues?.product_category - ? { value: initialValues.product_category.id, label: initialValues.product_category.name } - : null, - product_category_id: initialValues?.product_category?.id ?? 0, - product_price: initialValues?.product_price ?? 0, - selling_price: initialValues?.selling_price ?? 0, - tax: initialValues?.tax ?? 0, - expiry_period: initialValues?.expiry_period ?? 0, - supplier: null, // not used for payload, just for UI - supplier_ids: initialValues?.suppliers?.map(s => s.id) ?? [], - flags: initialValues?.flags ?? [], - }), [initialValues]); + const formikInitialValues = useMemo( + () => ({ + name: initialValues?.name ?? '', + brand: initialValues?.brand ?? '', + sku: initialValues?.sku ?? '', + uom: initialValues?.uom + ? { value: initialValues.uom.id, label: initialValues.uom.name } + : null, + uom_id: initialValues?.uom?.id ?? 0, + product_category: initialValues?.product_category + ? { + value: initialValues.product_category.id, + label: initialValues.product_category.name, + } + : null, + product_category_id: initialValues?.product_category?.id ?? 0, + product_price: initialValues?.product_price ?? 0, + selling_price: initialValues?.selling_price ?? 0, + tax: initialValues?.tax ?? 0, + expiry_period: initialValues?.expiry_period ?? 0, + supplier: null, // not used for payload, just for UI + supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [], + flags: initialValues?.flags ?? [], + }), + [initialValues] + ); const formik = useFormik({ initialValues: formikInitialValues, - validationSchema: type === 'edit' ? UpdateProductFormSchema : ProductFormSchema, + validationSchema: + type === 'edit' ? UpdateProductFormSchema : ProductFormSchema, onSubmit: async (values) => { setProductFormErrorMessage(''); const payload: CreateProductPayload = { @@ -99,12 +115,16 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { sku: values.sku, uom_id: values.uom_id, product_category_id: values.product_category_id, - product_price: values.product_price, - selling_price: values.selling_price, - tax: values.tax, - expiry_period: values.expiry_period, - supplier_ids: (values.supplier_ids ?? []).filter((id): id is number => typeof id === 'number'), - flags: (values.flags ?? []).filter((f): f is string => typeof f === 'string'), + product_price: parseInt(values.product_price.toString()) || 0, + selling_price: parseInt(values.selling_price.toString()) || 0, + tax: parseInt(values.tax.toString()) || 0, + expiry_period: parseInt(values.expiry_period.toString()) || 0, + supplier_ids: (values.supplier_ids ?? []).filter( + (id): id is number => typeof id === 'number' + ), + flags: (values.flags ?? []).filter( + (f): f is string => typeof f === 'string' + ), }; switch (type) { case 'add': @@ -120,12 +140,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { const { setValues: formikSetValues } = formik; // UOM - const [uomSelectInputValue, setUomSelectInputValue] = useState(''); - const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`; - const { data: uoms, isLoading: isLoadingUoms } = useSWR(uomsUrl, UomApi.getAllFetcher); - const uomOptions = isResponseSuccess(uoms) - ? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name })) - : []; + const { + setInputValue: setUomSelectInputValue, + options: uomOptions, + isLoadingOptions: isLoadingUoms, + } = useSelect(UomApi.basePath, 'id', 'name'); const uomChangeHandler = (val: OptionType | OptionType[] | null) => { formik.setFieldTouched('uom', true); formik.setFieldValue('uom', val); @@ -134,12 +153,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { }; // Product Category - const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); - const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`; - const { data: categories, isLoading: isLoadingCategories } = useSWR(categoriesUrl, ProductCategoryApi.getAllFetcher); - const categoryOptions = isResponseSuccess(categories) - ? categories?.data.map((cat) => ({ value: cat.id, label: cat.name })) - : []; + const { + setInputValue: setCategorySelectInputValue, + options: categoryOptions, + isLoadingOptions: isLoadingCategories, + } = useSelect(ProductCategoryApi.basePath, 'id', 'name'); const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { formik.setFieldTouched('product_category', true); formik.setFieldValue('product_category', val); @@ -147,19 +165,25 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { formik.setFieldValue('product_category_id', (val as OptionType)?.value); }; - // Supplier (multi select) + // Supplier (multi select) - using SWR to filter by category const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; - const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(suppliersUrl, SupplierApi.getAllFetcher); + const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( + suppliersUrl, + SupplierApi.getAllFetcher + ); const supplierOptions = isResponseSuccess(suppliers) - ? suppliers?.data - .filter((sup) => sup.category === 'SAPRONAK') - .map((sup) => ({ value: sup.id, label: sup.name })) - : []; + ? suppliers?.data + .filter((sup) => sup.category === 'SAPRONAK') + .map((sup) => ({ value: sup.id, label: sup.name })) + : []; const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const arr = Array.isArray(val) ? val : val ? [val] : []; formik.setFieldTouched('supplier_ids', true); - formik.setFieldValue('supplier_ids', arr.map((v) => (v as OptionType).value)); + formik.setFieldValue( + 'supplier_ids', + arr.map((v) => (v as OptionType).value) + ); }; const deleteProductClickHandler = () => { @@ -181,7 +205,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { return ( <> -
+
@@ -148,7 +158,7 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor required label='Kode' name='code' - placeholder='Masukkan kode kategori produk' + placeholder='Masukkan kode...' value={formik.values.code} onChange={formik.handleChange} onBlur={formik.handleBlur} @@ -160,7 +170,7 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor required label='Nama' name='name' - placeholder='Masukkan nama kategori produk' + placeholder='Masukkan nama...' value={formik.values.name} onChange={formik.handleChange} onBlur={formik.handleBlur} @@ -247,7 +257,7 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor Date: Sun, 2 Nov 2025 21:33:38 +0700 Subject: [PATCH 079/174] refactor(FE-Storyless): enhance MovementForm schema and validation, improve handling of product quantities and delivery costs --- .../movement/form/MovementForm.schema.ts | 64 ++++++++++++++--- .../inventory/movement/form/MovementForm.tsx | 70 +++++++++++-------- 2 files changed, 98 insertions(+), 36 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 20f2fb7d..195873b7 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -1,34 +1,82 @@ import * as Yup from 'yup'; import { Movement } from '@/types/api/inventory/movement'; +type MovementFormSchemaType = { + transfer_reason: string; + transfer_date: string; + source_warehouse?: { + value: number; + label: string; + area?: string; + location?: string; + } | null; + source_warehouse_id: number; + destination_warehouse?: { + value: number; + label: string; + area?: string; + location?: string; + } | null; + destination_warehouse_id: number; + products: { + product?: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number | string; + }[]; + deliveries: { + delivery_cost?: number | string; + delivery_cost_per_item?: number | string; + document?: File | string | null; + document_path?: string | null; + driver_name: string; + vehicle_plate: string; + supplier?: { + value: number; + label: string; + } | null; + supplier_id: number; + products: { + product?: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number | string; + }[]; + }[]; +}; + export type ProductSchema = { - product: { + product?: { value: number; label: string; } | null; product_id: number; - product_qty: number; + product_qty: number | string; }; export type DeliverySchema = { - delivery_cost?: number | undefined; - delivery_cost_per_item?: number | undefined; + delivery_cost?: number | string; + delivery_cost_per_item?: number | string; document?: File | string | null; document_path?: string | null; driver_name: string; vehicle_plate: string; - supplier: { + supplier?: { value: number; label: string; } | null; supplier_id: number; products: { - product: { + product?: { value: number; label: string; } | null; product_id: number; - product_qty: number; + product_qty: number | string; }[]; }; @@ -102,7 +150,7 @@ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ .required('Produk wajib diisi!'), }); -export const MovementFormSchema = Yup.object({ +export const MovementFormSchema: Yup.ObjectSchema = Yup.object({ transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), source_warehouse: Yup.object({ diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 0bfb94c3..56038f6a 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -173,8 +173,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } return { - delivery_cost: d.delivery_cost ?? 0, - delivery_cost_per_item: d.delivery_cost_per_item ?? 0, + delivery_cost: parseInt((d.delivery_cost || '').toString()) || 0, + delivery_cost_per_item: + parseInt((d.delivery_cost_per_item || '').toString()) || 0, document_index: documentIndex, document_path: d.document_path, driver_name: d.driver_name, @@ -182,7 +183,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { supplier_id: d.supplier_id, products: d.products.map((p) => ({ product_id: p.product_id, - product_qty: p.product_qty, + product_qty: parseInt(p.product_qty.toString()) || 0, })), }; }); @@ -194,7 +195,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { destination_warehouse_id: values.destination_warehouse_id, products: values.products.map((p) => ({ product_id: p.product_id, - product_qty: p.product_qty, + product_qty: parseInt(p.product_qty.toString()) || 0, })), deliveries: deliveriesPayload, }; @@ -304,7 +305,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { product: null, product_id: 0, - product_qty: 0, + product_qty: '', }, ]; formik.setFieldValue('products', newProducts); @@ -339,8 +340,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formik.setFieldValue('deliveries', [ ...(formik.values.deliveries || []), { - delivery_cost: undefined, - delivery_cost_per_item: undefined, + delivery_cost: '', + delivery_cost_per_item: '', document: null, driver_name: '', vehicle_plate: '', @@ -350,7 +351,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { product: null, product_id: 0, - product_qty: 0, + product_qty: '', }, ], }, @@ -392,7 +393,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const delivery = formik.values.deliveries?.[idx]; if (delivery) { const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), 0 ); if (productQty > 0 && value > 0) { @@ -416,7 +417,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const delivery = formik.values.deliveries?.[idx]; if (delivery) { const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), 0 ); if (productQty > 0 && value > 0) { @@ -690,36 +691,38 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { useEffect(() => { formik.values.deliveries?.forEach((delivery, idx) => { const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), 0 ); - if ( - delivery.delivery_cost && - delivery.delivery_cost > 0 && - productQty > 0 - ) { - const perItem = delivery.delivery_cost / productQty; - if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { + const deliveryCost = + parseInt((delivery.delivery_cost || '').toString()) || 0; + const deliveryCostPerItem = + parseInt((delivery.delivery_cost_per_item || '').toString()) || 0; + + if (deliveryCost > 0 && productQty > 0) { + const perItem = deliveryCost / productQty; + if (Math.abs(deliveryCostPerItem - perItem) > 0.01) { formik.setFieldValue( `deliveries.${idx}.delivery_cost_per_item`, perItem ); } - } else if ( - delivery.delivery_cost_per_item && - delivery.delivery_cost_per_item > 0 && - productQty > 0 - ) { - const totalCost = delivery.delivery_cost_per_item * productQty; - if (Math.abs((delivery.delivery_cost || 0) - totalCost) > 0.01) { + } else if (deliveryCostPerItem > 0 && productQty > 0) { + const totalCost = deliveryCostPerItem * productQty; + if (Math.abs(deliveryCost - totalCost) > 0.01) { formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); } } }); }, [ formik.values.deliveries - ?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0)) + ?.map((d) => + d.products.reduce( + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), + 0 + ) + ) .join(','), ]); @@ -765,6 +768,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { required label='Alasan Transfer' name='transfer_reason' + placeholder='Masukkan alasan transfer...' value={formik.values.transfer_reason} onChange={formik.handleChange} onBlur={formik.handleBlur} @@ -802,6 +806,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { formik.setFieldTouched('source_warehouse', true); @@ -869,6 +874,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { formik.setFieldTouched('destination_warehouse', true); @@ -1055,8 +1061,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } placeholder={ !formik.values.source_warehouse_id - ? 'Pilih gudang asal terlebih dahulu' - : 'Pilih produk' + ? 'Pilih gudang asal terlebih dahulu...' + : 'Pilih produk...' } isClearable {...isRepeaterInputError( @@ -1074,6 +1080,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { { formik.setFieldTouched( @@ -1334,6 +1342,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { { formik.setFieldTouched( @@ -1403,6 +1413,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { { { Date: Sun, 2 Nov 2025 21:42:03 +0700 Subject: [PATCH 080/174] refactor(FE-Storyless): simplify MovementForm by integrating useSelect for warehouse and supplier inputs, enhance data fetching logic --- .../inventory/movement/form/MovementForm.tsx | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 56038f6a..28148706 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -8,7 +8,10 @@ import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; import NumberInput from '@/components/input/NumberInput'; -import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; import { CreateMovementPayload, Movement, @@ -46,9 +49,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); - const [warehouseSelectInputValue, setWarehouseSelectInputValue] = - useState(''); - const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); // ===== FORM HANDLERS ===== const createMovementHandler = useCallback( @@ -92,18 +92,25 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ProductWarehouseApi.getAllFetcher ); + // ===== USE SELECT HOOKS ===== + const { + inputValue: warehouseSelectInputValue, + setInputValue: setWarehouseSelectInputValue, + isLoadingOptions: isLoadingWarehouses, + } = useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); + + const { + setInputValue: setSupplierSelectInputValue, + options: supplierOptions, + isLoadingOptions: isLoadingSuppliers, + } = useSelect(SupplierApi.basePath, 'id', 'name', 'search'); + const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; - const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( + const { data: warehouses } = useSWR( warehousesUrl, WarehouseApi.getAllFetcher ); - const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`; - const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( - suppliersUrl, - SupplierApi.getAllFetcher - ); - // ===== DATA PROCESSING ===== const warehouseStockMap = useMemo(() => { if (!isResponseSuccess(allProductWarehouses)) return new Map(); @@ -129,8 +136,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return stockMap; }, [allProductWarehouses]); - const warehouseOptions = isResponseSuccess(warehouses) - ? warehouses?.data.map((w) => { + const warehouseOptions = useMemo(() => { + if (!isResponseSuccess(warehouses)) return []; + + return ( + warehouses?.data.map((w) => { warehouseStockMap.get(w.id); return { value: w.id, @@ -141,12 +151,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ? w.location?.name : undefined, }; - }) - : []; - - const supplierOptions = isResponseSuccess(suppliers) - ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) - : []; + }) || [] + ); + }, [warehouses, warehouseStockMap]); // ===== FORM INITIALIZATION ===== const formikInitialValues = useMemo( From e116311dc2d60bc8b0e79bf85c588b87d2943f47 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sun, 2 Nov 2025 23:04:54 +0700 Subject: [PATCH 081/174] refactor(FE-170,174): restructure schema and validation for body weights, stocks, and depletions; improve handling of input values --- .../recording/form/RecordingForm.schema.ts | 307 +++++++++++------- .../recording/form/RecordingForm.tsx | 44 +-- 2 files changed, 207 insertions(+), 144 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index fb58b048..5bd914b2 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -7,107 +7,165 @@ import { CreateGradingPayload, } from '@/types/api/production/recording'; -export const RecordingGrowingFormSchema = Yup.object({ - project_flock_kandang: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - project_flock_kandangs_id: Yup.number() - .default(0) - .typeError('Project Flock Kandang wajib diisi!') - .test( - 'is-valid-project-flock-kandang', - 'Project Flock Kandang wajib diisi!', - (value) => value !== undefined && value !== null && value > 0 - ) - .required('Project Flock Kandang wajib diisi!') - .test( - 'not-already-recorded', - 'Project Flock ini sudah direcord hari ini!', - function (value) { - const recordedProjectFlockIds = this.options.context - ?.recordedProjectFlockIds as Set; - const formType = this.options.context?.type as - | 'add' - | 'edit' - | 'detail'; - if (formType !== 'add') return true; - if (value && recordedProjectFlockIds?.has(value)) { - return false; - } - return true; - } - ), - body_weights: Yup.array() - .of( - Yup.object({ - weight: Yup.number() - .required('Berat ayam total wajib diisi!') - .min(1, 'Berat ayam total minimal 1 gram!') - .typeError('Berat ayam total harus berupa angka!'), - avg_weight: Yup.number() - .required('Berat ayam rata-rata wajib diisi!') - .min(1, 'Berat ayam rata-rata minimal 1 gram!') - .typeError('Berat ayam rata-rata harus berupa angka!'), - qty: Yup.number() - .required('Jumlah ayam wajib diisi!') - .min(1, 'Jumlah ayam minimal 1 ekor!') - .typeError('Jumlah ayam harus berupa angka!') - .default(1), - }) - ) - .min(1, 'Minimal harus ada 1 data bobot badan!') - .required('Data bobot badan wajib diisi!'), - stocks: Yup.array() - .of( - Yup.object({ - product_warehouse_id: Yup.number() - .required('Produk wajib diisi!') - .min(1, 'Produk wajib diisi!') - .typeError('Produk harus berupa angka!'), - usage_qty: Yup.number() - .required('Jumlah penggunaan wajib diisi!') - .min(1, 'Jumlah penggunaan tidak boleh 0!') - .typeError('Jumlah penggunaan harus berupa angka!'), - }) - ) - .min(1, 'Minimal harus ada 1 data stok!') - .required('Data stok wajib diisi!'), - depletions: Yup.array() - .of( - Yup.object({ - product_warehouse_id: Yup.number() - .required('Produk depletions wajib diisi!') - .min(1, 'Produk depletions wajib diisi!') - .typeError('Produk depletions harus berupa angka!'), - qty: Yup.number() - .required('Jumlah depletions wajib diisi!') - .min(1, 'Jumlah depletions minimal 1!') - .typeError('Jumlah depletions harus berupa angka!'), - }) - ) - .min(1, 'Minimal harus ada 1 data depletions!') - .required('Data depletions wajib diisi!'), +type RecordingGrowingFormSchemaType = { + project_flock_kandang: { + value: number; + label: string; + } | null; + project_flock_kandangs_id: number; + body_weights: { + weight: number | string; + avg_weight: number | string; + qty: number | string; + }[]; + stocks: { + product_warehouse_id: number; + usage_qty: number | string; + }[]; + depletions: { + product_warehouse_id: number; + qty: number | string; + }[]; +}; + +type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & { + eggs: { + product_warehouse_id: number; + qty: number | string; + }[]; +}; + +type RecordingGradingFormSchemaType = { + recording_egg_id: number; + eggs_grading: { + grade: string; + qty: number | string; + }[]; +}; + +export type BodyWeightSchema = { + weight: number | string; + avg_weight: number | string; + qty: number | string; +}; + +export type StockSchema = { + product_warehouse_id: number; + usage_qty: number | string; +}; + +export type DepletionSchema = { + product_warehouse_id: number; + qty: number | string; +}; + +export type EggSchema = { + product_warehouse_id: number; + qty: number | string; +}; + +const BodyWeightObjectSchema: Yup.ObjectSchema = Yup.object({ + weight: Yup.number() + .required('Berat ayam total wajib diisi!') + .min(1, 'Berat ayam total minimal 1 gram!') + .typeError('Berat ayam total harus berupa angka!'), + avg_weight: Yup.number() + .required('Berat ayam rata-rata wajib diisi!') + .min(1, 'Berat ayam rata-rata minimal 1 gram!') + .typeError('Berat ayam rata-rata harus berupa angka!'), + qty: Yup.number() + .required('Jumlah ayam wajib diisi!') + .min(1, 'Jumlah ayam minimal 1 ekor!') + .typeError('Jumlah ayam harus berupa angka!'), }); -export const RecordingLayingFormSchema = RecordingGrowingFormSchema.shape({ - eggs: Yup.array() - .of( - Yup.object({ - product_warehouse_id: Yup.number() - .required('Produk telur wajib diisi!') - .min(1, 'Produk telur wajib diisi!') - .typeError('Produk telur harus berupa angka!'), - qty: Yup.number() - .required('Jumlah telur wajib diisi!') - .min(1, 'Jumlah telur tidak boleh 0!') - .typeError('Jumlah telur harus berupa angka!'), - }) - ) - .min(1, 'Minimal harus ada 1 data telur!') - .required('Data telur wajib diisi!'), +const StockObjectSchema: Yup.ObjectSchema = Yup.object({ + product_warehouse_id: Yup.number() + .required('Produk wajib diisi!') + .min(1, 'Produk wajib diisi!') + .typeError('Produk harus berupa angka!'), + usage_qty: Yup.number() + .required('Jumlah penggunaan wajib diisi!') + .min(1, 'Jumlah penggunaan tidak boleh 0!') + .typeError('Jumlah penggunaan harus berupa angka!'), }); +const DepletionObjectSchema: Yup.ObjectSchema = Yup.object({ + product_warehouse_id: Yup.number() + .required('Produk depletions wajib diisi!') + .min(1, 'Produk depletions wajib diisi!') + .typeError('Produk depletions harus berupa angka!'), + qty: Yup.number() + .required('Jumlah depletions wajib diisi!') + .min(1, 'Jumlah depletions minimal 1!') + .typeError('Jumlah depletions harus berupa angka!'), +}); + +const EggObjectSchema: Yup.ObjectSchema = Yup.object({ + product_warehouse_id: Yup.number() + .required('Produk telur wajib diisi!') + .min(1, 'Produk telur wajib diisi!') + .typeError('Produk telur harus berupa angka!'), + qty: Yup.number() + .required('Jumlah telur wajib diisi!') + .min(1, 'Jumlah telur tidak boleh 0!') + .typeError('Jumlah telur harus berupa angka!'), +}); + +export const RecordingGrowingFormSchema: Yup.ObjectSchema = + Yup.object({ + project_flock_kandang: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + project_flock_kandangs_id: Yup.number() + .default(0) + .typeError('Project Flock Kandang wajib diisi!') + .test( + 'is-valid-project-flock-kandang', + 'Project Flock Kandang wajib diisi!', + (value) => value !== undefined && value !== null && value > 0 + ) + .required('Project Flock Kandang wajib diisi!') + .test( + 'not-already-recorded', + 'Project Flock ini sudah direcord hari ini!', + function (value) { + const recordedProjectFlockIds = this.options.context + ?.recordedProjectFlockIds as Set; + const formType = this.options.context?.type as + | 'add' + | 'edit' + | 'detail'; + if (formType !== 'add') return true; + if (value && recordedProjectFlockIds?.has(value)) { + return false; + } + return true; + } + ), + body_weights: Yup.array() + .of(BodyWeightObjectSchema) + .min(1, 'Minimal harus ada 1 data bobot badan!') + .required('Data bobot badan wajib diisi!'), + stocks: Yup.array() + .of(StockObjectSchema) + .min(1, 'Minimal harus ada 1 data stok!') + .required('Data stok wajib diisi!'), + depletions: Yup.array() + .of(DepletionObjectSchema) + .min(1, 'Minimal harus ada 1 data depletions!') + .required('Data depletions wajib diisi!'), + }); + +export const RecordingLayingFormSchema: Yup.ObjectSchema = + RecordingGrowingFormSchema.shape({ + eggs: Yup.array() + .of(EggObjectSchema) + .min(1, 'Minimal harus ada 1 data telur!') + .required('Data telur wajib diisi!'), + }); + export const UpdateRecordingGrowingFormSchema = RecordingGrowingFormSchema.shape({ project_flock_kandangs_id: Yup.number() @@ -133,26 +191,27 @@ export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({ .required('Project Flock Kandang wajib diisi!'), }); -export const RecordingGradingFormSchema = Yup.object({ - recording_egg_id: Yup.number() - .required('Recording Egg ID wajib diisi!') - .min(1, 'Recording Egg ID minimal 1!') - .typeError('Recording Egg ID harus berupa angka!'), - eggs_grading: Yup.array() - .of( - Yup.object({ - grade: Yup.string() - .required('Grade telur wajib diisi!') - .typeError('Grade telur harus berupa string!'), - qty: Yup.number() - .required('Jumlah telur wajib diisi!') - .min(1, 'Jumlah telur minimal 1!') - .typeError('Jumlah telur harus berupa angka!'), - }) - ) - .min(1, 'Minimal harus ada 1 data grading telur!') - .required('Data grading telur wajib diisi!'), -}); +export const RecordingGradingFormSchema: Yup.ObjectSchema = + Yup.object({ + recording_egg_id: Yup.number() + .required('Recording Egg ID wajib diisi!') + .min(1, 'Recording Egg ID minimal 1!') + .typeError('Recording Egg ID harus berupa angka!'), + eggs_grading: Yup.array() + .of( + Yup.object({ + grade: Yup.string() + .required('Grade telur wajib diisi!') + .typeError('Grade telur harus berupa string!'), + qty: Yup.number() + .required('Jumlah telur wajib diisi!') + .min(1, 'Jumlah telur minimal 1!') + .typeError('Jumlah telur harus berupa angka!'), + }) + ) + .min(1, 'Minimal harus ada 1 data grading telur!') + .required('Data grading telur wajib diisi!'), + }); export const UpdateRecordingGradingFormSchema = RecordingGradingFormSchema.shape({ @@ -199,9 +258,9 @@ export const getRecordingGrowingFormInitialValues = ( }) ) ?? [ { - weight: 0, - avg_weight: 0, - qty: 0, + weight: '', + avg_weight: '', + qty: '', }, ], stocks: initialValues?.stocks?.map( @@ -212,7 +271,7 @@ export const getRecordingGrowingFormInitialValues = ( ) ?? [ { product_warehouse_id: 0, - usage_qty: 0, + usage_qty: '', }, ], depletions: initialValues?.depletions?.map( @@ -225,7 +284,7 @@ export const getRecordingGrowingFormInitialValues = ( ) ?? [ { product_warehouse_id: 0, - qty: 0, + qty: '', }, ], }); @@ -241,7 +300,7 @@ export const getRecordingLayingFormInitialValues = ( })) ?? [ { product_warehouse_id: 0, - qty: 0, + qty: '', }, ], }); @@ -256,7 +315,7 @@ export const getRecordingGradingFormInitialValues = ( })) ?? [ { grade: '', - qty: 0, + qty: '', }, ], }); diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 5dfddbdd..020de967 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -773,7 +773,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const currentWeight = formik.values.body_weights?.[idx]; if (currentWeight) { - const qty = currentWeight.qty; + const qty = Number(currentWeight.qty) || 0; if (qty > 0 && value > 0) { const avgWeight = parseFloat((value / qty).toFixed(2)); formik.setFieldValue(`body_weights.${idx}.avg_weight`, avgWeight); @@ -794,7 +794,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const currentWeight = formik.values.body_weights?.[idx]; if (currentWeight) { - const qty = currentWeight.qty; + const qty = Number(currentWeight.qty) || 0; if (qty > 0 && value > 0) { const totalWeight = value * qty; formik.setFieldValue(`body_weights.${idx}.weight`, totalWeight); @@ -815,7 +815,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const currentWeight = formik.values.body_weights?.[idx]; if (currentWeight) { - const weight = currentWeight.weight; + const weight = Number(currentWeight.weight) || 0; if (value > 0 && weight > 0) { const avgWeight = parseFloat((weight / value).toFixed(2)); formik.setFieldValue(`body_weights.${idx}.avg_weight`, avgWeight); @@ -864,7 +864,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...(formik.values.stocks || []), { product_warehouse_id: 0, - usage_qty: 0, + usage_qty: '', }, ]; formik.setFieldValue('stocks', newStocks); @@ -897,7 +897,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...(formik.values.depletions || []), { product_warehouse_id: 0, - qty: 0, + qty: '', }, ]; formik.setFieldValue('depletions', newDepletions); @@ -932,7 +932,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...((formik.values as RecordingLayingFormValues).eggs || []), { product_warehouse_id: 0, - qty: 0, + qty: '', }, ]; formik.setFieldValue('eggs', newEggs); @@ -966,7 +966,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { 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 }]); + formik.setFieldValue('eggs', [{ product_warehouse_id: 0, qty: '' }]); } } }, [isLayingCategory, type, formik]); @@ -998,11 +998,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (idx === editingAverageIndex || manuallyEditedRows.has(idx)) { return weight; } + const qty = Number(weight.qty) || 0; + const weightValue = Number(weight.weight) || 0; return { ...weight, avg_weight: - weight.qty > 0 && weight.weight > 0 - ? parseFloat((weight.weight / weight.qty).toFixed(2)) + qty > 0 && weightValue > 0 + ? parseFloat((weightValue / qty).toFixed(2)) : 0, }; } @@ -1303,7 +1305,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { thousandSeparator=',' decimalSeparator='.' inputSuffix='gram' + placeholder='Masukkan berat total...' isError={ isRepeaterInputError('body_weights', 'weight', idx) .isError @@ -1329,13 +1332,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { { thousandSeparator=',' decimalSeparator='.' inputSuffix='gram' + placeholder='Masukkan berat rata-rata...' isError={ isRepeaterInputError( 'body_weights', @@ -1574,7 +1579,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { className={{ wrapper: 'w-full min-w-24', }} - placeholder='Jumlah Pakai' + placeholder='Masukkan jumlah pakai' /> {(type as 'add' | 'edit' | 'detail') !== 'detail' && getStockUsageAdornment(idx)} @@ -1787,7 +1792,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { className={{ wrapper: 'w-full min-w-24', }} - placeholder='Jumlah' + placeholder='Masukkan jumlah deplesi' /> {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( @@ -1998,7 +2003,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { className={{ wrapper: 'w-full min-w-24', }} - placeholder='Jumlah' + placeholder='Masukkan jumlah telur' />
@@ -2191,10 +2196,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { toast.success( 'Recording berhasil disimpan! Mengalihkan ke form Grading...' ); - // Redirect ke grading form setelah submit berhasil setTimeout(() => { router.push( - '/production/recording/grading/add?recording_id=new' + '/production/recording/grading/add?recording_id=1' ); }, 1000); } From aac7215be7a701da99b9cf82f2c2e1159a4039b4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sun, 2 Nov 2025 23:14:07 +0700 Subject: [PATCH 082/174] refactor(FE-170,174): update schema and validation for stocks and depletions; rename usage_qty to qty for consistency --- .../recording/form/RecordingForm.schema.ts | 10 +++--- .../recording/form/RecordingForm.tsx | 34 +++++++++---------- src/types/api/production/recording.d.ts | 5 ++- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 5bd914b2..f91a4e7b 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -20,7 +20,7 @@ type RecordingGrowingFormSchemaType = { }[]; stocks: { product_warehouse_id: number; - usage_qty: number | string; + qty: number | string; }[]; depletions: { product_warehouse_id: number; @@ -51,7 +51,7 @@ export type BodyWeightSchema = { export type StockSchema = { product_warehouse_id: number; - usage_qty: number | string; + qty: number | string; }; export type DepletionSchema = { @@ -84,7 +84,7 @@ const StockObjectSchema: Yup.ObjectSchema = Yup.object({ .required('Produk wajib diisi!') .min(1, 'Produk wajib diisi!') .typeError('Produk harus berupa angka!'), - usage_qty: Yup.number() + qty: Yup.number() .required('Jumlah penggunaan wajib diisi!') .min(1, 'Jumlah penggunaan tidak boleh 0!') .typeError('Jumlah penggunaan harus berupa angka!'), @@ -266,12 +266,12 @@ export const getRecordingGrowingFormInitialValues = ( stocks: initialValues?.stocks?.map( (stock: NonNullable[0]) => ({ product_warehouse_id: stock.product_warehouse_id, - usage_qty: stock.usage_qty, + qty: stock.qty, }) ) ?? [ { product_warehouse_id: 0, - usage_qty: '', + qty: '', }, ], depletions: initialValues?.depletions?.map( diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 020de967..26d6aad7 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -380,25 +380,25 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const layingValues = values as RecordingLayingFormValues; const layingPayload = { - project_flock_kandangs_id: layingValues.project_flock_kandangs_id, + project_flock_kandang_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, + qty: Number(bw.qty) || 0, })), stocks: (layingValues.stocks ?? []).map((stock) => ({ product_warehouse_id: stock.product_warehouse_id, - usage_qty: stock.usage_qty || 0, + qty: Number(stock.qty) || 0, })), depletions: (layingValues.depletions ?? []).map((depletion) => ({ product_warehouse_id: depletion.product_warehouse_id, - qty: depletion.qty || 0, + qty: Number(depletion.qty) || 0, })), eggs: (layingValues.eggs ?? []).map((egg) => ({ product_warehouse_id: egg.product_warehouse_id, - qty: egg.qty || 0, + qty: Number(egg.qty) || 0, })), }; @@ -419,21 +419,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const growingValues = values as RecordingGrowingFormValues; const growingPayload = { - project_flock_kandangs_id: growingValues.project_flock_kandangs_id, + project_flock_kandang_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, + qty: Number(bw.qty) || 0, })), stocks: (growingValues.stocks ?? []).map((stock) => ({ product_warehouse_id: stock.product_warehouse_id, - usage_qty: stock.usage_qty || 0, + qty: Number(stock.qty) || 0, })), depletions: (growingValues.depletions ?? []).map((depletion) => ({ product_warehouse_id: depletion.product_warehouse_id, - qty: depletion.qty || 0, + qty: Number(depletion.qty) || 0, })), }; @@ -493,7 +493,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const stock = formik.values.stocks?.[stockIdx]; if (!stock || !stock.product_warehouse_id) return null; const availableStock = getAvailableStock(stock.product_warehouse_id); - const requestedUsage = Number(stock.usage_qty) || 0; + const requestedUsage = Number(stock.qty) || 0; if (requestedUsage > availableStock) { return `Jumlah pakai melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('en-US')}`; } @@ -508,7 +508,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const stock = formik.values.stocks?.[stockIdx]; if (!stock || !stock.product_warehouse_id) return null; const availableStock = getAvailableStock(stock.product_warehouse_id); - const requestedUsage = Number(stock.usage_qty) || 0; + const requestedUsage = Number(stock.qty) || 0; const remainingStock = availableStock - requestedUsage; if (requestedUsage > 0) { return ( @@ -864,7 +864,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...(formik.values.stocks || []), { product_warehouse_id: 0, - usage_qty: '', + qty: '', }, ]; formik.setFieldValue('stocks', newStocks); @@ -873,7 +873,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const handleStockUsageQtyChangeWrapper = useCallback( (idx: number) => (e: React.ChangeEvent) => { const value = parseFloat(e.target.value) || 0; - formik.setFieldValue(`stocks.${idx}.usage_qty`, value); + formik.setFieldValue(`stocks.${idx}.qty`, value); }, [formik] ); @@ -1578,8 +1578,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{ thousandSeparator=',' decimalSeparator='.' isError={ - isRepeaterInputError('stocks', 'usage_qty', idx) + isRepeaterInputError('stocks', 'qty', idx) .isError || Boolean(getStockUsageError(idx)) } errorMessage={ - isRepeaterInputError('stocks', 'usage_qty', idx) + isRepeaterInputError('stocks', 'qty', idx) .errorMessage || getStockUsageError(idx) || undefined diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index 171ba09e..34f49c08 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -69,15 +69,14 @@ export type Recording = BaseMetadata & }; export type CreateGrowingRecordingPayload = { - project_flock_kandangs_id: number; + project_flock_kandang_id: number; body_weights: { avg_weight: number; qty: number; }[]; stocks?: { product_warehouse_id: number; - usage_qty: number; - pending_qty?: number; + qty: number; }[]; depletions?: { product_warehouse_id: number; From b976600099bd3503c03658fbbf75e6ff86f0e0f5 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sun, 2 Nov 2025 23:22:37 +0700 Subject: [PATCH 083/174] refactor(FE-170,174): update GradingForm to include recording_egg_id in grading data enhance validation schema --- .../recording/form/RecordingForm.schema.ts | 23 +++++------- .../recording/grading/form/GradingForm.tsx | 36 +++++++++++-------- src/types/api/production/recording.d.ts | 4 +-- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index f91a4e7b..6660e491 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -36,8 +36,8 @@ type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & { }; type RecordingGradingFormSchemaType = { - recording_egg_id: number; eggs_grading: { + recording_egg_id: number; grade: string; qty: number | string; }[]; @@ -193,13 +193,13 @@ export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({ export const RecordingGradingFormSchema: Yup.ObjectSchema = Yup.object({ - recording_egg_id: Yup.number() - .required('Recording Egg ID wajib diisi!') - .min(1, 'Recording Egg ID minimal 1!') - .typeError('Recording Egg ID harus berupa angka!'), eggs_grading: Yup.array() .of( Yup.object({ + recording_egg_id: Yup.number() + .required('Recording Egg ID wajib diisi!') + .min(1, 'Recording Egg ID minimal 1!') + .typeError('Recording Egg ID harus berupa angka!'), grade: Yup.string() .required('Grade telur wajib diisi!') .typeError('Grade telur harus berupa string!'), @@ -213,13 +213,7 @@ export const RecordingGradingFormSchema: Yup.ObjectSchema + initialValues?: Partial & { recording_egg_id?: number } ): RecordingGradingFormValues => ({ - recording_egg_id: initialValues?.recording_egg_id ?? 0, eggs_grading: initialValues?.eggs_grading?.map((grading) => ({ + recording_egg_id: grading.recording_egg_id, grade: grading.grade, qty: grading.qty, })) ?? [ { + recording_egg_id: initialValues?.recording_egg_id ?? 0, grade: '', qty: '', }, diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx index d07e61b1..57e55f3d 100644 --- a/src/components/pages/production/recording/grading/form/GradingForm.tsx +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -3,7 +3,6 @@ import { useMemo, useState, useEffect, useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useFormik } from 'formik'; -import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; @@ -12,7 +11,6 @@ import SelectInput, { OptionType } from '@/components/input/SelectInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { FormHeader } from '@/components/helper/form/FormHeader'; -import { RecordingApi } from '@/services/api/production'; import { CreateGradingPayload, UpdateGradingPayload, @@ -50,8 +48,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { const [formSteps, setFormSteps] = useState(null); // ===== API DATA FETCHING ===== - // Fetch existing gradings for the recording egg to show recorded data - // Optional: Fetch existing gradings if needed for display purposes // const existingGradingsUrl = useMemo(() => { // const recordingEggIdToUse = recordingId || initialValues?.id?.toString(); // if (!recordingEggIdToUse) return null; @@ -64,7 +60,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { // ); // ===== DATA PROCESSING ===== - // No data processing needed - grading form only needs recording_egg_id and grading data // ===== FORM HANDLERS ===== const { @@ -88,6 +83,7 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { recording_egg_id: recordingEggId, eggs_grading: initialValues?.grading_eggs?.map((grading: GradingEgg) => ({ + recording_egg_id: recordingEggId, grade: grading.grade, qty: grading.qty, })) || [], @@ -105,8 +101,8 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { validateOnBlur: true, onSubmit: async (values) => { const gradingPayload = { - recording_egg_id: values.recording_egg_id, eggs_grading: (values.eggs_grading ?? []).map((grading) => ({ + recording_egg_id: grading.recording_egg_id, grade: grading.grade, qty: grading.qty || 0, })), @@ -126,15 +122,20 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { }, }); - // Grading form doesn't need approval/reject handlers - // Grading Handlers const addGrading = () => { + let recordingEggId: number | undefined = initialValues?.id; + + if (!recordingEggId) { + recordingEggId = parseInt(recordingId || '0') || 0; + } + const newGrading = [ ...(formik.values.eggs_grading || []), { + recording_egg_id: recordingEggId, grade: '', - qty: 0, + qty: '', }, ]; formik.setFieldValue('eggs_grading', newGrading); @@ -223,12 +224,18 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { useEffect(() => { if (formik.values.eggs_grading && formik.values.eggs_grading.length === 0) { - formik.setFieldValue('eggs_grading', [{ grade: '', qty: 0 }]); + let recordingEggId: number | undefined = initialValues?.id; + + if (!recordingEggId) { + recordingEggId = parseInt(recordingId || '0') || 0; + } + + formik.setFieldValue('eggs_grading', [ + { recording_egg_id: recordingEggId, grade: '', qty: '' }, + ]); } }, [formik]); - // Grading form doesn't need loading state since it only depends on recording_egg_id - return ( <>
@@ -320,7 +327,8 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { ) : ( <> - Recording Egg ID#{formik.values.recording_egg_id} + Recording Egg ID# + {formik.values.eggs_grading?.[0]?.recording_egg_id || '-'} )}
@@ -473,7 +481,7 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { className={{ wrapper: 'w-full min-w-24', }} - placeholder='Jumlah' + placeholder='Masukkan jumlah telur' /> {type !== 'detail' && ( diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index 34f49c08..5b544175 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -85,8 +85,8 @@ export type CreateGrowingRecordingPayload = { }; export type CreateGradingPayload = { - recording_egg_id: number; eggs_grading: { + recording_egg_id: number; grade: string; qty: number; }[]; @@ -95,8 +95,8 @@ export type CreateGradingPayload = { export type UpdateGradingPayload = CreateGradingPayload; export type CreateGradingRecordingPayload = { - recording_egg_id: number; eggs_grading: { + recording_egg_id: number; grade: string; qty: number; }[]; From c26e17488598f4948dfea9c3c075fe4516a7a1d8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 3 Nov 2025 09:00:49 +0700 Subject: [PATCH 084/174] refactor(FE-174): enhance RecordingApi by adding approve and reject methods for better approval handling --- .../production/recording/RecordingTable.tsx | 28 ++++--------- .../recording/form/RecordingForm.tsx | 28 ++++--------- src/services/api/production.ts | 41 ++++++++++++++++++- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 5e018e61..45ed54a5 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -226,16 +226,10 @@ const RecordingTable = () => { const bulkApproveHandler = async () => { setIsBulkApproveLoading(true); - const approveResponse = await RecordingApi.customRequest< - BaseApiResponse - >('approvals', { - method: 'POST', - payload: { - action: 'APPROVED', - approvable_ids: selectedRowIds, - notes: 'Bulk Approved', - }, - }); + const approveResponse = await RecordingApi.approve( + selectedRowIds, + 'Bulk Approved' + ); if (isResponseSuccess(approveResponse)) { await refreshRecordings(); @@ -255,16 +249,10 @@ const RecordingTable = () => { const bulkRejectHandler = async () => { setIsBulkRejectLoading(true); - const rejectResponse = await RecordingApi.customRequest< - BaseApiResponse - >('approvals', { - method: 'POST', - payload: { - action: 'REJECTED', - approvable_ids: selectedRowIds, - notes: 'Bulk Rejected', - }, - }); + const rejectResponse = await RecordingApi.reject( + selectedRowIds, + 'Bulk Rejected' + ); if (isResponseSuccess(rejectResponse)) { refreshRecordings(); diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 26d6aad7..7e888cd5 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -696,16 +696,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const approveHandler = async () => { setIsApproveLoading(true); - const approveResponse = await RecordingApi.customRequest< - BaseApiResponse - >('approvals', { - method: 'POST', - payload: { - action: 'APPROVED', - approvable_ids: [initialValues?.id as number], - notes: 'Approved via Form', - }, - }); + const approveResponse = await RecordingApi.approve( + initialValues?.id as number, + 'Approved via Form' + ); if (isResponseSuccess(approveResponse)) { toast.success('Recording berhasil disetujui!'); @@ -724,16 +718,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const rejectHandler = async () => { setIsRejectLoading(true); - const rejectResponse = await RecordingApi.customRequest< - BaseApiResponse - >('approvals', { - method: 'POST', - payload: { - action: 'REJECTED', - approvable_ids: [initialValues?.id as number], - notes: 'Rejected via Form', - }, - }); + const rejectResponse = await RecordingApi.reject( + initialValues?.id as number, + 'Rejected via Form' + ); if (isResponseSuccess(rejectResponse)) { toast.success('Recording berhasil ditolak!'); diff --git a/src/services/api/production.ts b/src/services/api/production.ts index 5c2754c8..788b9eb7 100644 --- a/src/services/api/production.ts +++ b/src/services/api/production.ts @@ -1,4 +1,5 @@ import { BaseApiService } from './base'; +import { BaseApiResponse } from '@/types/api/api-general'; import { CreateProjectFlockPayload, ProjectFlock, @@ -20,11 +21,47 @@ export const ProjectFlockApi = new BaseApiService< CreateProjectFlockPayload, UpdateProjectFlockPayload >('/production/project_flocks'); -export const RecordingApi = new BaseApiService< +export class RecordingService extends BaseApiService< Recording, CreateRecordingPayload, UpdateRecordingPayload ->('/production/recordings'); +> { + constructor(basePath: string = '') { + super(basePath); + } + + async approve( + idOrIds: number | number[], + notes: string = 'Approved via Form' + ): Promise | undefined> { + const approvable_ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + return await this.customRequest>('approvals', { + method: 'POST', + payload: { + action: 'APPROVED', + approvable_ids, + notes, + }, + }); + } + + async reject( + idOrIds: number | number[], + notes: string = 'Rejected via Form' + ): Promise | undefined> { + const approvable_ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + return await this.customRequest>('approvals', { + method: 'POST', + payload: { + action: 'REJECTED', + approvable_ids, + notes, + }, + }); + } +} + +export const RecordingApi = new RecordingService('/production/recordings'); export const ChickinApi = new BaseApiService< Chickin, CreateChickinPayload, From 9a4d961deef4e58670e1fbb1b7b9dd48149a52ca Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 3 Nov 2025 09:28:20 +0700 Subject: [PATCH 085/174] refactor(FE-174): implement create, update, and delete grading methods in RecordingApi and update handlers --- .../recording/grading/form/GradingForm.tsx | 1 - .../grading/form/useGradingFormHandlers.ts | 45 +++++++++++-------- src/services/api/production.ts | 35 +++++++++++++++ 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx index 57e55f3d..4f41fc16 100644 --- a/src/components/pages/production/recording/grading/form/GradingForm.tsx +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -29,7 +29,6 @@ import toast from 'react-hot-toast'; import Card from '@/components/Card'; import StepItem from '@/components/steps/StepItem'; -import { useModal } from '@/components/Modal'; import { useGradingFormHandlers } from './useGradingFormHandlers'; interface GradingFormProps { diff --git a/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts b/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts index c24a644b..6cc5c8b0 100644 --- a/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts +++ b/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts @@ -19,14 +19,15 @@ export const useGradingFormHandlers = (gradingId?: number) => { const createGradingHandler = useCallback( async (payload: CreateGradingPayload) => { - const res = await RecordingApi.customRequest('gradings', { - method: 'POST', - payload, - }) as BaseApiResponse; - if (isResponseError(res)) { - setRecordingFormErrorMessage(res.message); + const res = (await RecordingApi.createGrading(payload)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setRecordingFormErrorMessage(res?.message || 'Failed to add Grading'); return; } + toast.success(res?.message || 'Successfully added Grading!'); router.push('/production/recording'); }, @@ -35,12 +36,14 @@ export const useGradingFormHandlers = (gradingId?: number) => { const updateGradingHandler = useCallback( async (gradingId: number, payload: UpdateGradingPayload) => { - const res = await RecordingApi.customRequest(`gradings/${gradingId}`, { - method: 'PUT', - payload, - }) as BaseApiResponse; - if (isResponseError(res)) { - setRecordingFormErrorMessage(res.message); + const res = (await RecordingApi.updateGrading(gradingId, payload)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setRecordingFormErrorMessage( + res?.message || 'Failed to update Grading' + ); return; } toast.success(res?.message || 'Successfully updated Grading!'); @@ -59,17 +62,21 @@ export const useGradingFormHandlers = (gradingId?: number) => { setIsDeleteLoading(true); try { - const res = await RecordingApi.customRequest(`gradings/${gradingId}`, { - method: 'DELETE', - }) as BaseApiResponse; - if (isResponseError(res)) { - setRecordingFormErrorMessage(res.message); + const res = (await RecordingApi.deleteGrading(gradingId)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setRecordingFormErrorMessage( + res?.message || 'Failed to delete Grading' + ); return; } deleteModal.closeModal(); toast.success(res?.message || 'Successfully delete Grading!'); router.push('/production/recording'); - } catch (error) { + } catch (err) { + console.error(err); setRecordingFormErrorMessage('Failed to delete Grading'); } finally { setIsDeleteLoading(false); @@ -85,4 +92,4 @@ export const useGradingFormHandlers = (gradingId?: number) => { deleteRecordingClickHandler, confirmationModalDeleteClickHandler, }; -}; \ No newline at end of file +}; diff --git a/src/services/api/production.ts b/src/services/api/production.ts index 788b9eb7..a2853803 100644 --- a/src/services/api/production.ts +++ b/src/services/api/production.ts @@ -9,6 +9,8 @@ import { CreateRecordingPayload, Recording, UpdateRecordingPayload, + CreateGradingPayload, + UpdateGradingPayload, } from '@/types/api/production/recording'; import { Chickin, @@ -59,6 +61,39 @@ export class RecordingService extends BaseApiService< }, }); } + + async createGrading( + payload: CreateGradingPayload + ): Promise | undefined> { + return await this.customRequest>('gradings', { + method: 'POST', + payload, + }); + } + + async updateGrading( + gradingId: number, + payload: UpdateGradingPayload + ): Promise | undefined> { + return await this.customRequest>( + `gradings/${gradingId}`, + { + method: 'PUT', + payload, + } + ); + } + + async deleteGrading( + gradingId: number + ): Promise | undefined> { + return await this.customRequest>( + `gradings/${gradingId}`, + { + method: 'DELETE', + } + ); + } } export const RecordingApi = new RecordingService('/production/recordings'); From ac11559754870720321a9c9e186a7ed2df10c9f6 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 3 Nov 2025 09:35:52 +0700 Subject: [PATCH 086/174] chore(FE-Storyless): remove inputmask package and its type definitions from dependencies --- package-lock.json | 27 +++++++++++---------------- package.json | 2 -- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33b7c640..2fa0e38b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -31,7 +30,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1639,13 +1637,6 @@ "@types/react": "*" } }, - "node_modules/@types/inputmask": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz", - "integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1681,6 +1672,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1750,6 +1742,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2267,6 +2260,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2800,7 +2794,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/daisyui": { "version": "5.3.10", @@ -3228,6 +3223,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3401,6 +3397,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4203,12 +4200,6 @@ "node": ">=0.8.19" } }, - "node_modules/inputmask": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz", - "integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==", - "license": "MIT" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5745,6 +5736,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5754,6 +5746,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6552,6 +6545,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6719,6 +6713,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 10fe9598..1d181a40 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -34,7 +33,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", From 2ba23654ceffa3de871aca6d4deb2007f6b2ff4e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 3 Nov 2025 09:44:50 +0700 Subject: [PATCH 087/174] refactor(FE-Storyless): simplify RowOptionsMenu by using RowOptionsMenuWrapper and enhance button layout --- .../inventory/movement/MovementTable.tsx | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index 6ed4c49a..8ff39e3d 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -20,6 +20,7 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import SelectInput from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; const RowOptionsMenu = ({ type = 'dropdown', @@ -27,30 +28,19 @@ const RowOptionsMenu = ({ }: { type: 'dropdown' | 'collapse'; props: CellContext; -}) => { - return ( -
( + + -
- ); -}; + + Detail + + +); const MovementTable = () => { const { @@ -175,7 +165,7 @@ const MovementTable = () => { {currentPageSize <= 2 && ( - + )} @@ -188,15 +178,16 @@ const MovementTable = () => { <>
-
-
+
+
From e9e8ad771eafac1db87fe02b0c175b283282ed65 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 3 Nov 2025 10:22:35 +0700 Subject: [PATCH 088/174] refactor(FE-174): enhance GradingForm and RecordingForm with improved error handling and modal integration for delete actions --- .../recording/form/RecordingForm.tsx | 66 ++++++++++--- .../form/useRecordingFormHandlers.ts | 70 -------------- .../recording/grading/form/GradingForm.tsx | 86 ++++++++++++++--- .../grading/form/useGradingFormHandlers.ts | 95 ------------------- 4 files changed, 126 insertions(+), 191 deletions(-) delete mode 100644 src/components/pages/production/recording/form/useRecordingFormHandlers.ts delete mode 100644 src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 7e888cd5..e6eba21c 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -31,11 +31,10 @@ import { UpdateRecordingGrowingFormSchema, UpdateRecordingLayingFormSchema, } from './RecordingForm.schema'; -import { useRecordingFormHandlers } from './useRecordingFormHandlers'; import { ProjectFlockApi } from '@/services/api/production'; import { LocationApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { cn } from '@/lib/helper'; import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; import { useModal } from '@/components/Modal'; @@ -77,9 +76,61 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [isApproveLoading, setIsApproveLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false); const [formSteps, setFormSteps] = useState(null); + const [recordingFormErrorMessage, setRecordingFormErrorMessage] = + useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); const approveModal = useModal(); const rejectModal = useModal(); + const deleteModal = useModal(); + + // ===== FORM HANDLERS ===== + const createRecordingHandler = useCallback( + async ( + payload: CreateGrowingRecordingPayload | CreateLayingRecordingPayload + ) => { + const res = await RecordingApi.create(payload); + if (isResponseError(res)) { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/production/recording'); + }, + [router] + ); + + const updateRecordingHandler = useCallback( + async ( + recordingId: number, + payload: UpdateGrowingRecordingPayload | UpdateLayingRecordingPayload + ) => { + const res = await RecordingApi.update(recordingId, payload); + if (res?.status === 'error') { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/production/recording'); + }, + [router] + ); + + const deleteRecordingClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!initialValues?.id) return; + + setIsDeleteLoading(true); + await RecordingApi.delete(initialValues.id); + deleteModal.closeModal(); + toast.success('Successfully delete Recording!'); + setIsDeleteLoading(false); + router.push('/production/recording'); + }, [deleteModal, initialValues?.id, router]); // ===== API DATA FETCHING ===== const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ @@ -336,17 +387,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return options; }, [eggProductsData]); - // ===== FORM HANDLERS ===== - const { - deleteModal, - recordingFormErrorMessage, - isDeleteLoading, - createRecordingHandler, - updateRecordingHandler, - deleteRecordingClickHandler, - confirmationModalDeleteClickHandler, - } = useRecordingFormHandlers(initialValues?.id); - const isLayingCategory = projectFlockKandangLookup?.project_flock?.category === 'LAYING'; diff --git a/src/components/pages/production/recording/form/useRecordingFormHandlers.ts b/src/components/pages/production/recording/form/useRecordingFormHandlers.ts deleted file mode 100644 index 58893ce1..00000000 --- a/src/components/pages/production/recording/form/useRecordingFormHandlers.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { toast } from 'react-hot-toast'; -import { useModal } from '@/components/Modal'; -import { RecordingApi } from '@/services/api/production'; -import { - CreateRecordingPayload, - UpdateRecordingPayload, -} from '@/types/api/production/recording'; -import { isResponseError } from '@/lib/api-helper'; - -export const useRecordingFormHandlers = (initialValuesId?: number) => { - const router = useRouter(); - const deleteModal = useModal(); - const [recordingFormErrorMessage, setRecordingFormErrorMessage] = - useState(''); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const createRecordingHandler = useCallback( - async (payload: CreateRecordingPayload) => { - const res = await RecordingApi.create(payload); - if (isResponseError(res)) { - setRecordingFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.push('/production/recording'); - }, - [router] - ); - - const updateRecordingHandler = useCallback( - async (recordingId: number, payload: UpdateRecordingPayload) => { - const res = await RecordingApi.update(recordingId, payload); - if (res?.status === 'error') { - setRecordingFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.refresh(); - router.push('/production/recording'); - }, - [router] - ); - - const deleteRecordingClickHandler = useCallback(() => { - deleteModal.openModal(); - }, [deleteModal]); - - const confirmationModalDeleteClickHandler = useCallback(async () => { - if (!initialValuesId) return; - - setIsDeleteLoading(true); - await RecordingApi.delete(initialValuesId); - deleteModal.closeModal(); - toast.success('Successfully delete Recording!'); - setIsDeleteLoading(false); - router.push('/production/recording'); - }, [deleteModal, initialValuesId, router]); - - return { - deleteModal, - recordingFormErrorMessage, - isDeleteLoading, - createRecordingHandler, - updateRecordingHandler, - deleteRecordingClickHandler, - confirmationModalDeleteClickHandler, - }; -}; diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx index 4f41fc16..9979c2cc 100644 --- a/src/components/pages/production/recording/grading/form/GradingForm.tsx +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -17,7 +17,10 @@ import { RecordingEgg, GradingEgg, } from '@/types/api/production/recording'; -import { type FormStepStatus } from '@/types/api/api-general'; +import { + type FormStepStatus, + type BaseApiResponse, +} from '@/types/api/api-general'; import { RecordingGradingFormSchema, RecordingGradingFormValues, @@ -26,10 +29,12 @@ import { } from '../../form/RecordingForm.schema'; import { cn } from '@/lib/helper'; import toast from 'react-hot-toast'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseError } from '@/lib/api-helper'; +import { useModal } from '@/components/Modal'; import Card from '@/components/Card'; import StepItem from '@/components/steps/StepItem'; -import { useGradingFormHandlers } from './useGradingFormHandlers'; interface GradingFormProps { type?: 'add' | 'edit' | 'detail'; @@ -45,6 +50,9 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { ); const [formSteps, setFormSteps] = useState(null); + const [gradingFormErrorMessage, setGradingFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const deleteModal = useModal(); // ===== API DATA FETCHING ===== // const existingGradingsUrl = useMemo(() => { @@ -61,15 +69,67 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { // ===== DATA PROCESSING ===== // ===== FORM HANDLERS ===== - const { - deleteModal, - recordingFormErrorMessage, - isDeleteLoading, - createGradingHandler, - updateGradingHandler, - deleteRecordingClickHandler, - confirmationModalDeleteClickHandler, - } = useGradingFormHandlers(initialValues?.id); + const createGradingHandler = useCallback( + async (payload: CreateGradingPayload) => { + const res = (await RecordingApi.createGrading(payload)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setGradingFormErrorMessage(res?.message || 'Failed to add Grading'); + return; + } + + toast.success(res?.message || 'Successfully added Grading!'); + router.push('/production/recording'); + }, + [router] + ); + + const updateGradingHandler = useCallback( + async (gradingId: number, payload: UpdateGradingPayload) => { + const res = (await RecordingApi.updateGrading(gradingId, payload)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setGradingFormErrorMessage(res?.message || 'Failed to update Grading'); + return; + } + toast.success(res?.message || 'Successfully updated Grading!'); + router.refresh(); + router.push('/production/recording'); + }, + [router] + ); + + const deleteRecordingClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!initialValues?.id) return; + + setIsDeleteLoading(true); + try { + const res = (await RecordingApi.deleteGrading(initialValues.id)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setGradingFormErrorMessage(res?.message || 'Failed to delete Grading'); + return; + } + deleteModal.closeModal(); + toast.success(res?.message || 'Successfully delete Grading!'); + router.push('/production/recording'); + } catch (err) { + console.error(err); + setGradingFormErrorMessage('Failed to delete Grading'); + } finally { + setIsDeleteLoading(false); + } + }, [deleteModal, initialValues?.id, router]); const formikInitialValues = useMemo(() => { let recordingEggId: number | undefined = initialValues?.id; @@ -599,14 +659,14 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
)}
- {recordingFormErrorMessage && ( + {gradingFormErrorMessage && (
- {recordingFormErrorMessage} + {gradingFormErrorMessage}
)} diff --git a/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts b/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts deleted file mode 100644 index 6cc5c8b0..00000000 --- a/src/components/pages/production/recording/grading/form/useGradingFormHandlers.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { toast } from 'react-hot-toast'; -import { useModal } from '@/components/Modal'; -import { RecordingApi } from '@/services/api/production'; -import { - CreateGradingPayload, - UpdateGradingPayload, -} from '@/types/api/production/recording'; -import { isResponseError } from '@/lib/api-helper'; -import { type BaseApiResponse } from '@/types/api/api-general'; - -export const useGradingFormHandlers = (gradingId?: number) => { - const router = useRouter(); - const deleteModal = useModal(); - const [recordingFormErrorMessage, setRecordingFormErrorMessage] = - useState(''); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const createGradingHandler = useCallback( - async (payload: CreateGradingPayload) => { - const res = (await RecordingApi.createGrading(payload)) as - | BaseApiResponse - | undefined; - - if (!res || isResponseError(res)) { - setRecordingFormErrorMessage(res?.message || 'Failed to add Grading'); - return; - } - - toast.success(res?.message || 'Successfully added Grading!'); - router.push('/production/recording'); - }, - [router] - ); - - const updateGradingHandler = useCallback( - async (gradingId: number, payload: UpdateGradingPayload) => { - const res = (await RecordingApi.updateGrading(gradingId, payload)) as - | BaseApiResponse - | undefined; - - if (!res || isResponseError(res)) { - setRecordingFormErrorMessage( - res?.message || 'Failed to update Grading' - ); - return; - } - toast.success(res?.message || 'Successfully updated Grading!'); - router.refresh(); - router.push('/production/recording'); - }, - [router] - ); - - const deleteRecordingClickHandler = useCallback(() => { - deleteModal.openModal(); - }, [deleteModal]); - - const confirmationModalDeleteClickHandler = useCallback(async () => { - if (!gradingId) return; - - setIsDeleteLoading(true); - try { - const res = (await RecordingApi.deleteGrading(gradingId)) as - | BaseApiResponse - | undefined; - - if (!res || isResponseError(res)) { - setRecordingFormErrorMessage( - res?.message || 'Failed to delete Grading' - ); - return; - } - deleteModal.closeModal(); - toast.success(res?.message || 'Successfully delete Grading!'); - router.push('/production/recording'); - } catch (err) { - console.error(err); - setRecordingFormErrorMessage('Failed to delete Grading'); - } finally { - setIsDeleteLoading(false); - } - }, [deleteModal, gradingId, router]); - - return { - deleteModal, - recordingFormErrorMessage, - isDeleteLoading, - createGradingHandler, - updateGradingHandler, - deleteRecordingClickHandler, - confirmationModalDeleteClickHandler, - }; -}; From bcb4d4492d01ebce05f008544e309fdedc7d2d51 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 3 Nov 2025 10:30:33 +0700 Subject: [PATCH 089/174] refactor(FE-170): replace FormHeader with custom header in GradingForm and RecordingForm for improved layout and navigation --- .../recording/form/RecordingForm.tsx | 22 +++++++++++++------ .../recording/grading/form/GradingForm.tsx | 22 +++++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index e6eba21c..30aee0ac 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -11,7 +11,6 @@ import NumberInput from '@/components/input/NumberInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; -import { FormHeader } from '@/components/helper/form/FormHeader'; import { RecordingApi } from '@/services/api/production'; import { CreateGrowingRecordingPayload, @@ -1058,12 +1057,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return ( <>
- - +
+ +

+ {type === 'add' && 'Tambah Recording'} + {type === 'edit' && 'Edit Recording'} + {type === 'detail' && 'Detail Recording'} +

+
{/* Project Flock Info Card */} {projectFlockKandangLookup && (
diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx index 9979c2cc..0acaaa02 100644 --- a/src/components/pages/production/recording/grading/form/GradingForm.tsx +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -10,7 +10,6 @@ import NumberInput from '@/components/input/NumberInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; -import { FormHeader } from '@/components/helper/form/FormHeader'; import { CreateGradingPayload, UpdateGradingPayload, @@ -298,12 +297,21 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { return ( <>
- - +
+ +

+ {type === 'add' && 'Tambah Grading'} + {type === 'edit' && 'Edit Grading'} + {type === 'detail' && 'Detail Grading'} +

+
{/* Project Flock Info Card */}
{/* Form Steps */} From d8599a850aa9613e6eb9b441b84afdfab1019b4b Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 3 Nov 2025 10:40:13 +0700 Subject: [PATCH 090/174] refactor(FE-170): streamline RecordingTable component by removing unused state and optimizing layout for better usability --- .../production/recording/RecordingTable.tsx | 522 ++++-------------- 1 file changed, 114 insertions(+), 408 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index b4dbee3a..722589c8 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -1,26 +1,22 @@ 'use client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; -import { SortingState } from '@tanstack/react-table'; +import { SortingState, CellContext } from '@tanstack/react-table'; import { cn } from '@/lib/helper'; import { useModal } from '@/components/Modal'; import Button from '@/components/Button'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { OptionType } from '@/components/input/SelectInput'; import SelectInput from '@/components/input/SelectInput'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import { ROWS_OPTIONS } from '@/config/constant'; -import CheckboxInput from '@/components/input/CheckboxInput'; -import { TableToolbar } from '@/components/table/TableToolbar'; -import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; import Table from '@/components/Table'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; -import { type CellContext } from '@tanstack/react-table'; import { type Recording } from '@/types/api/production/recording'; -import { type BaseApiResponse } from '@/types/api/api-general'; import { RecordingApi } from '@/services/api/production'; import { AreaApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data'; @@ -103,17 +99,12 @@ const RecordingTable = () => { }); const [sorting, setSorting] = useState([]); - const [rowSelection, setRowSelection] = useState>({}); const [selectedRecording, setSelectedRecording] = useState< Recording | undefined >(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false); - const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); const singleDeleteModal = useModal(); - const bulkApproveModal = useModal(); - const bulkRejectModal = useModal(); // State for dropdown search const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); @@ -205,62 +196,6 @@ const RecordingTable = () => { [setPageSize, setPage] ); - const paginatedData = useMemo(() => { - if (!recordings || recordings.status !== 'success') return []; - - return recordings.data; - }, [recordings]); - - const selectedRowIds = Object.keys(rowSelection).map((item) => - parseInt(item) - ); - - const bulkApproveHandler = async () => { - setIsBulkApproveLoading(true); - - const approveResponse = await RecordingApi.approve( - selectedRowIds, - 'Bulk Approved' - ); - - if (isResponseSuccess(approveResponse)) { - await refreshRecordings(); - setRowSelection({}); - bulkApproveModal.closeModal(); - toast.success( - `Successfully approved ${selectedRowIds.length} recordings!` - ); - } - if (isResponseError(approveResponse)) { - toast.error(approveResponse?.message as string); - bulkApproveModal.closeModal(); - } - setIsBulkApproveLoading(false); - }; - - const bulkRejectHandler = async () => { - setIsBulkRejectLoading(true); - - const rejectResponse = await RecordingApi.reject( - selectedRowIds, - 'Bulk Rejected' - ); - - if (isResponseSuccess(rejectResponse)) { - refreshRecordings(); - setRowSelection({}); - bulkRejectModal.closeModal(); - toast.success( - `Successfully rejected ${selectedRowIds.length} recordings!` - ); - } - if (isResponseError(rejectResponse)) { - toast.error(rejectResponse?.message as string); - bulkRejectModal.closeModal(); - } - setIsBulkRejectLoading(false); - }; - const singleDeleteHandler = async () => { setIsDeleteLoading(true); @@ -273,315 +208,123 @@ const RecordingTable = () => { }; return ( -
+
- - - - {/* Filter Dropdowns - Desktop */} -
- { - const selectedValue = selected as OptionType | null; - setSelectedArea(selectedValue); - setSelectedLocation(null); - setSelectedKandang(null); - updateFilter( - 'areaFilter', - selectedValue ? selectedValue.value.toString() : '' - ); - updateFilter('locationFilter', ''); - updateFilter('kandangFilter', ''); - setPage(1); - }} - className={{ wrapper: 'w-full' }} - onInputChange={(value) => setAreaSelectInputValue(value)} - isLoading={isLoadingAreas} - isClearable - /> - - { - const selectedValue = selected as OptionType | null; - setSelectedLocation(selectedValue); - setSelectedKandang(null); - updateFilter( - 'locationFilter', - selectedValue ? selectedValue.value.toString() : '' - ); - updateFilter('kandangFilter', ''); - setPage(1); - }} - className={{ wrapper: 'w-full' }} - onInputChange={(value) => setLocationSelectInputValue(value)} - isLoading={isLoadingLocations} - isClearable - isDisabled={!selectedArea} - /> - - { - const selectedValue = selected as OptionType | null; - setSelectedKandang(selectedValue); - updateFilter( - 'kandangFilter', - selectedValue ? selectedValue.value.toString() : '' - ); - setPage(1); - }} - className={{ wrapper: 'w-full' }} - onInputChange={(value) => setKandangSelectInputValue(value)} - isLoading={isLoadingKandang} - isClearable - isDisabled={!selectedLocation} - /> - - { - const selectedValue = selected as OptionType | null; - updateFilter( - 'periodFilter', - selectedValue ? selectedValue.value.toString() : '' - ); - setPage(1); - }} - className={{ wrapper: 'w-full' }} - isClearable - /> -
- - {/* Filter Dropdowns - Mobile */} -
- { - const selectedValue = selected as OptionType | null; - setSelectedArea(selectedValue); - setSelectedLocation(null); - setSelectedKandang(null); - updateFilter( - 'areaFilter', - selectedValue ? selectedValue.value.toString() : '' - ); - updateFilter('locationFilter', ''); - updateFilter('kandangFilter', ''); - setPage(1); - }} - className={{ wrapper: 'w-full' }} - onInputChange={(value) => setAreaSelectInputValue(value)} - isLoading={isLoadingAreas} - isClearable - /> - - { - const selectedValue = selected as OptionType | null; - setSelectedLocation(selectedValue); - setSelectedKandang(null); - updateFilter( - 'locationFilter', - selectedValue ? selectedValue.value.toString() : '' - ); - updateFilter('kandangFilter', ''); - setPage(1); - }} - className={{ wrapper: 'w-full' }} - onInputChange={(value) => setLocationSelectInputValue(value)} - isLoading={isLoadingLocations} - isClearable - isDisabled={!selectedArea} - /> - - { - const selectedValue = selected as OptionType | null; - setSelectedKandang(selectedValue); - updateFilter( - 'kandangFilter', - selectedValue ? selectedValue.value.toString() : '' - ); - setPage(1); - }} - className={{ wrapper: 'w-full' }} - onInputChange={(value) => setKandangSelectInputValue(value)} - isLoading={isLoadingKandang} - isClearable - isDisabled={!selectedLocation} - /> - - { - const selectedValue = selected as OptionType | null; - updateFilter( - 'periodFilter', - selectedValue ? selectedValue.value.toString() : '' - ); - setPage(1); - }} - className={{ wrapper: 'w-full' }} - isClearable - /> -
-
- - {/* Bulk action buttons */} -
- {selectedRowIds.length > 0 && ( -
+
+
-
- )} - + +
- +
+ { + const selectedValue = selected as OptionType | null; + setSelectedArea(selectedValue); + setSelectedLocation(null); + setSelectedKandang(null); + updateFilter( + 'areaFilter', + selectedValue ? selectedValue.value.toString() : '' + ); + updateFilter('locationFilter', ''); + updateFilter('kandangFilter', ''); + setPage(1); + }} + onInputChange={setAreaSelectInputValue} + isClearable + className={{ + wrapper: 'col-span-12 sm:col-span-3', + }} + /> + + { + const selectedValue = selected as OptionType | null; + setSelectedLocation(selectedValue); + setSelectedKandang(null); + updateFilter( + 'locationFilter', + selectedValue ? selectedValue.value.toString() : '' + ); + updateFilter('kandangFilter', ''); + setPage(1); + }} + onInputChange={setLocationSelectInputValue} + isClearable + isDisabled={!selectedArea} + className={{ + wrapper: 'col-span-12 sm:col-span-3', + }} + /> + + { + const selectedValue = selected as OptionType | null; + setSelectedKandang(selectedValue); + updateFilter( + 'kandangFilter', + selectedValue ? selectedValue.value.toString() : '' + ); + setPage(1); + }} + onInputChange={setKandangSelectInputValue} + isClearable + isDisabled={!selectedLocation} + className={{ + wrapper: 'col-span-12 sm:col-span-2', + }} + /> + + +
- + data={isResponseSuccess(recordings) ? recordings?.data : []} columns={[ - { - id: 'select', - header: ({ table }) => ( -
- -
- ), - cell: ({ row }) => ( -
- -
- ), - }, { header: '#', cell: (props) => @@ -609,38 +352,6 @@ const RecordingTable = () => { cell: (props) => props.row.original.total_chick_qty?.toLocaleString() || '-', }, - { - header: 'BW', - cell: (props) => - props.row.original.avg_daily_gain?.toFixed(2) || '-', - }, - { - header: 'Pakan', - cell: (props) => - props.row.original.cum_intake?.toLocaleString() || '-', - }, - { - header: 'FCR', - cell: (props) => props.row.original.fcr_value?.toFixed(2) || '-', - }, - { - accessorKey: 'total_depletion', - header: 'Total Deplesi', - cell: (props) => props.row.original.total_depletion_qty, - }, - { - header: 'Deplesi (%)', - cell: (props) => - props.row.original.daily_depletion_rate?.toFixed(2) || '-', - }, - { - header: 'Populasi Akhir', - cell: (props) => - ( - props.row.original.total_chick_qty - - props.row.original.total_depletion_qty - )?.toLocaleString() || '-', - }, { header: 'Tanggal Submit', cell: (props) => @@ -690,23 +401,18 @@ const RecordingTable = () => { }, ]} pageSize={tableFilterState.pageSize} - page={ - recordings?.status === 'success' - ? recordings.meta?.page - : tableFilterState.page - } + page={isResponseSuccess(recordings) ? recordings?.meta?.page : 0} totalItems={ - recordings?.status === 'success' ? recordings.meta?.total_results : 0 + isResponseSuccess(recordings) ? recordings?.meta?.total_results : 0 } onPageChange={setPage} isLoading={isLoading} sorting={sorting} setSorting={setSorting} - rowSelection={rowSelection} - setRowSelection={setRowSelection} className={{ containerClassName: cn({ - 'mb-20': paginatedData.length === 0, + 'mb-20': + isResponseSuccess(recordings) && recordings?.data?.length === 0, }), tableWrapperClassName: 'overflow-x-auto min-h-full!', tableClassName: 'font-inter w-full table-auto min-h-full!', From e4ab86c3ebfb90c630b050773c1dcf2eef1fef50 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 3 Nov 2025 10:44:37 +0700 Subject: [PATCH 091/174] refactor(FE-170): streamline RecordingForm component by native card and optimizing layout for better usability --- .../inventory/movement/form/MovementForm.tsx | 1566 ++++++++--------- 1 file changed, 776 insertions(+), 790 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 28148706..438c09c6 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -32,6 +32,7 @@ import { MovementApi } from '@/services/api/inventory'; import FileInput from '@/components/input/FileInput'; import CheckboxInput from '@/components/input/CheckboxInput'; import Badge from '@/components/Badge'; +import Card from '@/components/Card'; interface MovementFormProps { type?: 'add' | 'edit' | 'detail'; @@ -768,216 +769,270 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { className='w-full mt-8 flex flex-col gap-6' > {/* Top card - Movement details */} -
-
-
- - -
+ +
+ +
-
+ {/* Warehouse cards */}
-
-
-

Gudang Asal

- { - formik.setFieldTouched('source_warehouse', true); - formik.setFieldValue('source_warehouse', val); - formik.setFieldTouched('source_warehouse_id', true); - formik.setFieldValue( - 'source_warehouse_id', - (val as WarehouseOptionType)?.value - ); + + { + formik.setFieldTouched('source_warehouse', true); + formik.setFieldValue('source_warehouse', val); + formik.setFieldTouched('source_warehouse_id', true); + formik.setFieldValue( + 'source_warehouse_id', + (val as WarehouseOptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.source_warehouse_id && + Boolean(formik.errors.source_warehouse_id) + } + errorMessage={formik.errors.source_warehouse_id as string} + isDisabled={type === 'detail'} + isClearable + startAdornment={ + formik.values.source_warehouse_id + ? getWarehouseStockAdornment( + formik.values.source_warehouse_id + ) + : undefined + } + /> + + {/* Area and Location Info */} +
+ - - {/* Area and Location Info */} -
- - -
-
-
- -
-
-

Gudang Tujuan

- { - formik.setFieldTouched('destination_warehouse', true); - formik.setFieldValue('destination_warehouse', val); - formik.setFieldTouched('destination_warehouse_id', true); - formik.setFieldValue( - 'destination_warehouse_id', - (val as WarehouseOptionType)?.value - ); + - - {/* Area and Location Info */} -
- - -
-
+ + + + { + formik.setFieldTouched('destination_warehouse', true); + formik.setFieldValue('destination_warehouse', val); + formik.setFieldTouched('destination_warehouse_id', true); + formik.setFieldValue( + 'destination_warehouse_id', + (val as WarehouseOptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.destination_warehouse_id && + Boolean(formik.errors.destination_warehouse_id) + } + errorMessage={formik.errors.destination_warehouse_id as string} + isDisabled={type === 'detail'} + isClearable + startAdornment={ + formik.values.destination_warehouse_id + ? getWarehouseStockAdornment( + formik.values.destination_warehouse_id + ) + : undefined + } + /> + + {/* Area and Location Info */} +
+ + +
+
{/* Products table */} -
-
-

Produk

-
-
- - - {type !== 'detail' && ( -
- 0 + +
+ + + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && } + + + + {formik.values.products?.map((product, idx) => ( + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && ( + )} - - - {type !== 'detail' && } - - - {formik.values.products?.map((product, idx) => ( - - {type !== 'detail' && ( - - )} - - - {type !== 'detail' && ( - - )} - - ))} - -
+ 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedProducts( + formik.values.products?.map((_, idx) => idx) ?? + [] + ); + } else { + setSelectedProducts([]); } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Produk + + * + + + Qty + + * + + Aksi
+ ) => { if (e.target.checked) { - setSelectedProducts( - formik.values.products?.map( - (_, idx) => idx - ) ?? [] - ); + setSelectedProducts([...selectedProducts, idx]); } else { - setSelectedProducts([]); + setSelectedProducts( + selectedProducts.filter((i) => i !== idx) + ); } }} classNames={{ @@ -985,221 +1040,262 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { checkbox: 'checkbox checkbox-sm', }} /> - + + { + formik.setFieldTouched( + `products.${idx}.product`, + true + ); + formik.setFieldValue( + `products.${idx}.product`, + val + ); + formik.setFieldTouched( + `products.${idx}.product_id`, + true + ); + formik.setFieldValue( + `products.${idx}.product_id`, + (val as ProductWarehouseOptionType)?.value + ); + }} + options={productWarehouseOptions} + onInputChange={setProductWarehouseSelectInputValue} + isLoading={isLoadingProductWarehouses} + isDisabled={ + type === 'detail' || + !formik.values.source_warehouse_id + } + placeholder={ + !formik.values.source_warehouse_id + ? 'Pilih gudang asal terlebih dahulu...' + : 'Pilih produk...' + } + isClearable + {...isRepeaterInputError( + 'products', + 'product_id', + idx + )} + className={{ + wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + +
+ +
+
- Produk - - * - - - Qty - - * - - Aksi
- - ) => { - if (e.target.checked) { - setSelectedProducts([ - ...selectedProducts, - idx, - ]); - } else { - setSelectedProducts( - selectedProducts.filter((i) => i !== idx) - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - - { - formik.setFieldTouched( - `products.${idx}.product`, - true - ); - formik.setFieldValue( - `products.${idx}.product`, - val - ); - formik.setFieldTouched( - `products.${idx}.product_id`, - true - ); - formik.setFieldValue( - `products.${idx}.product_id`, - (val as ProductWarehouseOptionType)?.value - ); - }} - options={productWarehouseOptions} - onInputChange={setProductWarehouseSelectInputValue} - isLoading={isLoadingProductWarehouses} - isDisabled={ - type === 'detail' || - !formik.values.source_warehouse_id - } - placeholder={ - !formik.values.source_warehouse_id - ? 'Pilih gudang asal terlebih dahulu...' - : 'Pilih produk...' - } - isClearable - {...isRepeaterInputError( - 'products', - 'product_id', - idx - )} - className={{ - wrapper: - 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - -
- -
-
-
- {type !== 'detail' && ( -
- {selectedProducts.length > 0 && ( - - )} + ))} + +
+
+ {type !== 'detail' && ( +
+ {selectedProducts.length > 0 && ( -
- )} -
-
+ )} + +
+ )} + {/* Deliveries table */} -
-
-

Pengiriman

-
- - - - {type !== 'detail' && ( - + + + {type !== 'detail' && ( + + )} + + ))} + +
- 0 + +
+ + + + {type !== 'detail' && ( + + )} + + + + + + + + + {type !== 'detail' && } + + + + {formik.values.deliveries?.map((delivery, idx) => ( + + {type !== 'detail' && ( + )} - - - - - - - - - {type !== 'detail' && } - - - - {formik.values.deliveries?.map((delivery, idx) => ( - - {type !== 'detail' && ( - - )} - - - - - + + - - - - {type !== 'detail' && ( - + + + + ) : ( + { + const file = e.target.files?.[0]; + if (file) { + if (file.size > 2 * 1024 * 1024) { + toast.error('Ukuran dokumen maksimal 2 MB!'); + e.target.value = ''; + return; + } + formik.setFieldValue( + `deliveries.${idx}.document`, + file + ); + } + }} + {...isRepeaterInputError( + 'deliveries', + 'document', + idx + )} + className={{ + wrapper: + 'w-full min-w-72 md:w-min-80 lg:w-min-96', + }} + /> )} - - ))} - -
+ 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedDeliveries( + formik.values.deliveries?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedDeliveries([]); } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Produk + + * + + + Qty + + * + + + Supplier + + * + + + Plat Nomor + + * + + Dokumen + Biaya Pengiriman (Rp.) + + * + + + Biaya Per Item (Rp.) + + * + + + Nama Sopir + + * + + Aksi
+ ) => { if (e.target.checked) { - setSelectedDeliveries( - formik.values.deliveries?.map( - (_, idx) => idx - ) ?? [] - ); + setSelectedDeliveries([ + ...selectedDeliveries, + idx, + ]); } else { - setSelectedDeliveries([]); + setSelectedDeliveries( + selectedDeliveries.filter((i) => i !== idx) + ); } }} classNames={{ @@ -1207,417 +1303,307 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { checkbox: 'checkbox checkbox-sm', }} /> - + - Produk - - * - - - Qty - - * - - - Supplier - - * - - - Plat Nomor - - * - - Dokumen - Biaya Pengiriman (Rp.) - - * - - - Biaya Per Item (Rp.) - - * - - - Nama Sopir - - * - - Aksi
- - ) => { - if (e.target.checked) { - setSelectedDeliveries([ - ...selectedDeliveries, - idx, - ]); - } else { - setSelectedDeliveries( - selectedDeliveries.filter((i) => i !== idx) - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - - { - formik.setFieldTouched( - `deliveries.${idx}.products.0.product`, - true - ); - formik.setFieldValue( - `deliveries.${idx}.products.0.product`, - val - ); - formik.setFieldTouched( - `deliveries.${idx}.products.0.product_id`, - true - ); - formik.setFieldValue( - `deliveries.${idx}.products.0.product_id`, - (val as OptionType)?.value - ); - }} - options={getFilteredProductWarehouseOptions()} - isDisabled={type === 'detail'} - isClearable - isError={ - isDeliveryProductInputError(idx, 0, 'product_id') - .isError - } - errorMessage={ - isDeliveryProductInputError(idx, 0, 'product_id') - .errorMessage - } - className={{ - wrapper: - 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - - { - formik.setFieldTouched( - `deliveries.${idx}.supplier`, - true - ); - formik.setFieldValue( - `deliveries.${idx}.supplier`, - val - ); - formik.setFieldTouched( - `deliveries.${idx}.supplier_id`, - true - ); - formik.setFieldValue( - `deliveries.${idx}.supplier_id`, - (val as OptionType)?.value - ); - }} - options={supplierOptions} - onInputChange={setSupplierSelectInputValue} - isLoading={isLoadingSuppliers} - isDisabled={type === 'detail'} - isClearable - {...isRepeaterInputError( - 'deliveries', - 'supplier_id', - idx - )} - className={{ - wrapper: - 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - - {type === 'detail' ? ( - <> -
- -
- - ) : ( - { - const file = e.target.files?.[0]; - if (file) { - if (file.size > 2 * 1024 * 1024) { - toast.error( - 'Ukuran dokumen maksimal 2 MB!' - ); - e.target.value = ''; - return; - } - formik.setFieldValue( - `deliveries.${idx}.document`, - file - ); - } - }} - {...isRepeaterInputError( - 'deliveries', - 'document', - idx - )} - className={{ - wrapper: - 'w-full min-w-72 md:w-min-80 lg:w-min-96', - }} - /> +
+ { + formik.setFieldTouched( + `deliveries.${idx}.products.0.product`, + true + ); + formik.setFieldValue( + `deliveries.${idx}.products.0.product`, + val + ); + formik.setFieldTouched( + `deliveries.${idx}.products.0.product_id`, + true + ); + formik.setFieldValue( + `deliveries.${idx}.products.0.product_id`, + (val as OptionType)?.value + ); + }} + options={getFilteredProductWarehouseOptions()} + isDisabled={type === 'detail'} + isClearable + isError={ + isDeliveryProductInputError(idx, 0, 'product_id') + .isError + } + errorMessage={ + isDeliveryProductInputError(idx, 0, 'product_id') + .errorMessage + } + className={{ + wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + { + formik.setFieldTouched( + `deliveries.${idx}.supplier`, + true + ); + formik.setFieldValue( + `deliveries.${idx}.supplier`, + val + ); + formik.setFieldTouched( + `deliveries.${idx}.supplier_id`, + true + ); + formik.setFieldValue( + `deliveries.${idx}.supplier_id`, + (val as OptionType)?.value + ); + }} + options={supplierOptions} + onInputChange={setSupplierSelectInputValue} + isLoading={isLoadingSuppliers} + isDisabled={type === 'detail'} + isClearable + {...isRepeaterInputError( + 'deliveries', + 'supplier_id', + idx )} - - - - - - - + className={{ + wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + {type === 'detail' ? ( + <>
-
-
- {type !== 'detail' && ( -
- {selectedDeliveries.length > 0 && ( - - )} + +
+ + + + + + +
+ +
+
+
+ {type !== 'detail' && ( +
+ {selectedDeliveries.length > 0 && ( -
- )} -
-
+ )} + +
+ )} + {/* Action buttons */}
From b19be7dd4b1f20c4fcc982d0bc3b31ebcbec5958 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 4 Nov 2025 14:53:07 +0700 Subject: [PATCH 092/174] refactor(FE-174): update recording types to include approval and egg grading fields for enhanced data handling --- src/types/api/production/recording.d.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index 5b544175..2fa2fa81 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -1,4 +1,4 @@ -import { BaseMetadata, User } from '@/types/api/api-general'; +import { BaseApproval, BaseMetadata, User } from '@/types/api/api-general'; export type ProductionMetrics = { total_depletion_qty: number; @@ -8,13 +8,13 @@ export type ProductionMetrics = { cum_intake: number; fcr_value: number; total_chick_qty: number; - daily_depletion_rate: number; - cum_depletion: number; + daily_depletion_rate?: number; + cum_depletion?: number; }; export type BaseRecording = { id: number; - project_flock_kandangs_id: number; + project_flock_kandang_id: number; record_datetime: string; day: number; created_by: User; @@ -61,6 +61,11 @@ export type GradingEgg = { export type Recording = BaseMetadata & BaseRecording & { + project_flock_category?: 'GROWING' | 'LAYING'; + approval?: BaseApproval; + egg_grading_status?: string | null; + egg_grading_pending_qty?: number | null; + egg_grading_completed_qty?: number | null; recording_bws?: RecordingBW[]; recording_depletions?: RecordingDepletion[]; recording_stocks?: RecordingStock[]; From 0a17249fb9aa9b03e58a36277952f3c098170690 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 4 Nov 2025 14:54:07 +0700 Subject: [PATCH 093/174] refactor(FE-174): rename project_flock_kandangs_id to project_flock_kandang_id for consistency in RecordingForm schema --- .../recording/form/RecordingForm.schema.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 6660e491..47fad11e 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -12,7 +12,7 @@ type RecordingGrowingFormSchemaType = { value: number; label: string; } | null; - project_flock_kandangs_id: number; + project_flock_kandang_id: number; body_weights: { weight: number | string; avg_weight: number | string; @@ -118,7 +118,7 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema & { export const getRecordingGrowingFormInitialValues = ( initialValues?: RecordingFormData ): RecordingGrowingFormValues => ({ - project_flock_kandang: initialValues?.project_flock_kandangs_id + project_flock_kandang: initialValues?.project_flock_kandang_id ? { - value: initialValues.project_flock_kandangs_id, - label: `Project Flock #${initialValues.project_flock_kandangs_id}`, + value: initialValues.project_flock_kandang_id, + label: `Project Flock #${initialValues.project_flock_kandang_id}`, } : null, - project_flock_kandangs_id: initialValues?.project_flock_kandangs_id ?? 0, + project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0, body_weights: initialValues?.body_weights?.map( (bw: NonNullable[0]) => ({ weight: bw.avg_weight * bw.qty, From 966ad7545c5f728dd8a114a55952ca0d7c573ff7 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 4 Nov 2025 15:20:33 +0700 Subject: [PATCH 094/174] refactor(FE-174): rename project_flock_kandangs_id to project_flock_kandang_id for consistency in RecordingForm --- .../production/recording/form/RecordingForm.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 30aee0ac..e3a975b2 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -157,7 +157,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { project_flock_id: selectedProjectFlock.value.toString(), kandang_id: selectedKandang.value.toString(), }); - return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; + return `${ProjectFlockApi.basePath}/kandang/lookup?${params.toString()}`; }, [selectedProjectFlock, selectedKandang]); const { data: projectFlockKandangLookupData } = useSWR( @@ -272,7 +272,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { todayRecordings.forEach((recording) => { const recordingDate = recording.record_datetime?.split('T')[0]; if (recordingDate === today) { - recordedIds.add(recording.project_flock_kandangs_id); + recordedIds.add(recording.project_flock_kandang_id); } }); @@ -419,7 +419,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const layingValues = values as RecordingLayingFormValues; const layingPayload = { - project_flock_kandang_id: layingValues.project_flock_kandangs_id, + project_flock_kandang_id: layingValues.project_flock_kandang_id, body_weights: (layingValues.body_weights ?? []).map((bw) => ({ avg_weight: typeof bw.avg_weight === 'number' @@ -458,7 +458,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const growingValues = values as RecordingGrowingFormValues; const growingPayload = { - project_flock_kandang_id: growingValues.project_flock_kandangs_id, + project_flock_kandang_id: growingValues.project_flock_kandang_id, body_weights: (growingValues.body_weights ?? []).map((bw) => ({ avg_weight: typeof bw.avg_weight === 'number' @@ -688,20 +688,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setSelectedProjectFlock(null); setSelectedKandang(null); formik.setFieldValue('project_flock_kandang', null); - formik.setFieldValue('project_flock_kandangs_id', 0); + formik.setFieldValue('project_flock_kandang_id', 0); }; const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => { setSelectedProjectFlock(val as OptionType); setSelectedKandang(null); formik.setFieldValue('project_flock_kandang', null); - formik.setFieldValue('project_flock_kandangs_id', 0); + formik.setFieldValue('project_flock_kandang_id', 0); }; const kandangChangeHandler = (val: OptionType | OptionType[] | null) => { setSelectedKandang(val as OptionType); formik.setFieldTouched('project_flock_kandang', true); - formik.setFieldTouched('project_flock_kandangs_id', true); + formik.setFieldTouched('project_flock_kandang_id', true); }; useEffect(() => { @@ -717,7 +717,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return; } - formik.setFieldValue('project_flock_kandangs_id', projectFlockKandangId); + formik.setFieldValue('project_flock_kandang_id', projectFlockKandangId); formik.setFieldValue('project_flock_kandang', { value: projectFlockKandangId, From 77e3fe12c325db495b927027e03a6c5c61d52908 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 4 Nov 2025 16:06:04 +0700 Subject: [PATCH 095/174] refactor(FE-170): update API endpoint and rename label for project flock in RecordingForm --- .../pages/production/recording/form/RecordingForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index e3a975b2..eedbec03 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -157,7 +157,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { project_flock_id: selectedProjectFlock.value.toString(), kandang_id: selectedKandang.value.toString(), }); - return `${ProjectFlockApi.basePath}/kandang/lookup?${params.toString()}`; + return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; }, [selectedProjectFlock, selectedKandang]); const { data: projectFlockKandangLookupData } = useSWR( @@ -242,7 +242,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return ( projectFlocks?.data.map((projectFlock) => ({ value: projectFlock.id, - label: projectFlock.flock.name, + label: projectFlock.flock_name, })) || [] ); }, [projectFlocks]); From b0665b25418bcd6f1e34d89f7de4994b4c9bb314 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 4 Nov 2025 16:06:39 +0700 Subject: [PATCH 096/174] refactor(FE-174): rename name to flock_name in BaseProjectFlock type for clarity --- src/types/api/production/project-flock.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index cedfca38..f9bc3c38 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -7,7 +7,7 @@ import { BaseApproval, BaseMetadata } from '@/types/api/api-general'; export type BaseProjectFlock = { id: number; - name: string; + flock_name: string; status: string; flock: Flock; flock_id: number; From b7ab537b95f60c54e9bb72270c28e880597828ba Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 4 Nov 2025 16:07:02 +0700 Subject: [PATCH 097/174] feat(FE-170): add selection checkboxes and enhance project details in RecordingTable --- .../production/recording/RecordingTable.tsx | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 722589c8..4364f47e 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -4,7 +4,7 @@ import { useCallback, useState } from 'react'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; import { SortingState, CellContext } from '@tanstack/react-table'; -import { cn } from '@/lib/helper'; +import { cn, formatDate } from '@/lib/helper'; import { useModal } from '@/components/Modal'; import Button from '@/components/Button'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; @@ -24,6 +24,8 @@ import { KandangApi } from '@/services/api/master-data'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import toast from 'react-hot-toast'; +import Badge from '@/components/Badge'; +import CheckboxInput from '@/components/input/CheckboxInput'; const RowOptionsMenu = ({ type = 'dropdown', @@ -325,6 +327,30 @@ const RecordingTable = () => { data={isResponseSuccess(recordings) ? recordings?.data : []} columns={[ + { + id: 'select', + header: ({ table }) => ( +
+ +
+ ), + cell: ({ row }) => ( +
+ +
+ ), + }, { header: '#', cell: (props) => @@ -335,7 +361,20 @@ const RecordingTable = () => { { header: 'Nama Project', cell: (props) => - `Project ${props.row.original.project_flock_kandangs_id}`, + `Project ${props.row.original.project_flock_kandang_id}`, + }, + { + header: 'Kategori', + cell: (props) => { + const category = props.row.original.project_flock_category; + if (!category) return '-'; + const color = category === 'LAYING' ? 'info' : 'warning'; + return ( + + {category} + + ); + }, }, { header: 'Umur (hari)', @@ -345,17 +384,38 @@ const RecordingTable = () => { accessorKey: 'record_date', header: 'Waktu Recording', cell: (props) => - new Date(props.row.original.record_datetime).toLocaleDateString(), + formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'), }, { header: 'Populasi Awal', cell: (props) => props.row.original.total_chick_qty?.toLocaleString() || '-', }, + { + header: 'Status Approval', + cell: (props) => props.row.original.approval?.step_name || '-', + }, + { + header: 'Status Grading Telur', + cell: (props) => { + const status = props.row.original.egg_grading_status; + if (!status) return '-'; + const color = status === 'COMPLETED' ? 'success' : 'warning'; + return ( + + {status} + + ); + }, + }, + { + header: 'Dibuat Oleh', + cell: (props) => props.row.original.created_user?.name || '-', + }, { header: 'Tanggal Submit', cell: (props) => - new Date(props.row.original.created_at).toLocaleString(), + formatDate(props.row.original.created_at, 'DD MMMM YYYY'), }, { header: 'Aksi', From 04a1f5e014022de24426b6e9804796c8554961fe Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 4 Nov 2025 16:23:29 +0700 Subject: [PATCH 098/174] feat(FE-170,174): add total_weight field and update calculations in body_weights for RecordingForm --- .../recording/form/RecordingForm.schema.ts | 8 +++ .../recording/form/RecordingForm.tsx | 55 ++++++++++++++----- src/types/api/production/recording.d.ts | 1 + 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 47fad11e..383d4164 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -17,6 +17,7 @@ type RecordingGrowingFormSchemaType = { weight: number | string; avg_weight: number | string; qty: number | string; + total_weight: number | string; }[]; stocks: { product_warehouse_id: number; @@ -47,6 +48,7 @@ export type BodyWeightSchema = { weight: number | string; avg_weight: number | string; qty: number | string; + total_weight: number | string; }; export type StockSchema = { @@ -77,6 +79,10 @@ const BodyWeightObjectSchema: Yup.ObjectSchema = Yup.object({ .required('Jumlah ayam wajib diisi!') .min(1, 'Jumlah ayam minimal 1 ekor!') .typeError('Jumlah ayam harus berupa angka!'), + total_weight: Yup.number() + .required('Berat ayam total wajib diisi!') + .min(0, 'Berat ayam total tidak boleh negatif!') + .typeError('Berat ayam total harus berupa angka!'), }); const StockObjectSchema: Yup.ObjectSchema = Yup.object({ @@ -249,12 +255,14 @@ export const getRecordingGrowingFormInitialValues = ( weight: bw.avg_weight * bw.qty, avg_weight: bw.avg_weight, qty: bw.qty, + total_weight: bw.total_weight, }) ) ?? [ { weight: '', avg_weight: '', qty: '', + total_weight: 0, }, ], stocks: initialValues?.stocks?.map( diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index eedbec03..a0ade3fb 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -420,13 +420,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const layingPayload = { project_flock_kandang_id: layingValues.project_flock_kandang_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: Number(bw.qty) || 0, - })), + body_weights: (layingValues.body_weights ?? []).map((bw) => { + const qty = Number(bw.qty) || 0; + const weight = Number(bw.weight) || 0; + const totalWeight = qty * weight; + + return { + avg_weight: + typeof bw.avg_weight === 'number' + ? bw.avg_weight + : parseFloat(String(bw.avg_weight)) || 0, + qty: qty, + total_weight: parseFloat(String(totalWeight)) || 0, + }; + }), stocks: (layingValues.stocks ?? []).map((stock) => ({ product_warehouse_id: stock.product_warehouse_id, qty: Number(stock.qty) || 0, @@ -459,13 +466,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const growingPayload = { project_flock_kandang_id: growingValues.project_flock_kandang_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: Number(bw.qty) || 0, - })), + body_weights: (growingValues.body_weights ?? []).map((bw) => { + const qty = Number(bw.qty) || 0; + const weight = Number(bw.weight) || 0; + const totalWeight = qty * weight; + + return { + avg_weight: + typeof bw.avg_weight === 'number' + ? bw.avg_weight + : parseFloat(String(bw.avg_weight)) || 0, + qty: qty, + total_weight: parseFloat(String(totalWeight)) || 0, + }; + }), stocks: (growingValues.stocks ?? []).map((stock) => ({ product_warehouse_id: stock.product_warehouse_id, qty: Number(stock.qty) || 0, @@ -801,12 +815,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const currentWeight = formik.values.body_weights?.[idx]; if (currentWeight) { const qty = Number(currentWeight.qty) || 0; + const totalWeight = qty * value; + if (qty > 0 && value > 0) { const avgWeight = parseFloat((value / qty).toFixed(2)); formik.setFieldValue(`body_weights.${idx}.avg_weight`, avgWeight); } else { formik.setFieldValue(`body_weights.${idx}.avg_weight`, 0); } + + // Update total_weight + formik.setFieldValue(`body_weights.${idx}.total_weight`, totalWeight); } }; @@ -825,8 +844,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (qty > 0 && value > 0) { const totalWeight = value * qty; formik.setFieldValue(`body_weights.${idx}.weight`, totalWeight); + // Update total_weight + formik.setFieldValue(`body_weights.${idx}.total_weight`, totalWeight); } else { formik.setFieldValue(`body_weights.${idx}.weight`, 0); + formik.setFieldValue(`body_weights.${idx}.total_weight`, 0); } } }; @@ -843,12 +865,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const currentWeight = formik.values.body_weights?.[idx]; if (currentWeight) { const weight = Number(currentWeight.weight) || 0; + const totalWeight = value * weight; + if (value > 0 && weight > 0) { const avgWeight = parseFloat((weight / value).toFixed(2)); formik.setFieldValue(`body_weights.${idx}.avg_weight`, avgWeight); } else { formik.setFieldValue(`body_weights.${idx}.avg_weight`, 0); } + + // Update total_weight + formik.setFieldValue(`body_weights.${idx}.total_weight`, totalWeight); } }; diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index 2fa2fa81..7977d130 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -78,6 +78,7 @@ export type CreateGrowingRecordingPayload = { body_weights: { avg_weight: number; qty: number; + total_weight: number; }[]; stocks?: { product_warehouse_id: number; From 02cc4a759d388c9029d5faa0f6744e5b937ad0e8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 5 Nov 2025 08:43:10 +0700 Subject: [PATCH 099/174] feat(FE-Storyless): add approval workflows for project flocks and recordings --- src/config/constant.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/config/constant.ts b/src/config/constant.ts index b5a12fb4..ff5ada89 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -225,3 +225,36 @@ export const RECORDING_FLAG_OPTIONS = [ { label: 'Ayam Culling', value: 'Culling' }, { label: 'Ayam Mati', value: 'Mati' }, ]; + +export const APPROVAL_WORKFLOWS = [ + { + key: 'PROJECT_FLOCKS', + steps: [ + { + step_number: 1, + step_name: 'Pengajuan', + }, + { + step_number: 2, + step_name: 'Aktif', + }, + ], + }, + { + key: 'RECORDINGS', + steps: [ + { + step_number: 1, + step_name: 'Grading-Telur', + }, + { + step_number: 2, + step_name: 'Pengajuan', + }, + { + step_number: 3, + step_name: 'Disetujui', + }, + ], + }, +]; From fa36c10c01f76a5c79dc10c56314a7e745882314 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 5 Nov 2025 09:46:38 +0700 Subject: [PATCH 100/174] feat(FE-170,175): add approval and rejection functionality with confirmation modals in RecordingTable --- src/components/modal/ConfirmationModal.tsx | 8 + .../production/recording/RecordingTable.tsx | 152 +++++++++++++++++- 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx index 04c221e6..3fddba42 100644 --- a/src/components/modal/ConfirmationModal.tsx +++ b/src/components/modal/ConfirmationModal.tsx @@ -30,6 +30,7 @@ interface ConfirmationModalProps { modal?: string; modalBox?: string; }; + children?: React.ReactNode; } const ConfirmationModal = ({ @@ -40,6 +41,7 @@ const ConfirmationModal = ({ primaryButton, secondaryButton, className, + children, }: ConfirmationModalProps) => { const closeModalHandler = () => { ref.current?.close(); @@ -90,6 +92,12 @@ const ConfirmationModal = ({ {text ?? 'Apakah anda yakin ingin melakukan hal ini?'}

+ {children && ( +
+ {children} +
+ )} +
{secondaryButton && secondaryButton.text && ( + +