From 22f1a32e1b0dc699655f64f63d1a392429235561 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 11:59:22 +0700 Subject: [PATCH 001/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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/276] 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' && (
+ <> +
+
+ -
-
- - router.push( - `/production/chickin/add?projectFlockId=${ - (val as OptionType | null)?.value - }` - ) - } - onInputChange={(val) => { - setSearchProjectFlock(val); - }} - /> -
-
-
- - data={projectFlock.data?.kandangs} - columns={[ - { - header: '#', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, - }, - { - accessorKey: 'name', - header: 'Nama Kandang', - }, - { - header: 'Aksi', - cell: (props) => { - return ( - <> - - - ); - }, - }, - ]} - page={undefined} - className={{ - containerClassName: cn({ - 'mb-20': - isResponseSuccess(projectFlock) && - projectFlock.data?.kandangs?.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', - paginationClassName: 'hidden', - }} - /> -
- -
-

- Chickin Kandang - {selectedKandang?.name} -

- -
- {isResponseSuccess(projectFlockKandang) && - !isLoadingProjectFlockKandang && ( - +
+ { + setSearchProjectFlock(val); }} - afterSubmit={handleAfterSubmit} + isLoading={isLoadingListProjectFlock} + value={ + isResponseSuccess(projectFlock) + ? { + label: `${projectFlock.data?.flock?.name}`, + value: projectFlock.data?.id, + } + : undefined + } + onChange={(val) => { + handleChangeProjectFlock(val as OptionType); + }} + isSearchable + isClearable + startAdornment={ + isResponseSuccess(projectFlock) && ( + + Periode {projectFlock.data?.period} + + ) + } /> - )} - - { - alertModal.closeModal(); +
+
+ + + emptyContent={ +
+ {projectFlockId && isResponseError(projectFlock) ? ( + + {projectFlock.message} + + ) : ( + + Pilih project flock terlebih dahulu... + + )} +
+ } + data={ + isResponseSuccess(projectFlock) ? projectFlock.data?.kandangs : [] + } + columns={[ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, }, + { + accessorKey: 'name', + header: 'Nama Kandang', + }, + { + header: 'Aksi', + cell: (props) => { + return ( + <> + + + ); + }, + }, + ]} + page={undefined} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(projectFlock) && + projectFlock.data?.kandangs?.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', + paginationClassName: 'hidden', }} /> - - )} + + +
+

+ Chickin Kandang - {selectedKandang?.name} +

+ +
+ {isResponseSuccess(projectFlockKandang) && + isResponseSuccess(projectFlock) && + !isLoadingProjectFlockKandang && ( + + )} +
+ { + alertModal.closeModal(); + }, + }} + /> + ); }; diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index be485b75..2b91b7ae 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -4,10 +4,24 @@ import { ChangeEventHandler, FocusEventHandler, ReactNode, + useState, } from 'react'; - import { cn } from '@/lib/helper'; +const formatToISO = (dateStr: string): string | null => { + const parts = dateStr.split('/'); + if (parts.length !== 3) return null; + const [day, month, year] = parts; + if (!day || !month || !year) return null; + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; +}; + +const formatToLocal = (isoDate: string): string => { + if (!isoDate) return ''; + const [year, month, day] = isoDate.split('-'); + return `${day}/${month}/${year}`; +}; + export interface DateInputProps { label?: string; bottomLabel?: string; @@ -44,9 +58,9 @@ const DateInput = ({ min, max, className, - isError, - isValid, - errorMessage, + isError: externalError, + isValid: externalValid, + errorMessage: externalErrorMessage, startAdornment, endAdornment, disabled = false, @@ -56,6 +70,40 @@ const DateInput = ({ readOnly = false, isLoading = false, }: DateInputProps) => { + const [internalError, setInternalError] = useState(null); + + const minISO = min ? formatToISO(min) ?? undefined : undefined; + const maxISO = max ? formatToISO(max) ?? undefined : undefined; + + const valueISO = + value && value.includes('/') ? formatToISO(value) ?? '' : value ?? ''; + + const handleChange: ChangeEventHandler = (e) => { + const selectedDate = e.target.value; + const isoMin = minISO; + const isoMax = maxISO; + + if (isoMin && selectedDate < isoMin) { + setInternalError(`Tanggal tidak boleh sebelum ${min}`); + } else if (isoMax && selectedDate > isoMax) { + setInternalError(`Tanggal tidak boleh setelah ${max}`); + } else { + setInternalError(null); + } + + const event = { + ...e, + target: { + ...e.target, + value: formatToLocal(selectedDate), + }, + }; + onChange?.(event as React.ChangeEvent); + }; + + const finalIsError = externalError || !!internalError; + const finalErrorMessage = internalError || externalErrorMessage; + return (
@@ -90,8 +136,8 @@ const DateInput = ({ className={cn( 'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200 flex items-center', { - 'border-error': isError, - 'border-success!': isValid, + 'border-error': finalIsError, + 'border-success!': externalValid && !finalIsError, }, className?.inputWrapper )} @@ -103,16 +149,13 @@ const DateInput = ({ id={name} name={name} placeholder={placeholder} - value={value} - onChange={onChange} + value={valueISO} + onChange={handleChange} onBlur={onBlur} - min={min} - max={max} + min={minISO} + max={maxISO} disabled={disabled} - className={cn( - 'grow bg-transparent cursor-pointer', - className?.input - )} + className={cn('grow bg-transparent cursor-pointer', className?.input)} readOnly={readOnly} /> @@ -124,11 +167,11 @@ const DateInput = ({ )}
- {!isError && bottomLabel && ( + {!finalIsError && bottomLabel && (

{bottomLabel}

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

{errorMessage}

+ {finalIsError && finalErrorMessage && ( +

{finalErrorMessage}

)}
); diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 6a8d0ac8..8db939bd 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,22 +1,23 @@ 'use client'; import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; -import useSWR from 'swr'; - import Select, { OptionProps, GroupBase, InputActionMeta, MultiValue, SingleValue, + components as ReactSelectComponents, + ControlProps, } from 'react-select'; import CreatableSelect from 'react-select/creatable'; import makeAnimated from 'react-select/animated'; import { useDebounce } from 'use-debounce'; import { cn, getByPath } from '@/lib/helper'; +import useSWR from 'swr'; import { httpClientFetcher } from '@/services/http/client'; -import { isResponseSuccess } from '@/lib/api-helper'; import { BaseApiResponse } from '@/types/api/api-general'; +import { isResponseSuccess } from '@/lib/api-helper'; export interface OptionType { value: string | number; @@ -53,6 +54,7 @@ interface SelectInputBaseProps { openMenu?: boolean; delay?: number; onInputChange?: (search: string) => void; + startAdornment?: ReactNode; } interface SelectInputProps extends SelectInputBaseProps { @@ -63,6 +65,33 @@ interface SelectInputProps extends SelectInputBaseProps { const animatedComponents = makeAnimated(); +const CustomControl = < + Option, + IsMulti extends boolean, + Group extends GroupBase
diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index be14302d..cedfca38 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -47,3 +47,13 @@ export type ProjectFlockApprovalPayload = { action: 'APPROVED' | 'REJECTED'; approvable_ids: number[]; }; + +export type ProjectFlockKandangLookup = { + id: number; + project_flock_kandang_id: number; + project_flock_id: number; + kandang_id: number; + kandang: Kandang; + project_flock: ProjectFlock; + quantity: number; +}; From 0e77597a705e6ef5c6face2a06aad981e0696ab6 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 00:01:46 +0700 Subject: [PATCH 061/276] refactor(FE-174): add grading and egg handling to daily recording form --- .../recording/form/RecordingForm.schema.ts | 55 ++++++++++++++++++- src/types/api/production/recording.d.ts | 37 ++++++------- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 5506481e..7bb1cd26 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -2,6 +2,8 @@ import * as Yup from 'yup'; import { Recording, CreateGrowingRecordingPayload, + CreateLayingRecordingPayload, + CreateEggPayload, } from '@/types/api/production/recording'; export const RecordingGrowingFormSchema = Yup.object({ @@ -64,7 +66,7 @@ export const RecordingGrowingFormSchema = Yup.object({ .typeError('Produk harus berupa angka!'), usage_qty: Yup.number() .required('Jumlah penggunaan wajib diisi!') - .min(0, 'Jumlah penggunaan tidak boleh negatif!') + .min(1, 'Jumlah penggunaan tidak boleh 0!') .typeError('Jumlah penggunaan harus berupa angka!'), }) ) @@ -87,6 +89,24 @@ export const RecordingGrowingFormSchema = Yup.object({ .required('Data depletions wajib diisi!'), }); +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!'), +}); + export const UpdateRecordingGrowingFormSchema = RecordingGrowingFormSchema.shape({ project_flock_kandangs_id: Yup.number() @@ -100,14 +120,31 @@ export const UpdateRecordingGrowingFormSchema = .required('Project Flock Kandang wajib diisi!'), }); +export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({ + 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!'), +}); + export type RecordingGrowingFormValues = Yup.InferType< typeof RecordingGrowingFormSchema >; +export type RecordingLayingFormValues = Yup.InferType< + typeof RecordingLayingFormSchema +>; + type RecordingFormData = Partial & { body_weights?: CreateGrowingRecordingPayload['body_weights']; stocks?: CreateGrowingRecordingPayload['stocks']; depletions?: CreateGrowingRecordingPayload['depletions']; + eggs?: CreateLayingRecordingPayload['eggs']; }; export const getRecordingGrowingFormInitialValues = ( @@ -158,3 +195,19 @@ export const getRecordingGrowingFormInitialValues = ( }, ], }); + +export const getRecordingLayingFormInitialValues = ( + initialValues?: RecordingFormData +): RecordingLayingFormValues => ({ + ...getRecordingGrowingFormInitialValues(initialValues), + + eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({ + product_warehouse_id: egg.product_warehouse_id, + qty: egg.qty, + })) ?? [ + { + product_warehouse_id: 0, + qty: 0, + }, + ], +}); diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index d8903065..42a02c09 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -85,33 +85,28 @@ export type CreateGrowingRecordingPayload = { }[]; }; -export type CreateLayingRecordingPayload = { - project_flock_kandangs_id: number; - body_weights: { - avg_weight: number; +export type CreateGradingPayload = { + recording_id: number; + grading: { + product_warehouse_id: number; + grade: string; qty: number; }[]; - stocks?: { - product_warehouse_id: number; - usage_qty: number; - }[]; - depletions?: { - product_warehouse_id: number; - qty: number; - }[]; - eggs: { - product_warehouse_id: number; - qty: number; - grading?: { - grade: string; - qty: number; - }[]; - }[]; +}; + +export type CreateEggPayload = { + product_warehouse_id: number; + qty: number; +}; + +export type CreateLayingRecordingPayload = CreateGrowingRecordingPayload & { + eggs?: CreateEggPayload[]; }; export type CreateRecordingPayload = | CreateGrowingRecordingPayload - | CreateLayingRecordingPayload; + | CreateLayingRecordingPayload + | CreateGradingRecordingPayload; export type UpdateGrowingRecordingPayload = CreateGrowingRecordingPayload; export type UpdateLayingRecordingPayload = CreateLayingRecordingPayload; From 59b0eeea2b8a8ceafb14c56dbdcfe855056d5183 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 00:02:04 +0700 Subject: [PATCH 062/276] feat(FE-170): add egg handling and validation to daily recording form --- .../recording/form/RecordingForm.tsx | 543 +++++++++++++++--- 1 file changed, 455 insertions(+), 88 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 4502800f..5722e726 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -15,14 +15,21 @@ import { FormActions } from '@/components/helper/form/FormActions'; import { RecordingApi } from '@/services/api/production'; import { CreateGrowingRecordingPayload, + CreateLayingRecordingPayload, + UpdateGrowingRecordingPayload, + UpdateLayingRecordingPayload, Recording, } from '@/types/api/production/recording'; import { type BaseApiResponse } from '@/types/api/api-general'; import { RecordingGrowingFormSchema, + RecordingLayingFormSchema, RecordingGrowingFormValues, + RecordingLayingFormValues, getRecordingGrowingFormInitialValues, + getRecordingLayingFormInitialValues, UpdateRecordingGrowingFormSchema, + UpdateRecordingLayingFormSchema, } from './RecordingForm.schema'; import { useRecordingFormHandlers } from './useRecordingFormHandlers'; import { ProjectFlockApi } from '@/services/api/production'; @@ -49,6 +56,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [selectedBodyWeights, setSelectedBodyWeights] = useState([]); const [selectedStocks, setSelectedStocks] = useState([]); const [selectedDepletions, setSelectedDepletions] = useState([]); + const [selectedEggs, setSelectedEggs] = useState([]); const [editingAverageIndex] = useState(null); const [manuallyEditedRows, setManuallyEditedRows] = useState>( @@ -219,6 +227,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const { data: depletionProductsData, isLoading: isLoadingDepletionProducts } = useSWR(depletionProductsUrl, ProductWarehouseApi.getAllFetcher); + const eggProductsUrl = useMemo(() => { + if (!selectedLocation) return null; + const params = new URLSearchParams({ + search: '', + location_id: selectedLocation.value.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [selectedLocation]); + + const { data: eggProductsData, isLoading: isLoadingEggProducts } = useSWR( + eggProductsUrl, + ProductWarehouseApi.getAllFetcher + ); + // ===== DATA PROCESSING ===== const locationOptions = useMemo(() => { if (!isResponseSuccess(locations)) return []; @@ -358,6 +380,27 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return options; }, [depletionProductsData]); + const eggProducts = useMemo(() => { + const options: OptionType[] = []; + if (isResponseSuccess(eggProductsData)) { + eggProductsData.data.forEach((product) => { + const productName = product.product.name; + + if ( + productName.toLowerCase().includes('telur') || + productName.toLowerCase().includes('egg') + ) { + options.push({ + value: product.id, + label: product.product.name, + }); + } + }); + } + + return options; + }, [eggProductsData]); + // ===== FORM HANDLERS ===== const { deleteModal, @@ -369,46 +412,109 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { confirmationModalDeleteClickHandler, } = useRecordingFormHandlers(initialValues?.id); - const formikInitialValues = useMemo( - () => getRecordingGrowingFormInitialValues(initialValues), - [initialValues] - ); + const isLayingCategory = + projectFlockKandangLookup?.project_flock?.category === 'LAYING'; - const formik = useFormik({ + const formikInitialValues = useMemo(() => { + if (isLayingCategory) { + return getRecordingLayingFormInitialValues( + initialValues + ) as RecordingLayingFormValues; + } + return getRecordingGrowingFormInitialValues(initialValues); + }, [initialValues, isLayingCategory]); + + const formik = useFormik< + RecordingGrowingFormValues | RecordingLayingFormValues + >({ initialValues: formikInitialValues, - validationSchema: - type === 'edit' + validationSchema: (() => { + if (isLayingCategory) { + return type === 'edit' + ? UpdateRecordingLayingFormSchema + : RecordingLayingFormSchema; + } + return type === 'edit' ? UpdateRecordingGrowingFormSchema - : RecordingGrowingFormSchema, + : RecordingGrowingFormSchema; + })(), validateOnChange: true, validateOnBlur: true, onSubmit: async (values) => { - const payload: CreateGrowingRecordingPayload = { - project_flock_kandangs_id: values.project_flock_kandangs_id, - body_weights: (values.body_weights ?? []).map((bw) => ({ - avg_weight: - typeof bw.avg_weight === 'number' - ? bw.avg_weight - : parseFloat(String(bw.avg_weight)) || 0, - qty: bw.qty || 0, - })), - stocks: (values.stocks ?? []).map((stock) => ({ - product_warehouse_id: stock.product_warehouse_id, - usage_qty: stock.usage_qty || 0, - })), - depletions: (values.depletions ?? []).map((depletion) => ({ - product_warehouse_id: depletion.product_warehouse_id, - qty: depletion.qty || 0, - })), - }; + if (isLayingCategory) { + const layingValues = values as RecordingLayingFormValues; - switch (type) { - case 'add': - await createRecordingHandler(payload); - break; - case 'edit': - await updateRecordingHandler(initialValues?.id as number, payload); - break; + const layingPayload = { + project_flock_kandangs_id: layingValues.project_flock_kandangs_id, + body_weights: (layingValues.body_weights ?? []).map((bw) => ({ + avg_weight: + typeof bw.avg_weight === 'number' + ? bw.avg_weight + : parseFloat(String(bw.avg_weight)) || 0, + qty: bw.qty || 0, + })), + stocks: (layingValues.stocks ?? []).map((stock) => ({ + product_warehouse_id: stock.product_warehouse_id, + usage_qty: stock.usage_qty || 0, + })), + depletions: (layingValues.depletions ?? []).map((depletion) => ({ + product_warehouse_id: depletion.product_warehouse_id, + qty: depletion.qty || 0, + })), + eggs: (layingValues.eggs ?? []).map((egg) => ({ + product_warehouse_id: egg.product_warehouse_id, + qty: egg.qty || 0, + })), + }; + + switch (type) { + case 'add': + await createRecordingHandler( + layingPayload as CreateLayingRecordingPayload + ); + break; + case 'edit': + await updateRecordingHandler( + initialValues?.id as number, + layingPayload as UpdateLayingRecordingPayload + ); + break; + } + } else { + const growingValues = values as RecordingGrowingFormValues; + + const growingPayload = { + project_flock_kandangs_id: growingValues.project_flock_kandangs_id, + body_weights: (growingValues.body_weights ?? []).map((bw) => ({ + avg_weight: + typeof bw.avg_weight === 'number' + ? bw.avg_weight + : parseFloat(String(bw.avg_weight)) || 0, + qty: bw.qty || 0, + })), + stocks: (growingValues.stocks ?? []).map((stock) => ({ + product_warehouse_id: stock.product_warehouse_id, + usage_qty: stock.usage_qty || 0, + })), + depletions: (growingValues.depletions ?? []).map((depletion) => ({ + product_warehouse_id: depletion.product_warehouse_id, + qty: depletion.qty || 0, + })), + }; + + switch (type) { + case 'add': + await createRecordingHandler( + growingPayload as CreateGrowingRecordingPayload + ); + break; + case 'edit': + await updateRecordingHandler( + initialValues?.id as number, + growingPayload as UpdateGrowingRecordingPayload + ); + break; + } } }, }); @@ -436,7 +542,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const getAvailableStock = useCallback( (productWarehouseId: number) => { - if (type === 'detail') return 0; + if ((type as 'add' | 'edit' | 'detail') === 'detail') return 0; if (!isResponseSuccess(stockProducts)) return 0; const productWarehouse = stockProducts.data.find( (pw) => pw.id === productWarehouseId @@ -448,7 +554,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const getStockUsageError = useCallback( (stockIdx: number) => { - if (type === 'detail') return null; + if ((type as 'add' | 'edit' | 'detail') === 'detail') return null; const stock = formik.values.stocks?.[stockIdx]; if (!stock || !stock.product_warehouse_id) return null; const availableStock = getAvailableStock(stock.product_warehouse_id); @@ -463,7 +569,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const getStockUsageAdornment = useCallback( (stockIdx: number) => { - if (type === 'detail') return null; + if ((type as 'add' | 'edit' | 'detail') === 'detail') return null; const stock = formik.values.stocks?.[stockIdx]; if (!stock || !stock.product_warehouse_id) return null; const availableStock = getAvailableStock(stock.product_warehouse_id); @@ -566,7 +672,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); const hasExceededStock = useMemo(() => { - if (type === 'detail') return false; + if ((type as 'add' | 'edit' | 'detail') === 'detail') return false; return ( formik.values.stocks?.some((stock, idx) => { return getStockUsageError(idx) !== null; @@ -574,40 +680,35 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); }, [formik.values.stocks, getStockUsageError, type]); - const isRepeaterInputError = < - T extends 'body_weights' | 'stocks' | 'depletions', - >( - arrayName: T, - column: T extends 'body_weights' - ? keyof RecordingGrowingFormValues['body_weights'][0] - : T extends 'stocks' - ? keyof RecordingGrowingFormValues['stocks'][0] - : T extends 'depletions' - ? keyof RecordingGrowingFormValues['depletions'][0] - : never, + const isRepeaterInputError = ( + arrayName: 'body_weights' | 'stocks' | 'depletions' | 'eggs', + column: string, idx: number ) => { - if ( - !formik.touched[arrayName] || - !Array.isArray(formik.touched[arrayName]) - ) { + const touched = formik.touched as Record; + const errors = formik.errors as Record; + + if (!touched[arrayName] || !Array.isArray(touched[arrayName])) { return { isError: false, errorMessage: '', }; } - const touchedField = formik.touched[arrayName]?.[idx]?.[column as string]; - const errorField = formik.errors[arrayName]?.[idx] as Record< + const touchedField = (touched[arrayName] as unknown[])?.[idx] as Record< string, - string + unknown + >; + const errorField = (errors[arrayName] as unknown[])?.[idx] as Record< + string, + unknown >; return { - isError: touchedField && Boolean(errorField?.[column as string]), + isError: touchedField && Boolean(errorField?.[column]), errorMessage: - touchedField && errorField?.[column as string] - ? errorField[column as string] + touchedField && errorField?.[column] + ? (errorField[column] as string) : '', }; }; @@ -899,7 +1000,51 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setSelectedDepletions([]); }; + // Eggs Handlers + const addEgg = () => { + const newEggs = [ + ...((formik.values as RecordingLayingFormValues).eggs || []), + { + product_warehouse_id: 0, + qty: 0, + }, + ]; + formik.setFieldValue('eggs', newEggs); + }; + + const handleEggQtyChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0; + formik.setFieldValue(`eggs.${idx}.qty`, value); + }, + [formik] + ); + + const removeEgg = (idx: number) => { + const updatedEggs = ( + formik.values as RecordingLayingFormValues + ).eggs?.filter((_, i) => i !== idx); + formik.setFieldValue('eggs', updatedEggs); + }; + + const removeSelectedEggs = () => { + const updatedEggs = ( + formik.values as RecordingLayingFormValues + ).eggs?.filter((_, idx) => !selectedEggs.includes(idx)); + formik.setFieldValue('eggs', updatedEggs); + setSelectedEggs([]); + }; + // ===== EFFECTS ===== + useEffect(() => { + if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') { + const layingValues = formik.values as RecordingLayingFormValues; + if (!layingValues.eggs || layingValues.eggs.length === 0) { + formik.setFieldValue('eggs', [{ product_warehouse_id: 0, qty: 0 }]); + } + } + }, [isLayingCategory, type, formik]); + useEffect(() => { if (formik.values.body_weights && editingAverageIndex === null) { const updatedBodyWeights = formik.values.body_weights.map( @@ -976,7 +1121,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { : 'grid grid-cols-3 gap-4' } > - {type === 'detail' ? null : ( + {(type as 'add' | 'edit' | 'detail') === 'detail' ? null : ( <> { )} - {type === 'detail' && formik.values.project_flock_kandang && ( -
- -
- {formik.values.project_flock_kandang.label} + {(type as 'add' | 'edit' | 'detail') === 'detail' && + formik.values.project_flock_kandang && ( +
+ +
+ {formik.values.project_flock_kandang.label} +
-
- )} + )}
@@ -1060,7 +1206,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { - {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - {type !== 'detail' && } + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + )} {formik.values.body_weights?.map((bw, idx) => ( - {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
{ * ActionAction
{ />
- {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 063/276] 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 064/276] 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 065/276] 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 13:57:30 +0700 Subject: [PATCH 066/276] feat(FE-169): Slicing UI Index Pengajuan Sales & Define Data Type for Marketing --- src/app/marketing/sales-orders/add/page.tsx | 9 + .../sales-orders/detail/edit/page.tsx | 8 + .../marketing/sales-orders/detail/layout.tsx | 11 ++ .../marketing/sales-orders/detail/page.tsx | 9 + src/app/marketing/sales-orders/page.tsx | 10 ++ .../sales-orders/SalesOrderTable.tsx | 166 ++++++++++++++++++ .../sales-orders/form/SalesForm.schema.ts | 0 .../marketing/sales-orders/form/SalesForm.tsx | 0 .../sales-orders/form/SalesFormRepeater.ts | 0 .../sales-orders/form/SalesFromRepeater.tsx | 0 src/config/constant.ts | 2 +- src/types/api/marketing/marketing.d.ts | 39 ++++ 12 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/app/marketing/sales-orders/add/page.tsx create mode 100644 src/app/marketing/sales-orders/detail/edit/page.tsx create mode 100644 src/app/marketing/sales-orders/detail/layout.tsx create mode 100644 src/app/marketing/sales-orders/detail/page.tsx create mode 100644 src/app/marketing/sales-orders/page.tsx create mode 100644 src/components/pages/marketing/sales-orders/SalesOrderTable.tsx create mode 100644 src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts create mode 100644 src/components/pages/marketing/sales-orders/form/SalesForm.tsx create mode 100644 src/components/pages/marketing/sales-orders/form/SalesFormRepeater.ts create mode 100644 src/components/pages/marketing/sales-orders/form/SalesFromRepeater.tsx create mode 100644 src/types/api/marketing/marketing.d.ts diff --git a/src/app/marketing/sales-orders/add/page.tsx b/src/app/marketing/sales-orders/add/page.tsx new file mode 100644 index 00000000..ed193137 --- /dev/null +++ b/src/app/marketing/sales-orders/add/page.tsx @@ -0,0 +1,9 @@ +const AddSalesOrder = () => { + return ( +
+

Tambah Sales Order

+
+ ); +}; + +export default AddSalesOrder; diff --git a/src/app/marketing/sales-orders/detail/edit/page.tsx b/src/app/marketing/sales-orders/detail/edit/page.tsx new file mode 100644 index 00000000..cc0722ea --- /dev/null +++ b/src/app/marketing/sales-orders/detail/edit/page.tsx @@ -0,0 +1,8 @@ +const EditSalesOrder = () => { + return ( +
+

Edit Sales Order

+
+ ); +}; +export default EditSalesOrder; diff --git a/src/app/marketing/sales-orders/detail/layout.tsx b/src/app/marketing/sales-orders/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/marketing/sales-orders/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/marketing/sales-orders/detail/page.tsx b/src/app/marketing/sales-orders/detail/page.tsx new file mode 100644 index 00000000..ee377cfa --- /dev/null +++ b/src/app/marketing/sales-orders/detail/page.tsx @@ -0,0 +1,9 @@ +const DetailSalesOrder = () => { + return ( +
+

Detail Sales Order

+
+ ); +}; + +export default DetailSalesOrder; diff --git a/src/app/marketing/sales-orders/page.tsx b/src/app/marketing/sales-orders/page.tsx new file mode 100644 index 00000000..3494b6a1 --- /dev/null +++ b/src/app/marketing/sales-orders/page.tsx @@ -0,0 +1,10 @@ +import SalesOrderTable from '@/components/pages/marketing/sales-orders/SalesOrderTable'; + +const SalesOrder = () => { + return ( +
+ +
+ ); +}; +export default SalesOrder; diff --git a/src/components/pages/marketing/sales-orders/SalesOrderTable.tsx b/src/components/pages/marketing/sales-orders/SalesOrderTable.tsx new file mode 100644 index 00000000..eb35e666 --- /dev/null +++ b/src/components/pages/marketing/sales-orders/SalesOrderTable.tsx @@ -0,0 +1,166 @@ +'use client'; + +import Button from '@/components/Button'; +import { OptionType } from '@/components/input/SelectInput'; +import Table from '@/components/Table'; +import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; +import { TableToolbar } from '@/components/table/TableToolbar'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { cn } from '@/lib/helper'; +import { SalesOrder } from '@/types/api/marketing/marketing'; +import { Icon } from '@iconify/react'; +import { CellContext } from '@tanstack/react-table'; +import { useCallback, useState } from 'react'; + +const RowsOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+
+ + + +
+
+ ); +}; +const SalesOrderTable = () => { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const searchChangeHandler = useCallback( + (e: React.ChangeEvent) => { + setSearch(e.target.value); + setPage(1); + }, + [] + ); + const pageSizeChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + setPage(1); + }, + [] + ); + + return ( +
+
+ + +
+ pageSize * (page - 1) + props.row.index + 1, + }, + { + accessorKey: 'so_number', + header: 'No. Order', + }, + { + accessorKey: 'tanggal', + header: 'Tanggal', + }, + { + accessorKey: 'approval.step_name', + header: 'Status', + }, + { + accessorKey: 'customer', + header: 'Customer', + }, + { + accessorKey: 'grand_total', + header: 'Grand Total', + }, + { + accessorKey: 'product_details', + header: 'Product Details', + }, + { + header: 'Aksi', + cell: (props) => {}, + }, + ]} + pageSize={pageSize} + page={page} + onPageChange={setPage} + className={{ + 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', + }} + /> + + ); +}; +export default SalesOrderTable; diff --git a/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts b/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/marketing/sales-orders/form/SalesForm.tsx b/src/components/pages/marketing/sales-orders/form/SalesForm.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/marketing/sales-orders/form/SalesFormRepeater.ts b/src/components/pages/marketing/sales-orders/form/SalesFormRepeater.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/marketing/sales-orders/form/SalesFromRepeater.tsx b/src/components/pages/marketing/sales-orders/form/SalesFromRepeater.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/config/constant.ts b/src/config/constant.ts index 2e9d3832..840f5c32 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -42,7 +42,7 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ { title: 'Penjualan', - link: '/sale', + link: '/marketing/sales-orders', icon: 'mdi:attach-money', }, diff --git a/src/types/api/marketing/marketing.d.ts b/src/types/api/marketing/marketing.d.ts new file mode 100644 index 00000000..b577802a --- /dev/null +++ b/src/types/api/marketing/marketing.d.ts @@ -0,0 +1,39 @@ +import { Customer } from '@/types/api/master-data/customer'; +import { + BaseApproval, + BaseMetadata, + CreatedUser, +} from '@/types/api/api-general'; +import { ProductWarehouse } from '../inventory/product-warehouse'; + +export type BaseMarketing = { + id: number; + status?: string; + so_number: string; + customer: Customer; + so_docs: string; + so_date: string; + sales_person: CreatedUser; + notes: string; +}; + +export type MarketingProducts = { + id: number; + qty: number; + unit_price: number; + avg_weigth: number; + total_price: number; + product_warehouse: ProductWarehouse; + delivery_date?: string; + vehicle_number?: string; +}; + +export type SalesOrder = BaseMetadata & BaseMarketing; + +export type CreateSalesOrderPayload = { + customer_id: number; + date: string; + notes: string; +}; + +export type UpdateSalesOrderPayload = CreateSalesOrderPayload; From 3a52d800e08f4c0c3aea1355a922c954ecd822c4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 14:01:51 +0700 Subject: [PATCH 067/276] 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 c72befb5b42ca842e4a7b79264fe712a60c91a4b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:25:01 +0700 Subject: [PATCH 068/276] feat(FE-195): create Expense page --- src/app/expense/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/expense/page.tsx diff --git a/src/app/expense/page.tsx b/src/app/expense/page.tsx new file mode 100644 index 00000000..d6b00286 --- /dev/null +++ b/src/app/expense/page.tsx @@ -0,0 +1,11 @@ +import ExpensesTable from '@/components/pages/expense/ExpensesTable'; + +const Expense = () => { + return ( +
+ +
+ ); +}; + +export default Expense; From a0cf6c0f56aa48b3454671d5ab2f3b4097a8a7fa Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:26:52 +0700 Subject: [PATCH 069/276] feat(FE-188): create Add Expense page --- src/app/expense/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/expense/add/page.tsx diff --git a/src/app/expense/add/page.tsx b/src/app/expense/add/page.tsx new file mode 100644 index 00000000..00560dad --- /dev/null +++ b/src/app/expense/add/page.tsx @@ -0,0 +1,11 @@ +import ExpenseForm from '@/components/pages/expense/form/ExpenseForm'; + +const AddExpense = () => { + return ( +
+ +
+ ); +}; + +export default AddExpense; From 01bfe1cc3b8fcffa00687bb181c92b899533898f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:27:13 +0700 Subject: [PATCH 070/276] chore: export ButtonProps interface --- src/components/Button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 7cad5b58..2f209ece 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { cn } from '@/lib/helper'; import { Color } from '@/types/theme'; -interface ButtonProps extends react.ComponentProps<'button'> { +export interface ButtonProps extends react.ComponentProps<'button'> { variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active'; color?: Color; href?: string; From 00f64b18979e740978d0b38d2fe05dd4f28fb41f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:28:04 +0700 Subject: [PATCH 071/276] chore: add string as the valueKey and labelKey type --- src/components/input/SelectInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 6a8d0ac8..833d7d26 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -229,8 +229,8 @@ const SelectInput = (props: SelectInputProps) => { const useSelect = ( basePath: string, - valueKey: keyof T, - labelKey: keyof T, + valueKey: keyof T | string, + labelKey: keyof T | string, searchKey: string = 'search', params?: { [key: string]: string } ) => { From 80747bb44199d0505ad686f787263f9b111a8c19 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:28:51 +0700 Subject: [PATCH 072/276] chore(FE-195): adjust ConfirmationModal primaryButton and secondaryButton props --- src/components/modal/ConfirmationModal.tsx | 51 +++++++++++++++------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx index 04c221e6..2358f815 100644 --- a/src/components/modal/ConfirmationModal.tsx +++ b/src/components/modal/ConfirmationModal.tsx @@ -1,30 +1,23 @@ 'use client'; -import { RefObject } from 'react'; +import { MouseEventHandler, RefObject, useState } from 'react'; import { Icon } from '@iconify/react'; import Modal from '@/components/Modal'; -import Button from '@/components/Button'; +import Button, { ButtonProps } from '@/components/Button'; import { cn } from '@/lib/helper'; -import { Color } from '@/types/theme'; interface ConfirmationModalProps { ref: RefObject; type?: 'info' | 'success' | 'error'; text?: string; closeOnBackdrop?: boolean; - primaryButton?: { + primaryButton?: ButtonProps & { text?: string; - color?: Color; - isLoading?: boolean; - onClick?: () => void; }; - secondaryButton?: { + secondaryButton?: ButtonProps & { text?: string; - color?: Color; - isLoading?: boolean; - onClick?: () => void; }; className?: { modal?: string; @@ -41,10 +34,22 @@ const ConfirmationModal = ({ secondaryButton, className, }: ConfirmationModalProps) => { + const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false); + const closeModalHandler = () => { ref.current?.close(); }; + const primaryButtonClickHandler: MouseEventHandler< + HTMLButtonElement + > = async (event) => { + setIsPrimaryButtonLoading(true); + + await primaryButton?.onClick?.(event); + + setIsPrimaryButtonLoading(false); + }; + return (
@@ -93,10 +98,15 @@ const ConfirmationModal = ({
{secondaryButton && secondaryButton.text && ( + + + + +
+
+ ); +}; + +const ExpensesTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + }); + + const { + data: expenses, + isLoading, + mutate: refreshExpenses, + } = useSWR( + `${ExpenseApi.basePath}${getTableFilterQueryString()}`, + ExpenseApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedExpense, setSelectedExpense] = useState( + undefined + ); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const expensesColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + 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 = () => { + setSelectedExpense(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await ExpenseApi.delete(selectedExpense?.id as number); + refreshExpenses(); + + deleteModal.closeModal(); + toast.success('Berhasil menghapus biaya operasional!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting, updateFilter]); + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(expenses) ? expenses?.data : []} + columns={expensesColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0} + totalItems={ + isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(expenses) && expenses?.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', + }} + /> +
+ + + + ); +}; + +export default ExpensesTable; From a51c7c44ececeeef1c77fd8059d8aa79d796e27a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:29:31 +0700 Subject: [PATCH 074/276] feat(FE-188): create ExpenseForm component --- .../pages/expense/form/ExpenseForm.tsx | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 src/components/pages/expense/form/ExpenseForm.tsx diff --git a/src/components/pages/expense/form/ExpenseForm.tsx b/src/components/pages/expense/form/ExpenseForm.tsx new file mode 100644 index 00000000..9361c5c3 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseForm.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + ExpenseFormSchema, + ExpenseFormValues, + UpdateExpenseFormSchema, +} from '@/components/pages/expense/form/ExpenseForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { + Expense, + CreateExpensePayload, + UpdateExpensePayload, +} from '@/types/api/expense'; +import { ExpenseApi } from '@/services/api/expense'; +import { cn, sleep } from '@/lib/helper'; + +interface ExpenseFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Expense; +} + +// TODO: integrate this with real API +const ExpenseForm = ({ type = 'add', initialValues }: ExpenseFormProps) => { + const router = useRouter(); + + // Modal hooks + const deleteModal = useModal(); + const approveModal = useModal(); + const rejectModal = useModal(); + + const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState(''); + + const createExpenseHandler = useCallback( + async (payload: CreateExpensePayload) => { + const createExpenseRes = await ExpenseApi.create(payload); + + if (isResponseError(createExpenseRes)) { + setExpenseFormErrorMessage(createExpenseRes.message); + return; + } + + toast.success(createExpenseRes?.message as string); + router.push('/expense'); + }, + [router] + ); + + const updateExpenseHandler = useCallback( + async (expenseId: number, payload: UpdateExpensePayload) => { + const updateExpenseRes = await ExpenseApi.update(expenseId, payload); + + if (updateExpenseRes?.status === 'error') { + setExpenseFormErrorMessage(updateExpenseRes.message); + return; + } + + toast.success(updateExpenseRes?.message as string); + router.refresh(); + router.push('/expense'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateExpenseFormSchema : ExpenseFormSchema, + onSubmit: async (values) => { + setExpenseFormErrorMessage(''); + + const expensePayload: CreateExpensePayload = { + name: values.name, + }; + + switch (type) { + case 'add': + await createExpenseHandler(expensePayload); + break; + + case 'edit': + await updateExpenseHandler( + initialValues?.id as number, + expensePayload + ); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + const deleteExpenseClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalRejectClickHandler = async () => { + await sleep(750); + + rejectModal.closeModal(); + toast.success('Berhasil melakukan reject biaya operasional!'); + }; + + const confirmationModalApproveClickHandler = async () => { + await sleep(750); + + approveModal.closeModal(); + toast.success('Berhasil melakukan approve biaya operasional!'); + }; + + const confirmationModalDeleteClickHandler = async () => { + await ExpenseApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Expense!'); + router.push('/expense'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ + +

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

+
+ + +
+ +
+ +
+ {type !== 'add' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {expenseFormErrorMessage && ( +
+ + {expenseFormErrorMessage} +
+ )} + +
+ + {type !== 'add' && ( + + )} + + {type === 'detail' && ( + <> + + + + + )} + + ); +}; + +export default ExpenseForm; From 1a1bf8754e4834a1928073d21c097d9688f85902 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:30:00 +0700 Subject: [PATCH 075/276] feat(FE-188): create Expense Form schema --- src/components/pages/expense/form/ExpenseForm.schema.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/components/pages/expense/form/ExpenseForm.schema.ts diff --git a/src/components/pages/expense/form/ExpenseForm.schema.ts b/src/components/pages/expense/form/ExpenseForm.schema.ts new file mode 100644 index 00000000..35cd82f4 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseForm.schema.ts @@ -0,0 +1,9 @@ +import * as Yup from 'yup'; + +export const ExpenseFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), +}); + +export const UpdateExpenseFormSchema = ExpenseFormSchema; + +export type ExpenseFormValues = Yup.InferType; From 21b155e64b744ed3abfcfc53a70417f92f0d7000 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:30:36 +0700 Subject: [PATCH 076/276] feat(FE-195): add Expense menu --- src/config/constant.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config/constant.ts b/src/config/constant.ts index bf2bb0d9..57599702 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -40,6 +40,12 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ ], }, + { + title: 'Biaya Operasional', + link: '/expense', + icon: 'uil:wallet', + }, + { title: 'Persediaan', link: '/inventory', From 026e60704b4c8937a39c8e5b4c13da087662187c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:33:19 +0700 Subject: [PATCH 077/276] feat(FE-199): create Expense API service --- src/services/api/expense.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/services/api/expense.ts diff --git a/src/services/api/expense.ts b/src/services/api/expense.ts new file mode 100644 index 00000000..ec42743c --- /dev/null +++ b/src/services/api/expense.ts @@ -0,0 +1,19 @@ +import { BaseApiService } from '@/services/api/base'; +import { + CreateExpensePayload, + Expense, + UpdateExpensePayload, +} from '@/types/api/expense'; + +// export const ExpenseApi = new BaseApiService< +// Expense, +// CreateExpensePayload, +// UpdateExpensePayload +// >('/expense'); + +// TODO: remove this ASAP +export const ExpenseApi = new BaseApiService< + Expense, + CreateExpensePayload, + UpdateExpensePayload +>('/master-data/uoms'); From 15893c18c91797e0c3db0910d066d51eae8ebccc Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:33:28 +0700 Subject: [PATCH 078/276] feat(FE-199): create Expense API types --- src/types/api/expense.d.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/types/api/expense.d.ts diff --git a/src/types/api/expense.d.ts b/src/types/api/expense.d.ts new file mode 100644 index 00000000..6fdf358c --- /dev/null +++ b/src/types/api/expense.d.ts @@ -0,0 +1,14 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseExpense = { + id: number; + name: string; +}; + +export type Expense = BaseMetadata & BaseExpense; + +export type CreateExpensePayload = { + name: string; +}; + +export type UpdateExpensePayload = CreateExpensePayload; From 495e11c6fe5b5c539f598fa1fa6acfc9d7b1e22e Mon Sep 17 00:00:00 2001 From: randy-ar Date: Fri, 31 Oct 2025 14:57:15 +0700 Subject: [PATCH 079/276] fix(FE-41): Menambahkan kolom kapasitas di tabel kandang --- .../transfer-to-laying/detail/edit/page.tsx | 2 ++ .../transfer-to-laying/detail/page.tsx | 2 ++ .../master-data/kandang/KandangsTable.tsx | 16 +++++++++++++-- .../kandang/form/KandangForm.schema.ts | 4 ++++ .../master-data/kandang/form/KandangForm.tsx | 17 ++++++++++++++++ .../production/recording/RecordingTable.tsx | 1 + .../api/production/transfer-to-laying.ts | 20 +++++++++++++++++++ src/types/api/master-data/kandang.d.ts | 2 ++ 8 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/app/production/transfer-to-laying/detail/edit/page.tsx b/src/app/production/transfer-to-laying/detail/edit/page.tsx index 9003dbba..20c7373f 100644 --- a/src/app/production/transfer-to-laying/detail/edit/page.tsx +++ b/src/app/production/transfer-to-laying/detail/edit/page.tsx @@ -27,6 +27,7 @@ const DUMMY_TRANSFER_TO_LAYING_EDIT: TransferToLaying = { { kandang: { id: 1, + capacity: 1000, name: 'Kandang test', status: 'ACTIVE', location: { @@ -56,6 +57,7 @@ const DUMMY_TRANSFER_TO_LAYING_EDIT: TransferToLaying = { kandang: { id: 1, name: 'Kandang test 2', + capacity: 3000, status: 'ACTIVE', location: { id: 1, diff --git a/src/app/production/transfer-to-laying/detail/page.tsx b/src/app/production/transfer-to-laying/detail/page.tsx index de5426c8..3f7dbe56 100644 --- a/src/app/production/transfer-to-laying/detail/page.tsx +++ b/src/app/production/transfer-to-laying/detail/page.tsx @@ -27,6 +27,7 @@ const DUMMY_TRANSFER_TO_LAYING_DETAIL: TransferToLaying = { { kandang: { id: 1, + capacity: 1000, name: 'Kandang test', status: 'ACTIVE', location: { @@ -55,6 +56,7 @@ const DUMMY_TRANSFER_TO_LAYING_DETAIL: TransferToLaying = { { kandang: { id: 1, + capacity: 3000, name: 'Kandang test 2', status: 'ACTIVE', location: { diff --git a/src/components/pages/master-data/kandang/KandangsTable.tsx b/src/components/pages/master-data/kandang/KandangsTable.tsx index c51eeb21..d2b585de 100644 --- a/src/components/pages/master-data/kandang/KandangsTable.tsx +++ b/src/components/pages/master-data/kandang/KandangsTable.tsx @@ -22,7 +22,7 @@ import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import { Kandang } from '@/types/api/master-data/kandang'; import { KandangApi } from '@/services/api/master-data'; -import { cn } from '@/lib/helper'; +import { cn, formatNumber } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ROWS_OPTIONS } from '@/config/constant'; @@ -93,12 +93,19 @@ const KandangsTable = () => { setPageSize, toQueryString: getTableFilterQueryString, } = useTableFilter({ - initial: { search: '', nameSort: '', locationSort: '', picSort: '' }, + initial: { + search: '', + nameSort: '', + locationSort: '', + capacitySort: '', + picSort: '', + }, paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name', locationSort: 'sort_location', + capacitySort: 'sort_capacity', picSort: ' sort_pic', }, }); @@ -138,6 +145,11 @@ const KandangsTable = () => { header: 'Lokasi', cell: (props) => props.row.original.location.name, }, + { + accessorKey: 'capacity', + header: 'Kapasitas', + cell: (props) => formatNumber(props.row.original.capacity ?? 0), + }, { accessorKey: 'pic', header: 'PIC', diff --git a/src/components/pages/master-data/kandang/form/KandangForm.schema.ts b/src/components/pages/master-data/kandang/form/KandangForm.schema.ts index 9a0e42a0..b0f816bd 100644 --- a/src/components/pages/master-data/kandang/form/KandangForm.schema.ts +++ b/src/components/pages/master-data/kandang/form/KandangForm.schema.ts @@ -11,6 +11,10 @@ export const KandangFormSchema = Yup.object({ label: Yup.string().required(), }).nullable(), + capacity: Yup.number() + .min(1, 'Kapasitas wajib diisi!') + .required('Kapasitas wajib diisi!'), + picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'), pic: Yup.object({ value: Yup.number().min(1).required(), diff --git a/src/components/pages/master-data/kandang/form/KandangForm.tsx b/src/components/pages/master-data/kandang/form/KandangForm.tsx index f0d68983..7753a507 100644 --- a/src/components/pages/master-data/kandang/form/KandangForm.tsx +++ b/src/components/pages/master-data/kandang/form/KandangForm.tsx @@ -27,6 +27,7 @@ import { import { LocationApi, KandangApi } from '@/services/api/master-data'; import { cn } from '@/lib/helper'; import { UserApi } from '@/services/api/user'; +import NumberInput from '@/components/input/NumberInput'; interface KandangFormProps { type?: 'add' | 'edit' | 'detail'; @@ -81,6 +82,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => { label: initialValues.location.name, } : null, + capacity: initialValues?.capacity ?? 0, picId: initialValues?.pic?.id ?? 0, pic: initialValues?.pic ? { @@ -101,6 +103,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => { const kandangPayload: CreateKandangPayload = { name: values.name, location_id: values.locationId, + capacity: values.capacity, pic_id: values.picId, }; @@ -249,6 +252,20 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => { isClearable /> + + = { { kandang: { id: 3, + capacity: 1000, name: 'Cikaum 1', status: 'ACTIVE', location: { @@ -633,6 +649,7 @@ const FLOCK_SOURCE_DUMMY_DATA: BaseApiResponse = { { kandang: { id: 4, + capacity: 1000, name: 'Cikaum 2', status: 'ACTIVE', location: { @@ -656,6 +673,7 @@ const FLOCK_SOURCE_DUMMY_DATA: BaseApiResponse = { { kandang: { id: 5, + capacity: 1000, name: 'Cikaum 3', status: 'ACTIVE', location: { @@ -687,6 +705,7 @@ const FLOCK_SOURCE_DUMMY_DATA: BaseApiResponse = { { kandang: { id: 3, + capacity: 1000, name: 'Cikaum 1', status: 'ACTIVE', location: { @@ -710,6 +729,7 @@ const FLOCK_SOURCE_DUMMY_DATA: BaseApiResponse = { { kandang: { id: 4, + capacity: 1000, name: 'Cikaum 2', status: 'ACTIVE', location: { diff --git a/src/types/api/master-data/kandang.d.ts b/src/types/api/master-data/kandang.d.ts index 17cbbee7..8ad20f15 100644 --- a/src/types/api/master-data/kandang.d.ts +++ b/src/types/api/master-data/kandang.d.ts @@ -7,6 +7,7 @@ export type BaseKandang = { name: string; status: string; location: BaseLocation; + capacity: number; pic: BaseUser; }; @@ -15,6 +16,7 @@ export type Kandang = BaseMetadata & BaseKandang; export type CreateKandangPayload = { name: string; location_id: number; + capacity: number; pic_id: number; }; From 4a1f775c8544ca5a42d4d56f69631f9484bc7097 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 15:15:32 +0700 Subject: [PATCH 080/276] 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 081/276] 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 082/276] 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 083/276] 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 084/276] 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 085/276] 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 086/276] 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 087/276] 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 088/276] 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 bba8fb15e58c2e01946594f4be62f1cc88057f1b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:24:52 +0700 Subject: [PATCH 089/276] chore: change a element to button --- src/components/menu/MenuItem.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/menu/MenuItem.tsx b/src/components/menu/MenuItem.tsx index 5046f8ff..dce81dac 100644 --- a/src/components/menu/MenuItem.tsx +++ b/src/components/menu/MenuItem.tsx @@ -49,14 +49,18 @@ const MenuItem = ({ ); return ( -
  • +
  • {href && ( {menuItemContent} )} - {!href && {menuItemContent}} + {!href && ( + + )}
  • ); }; From e6187555ced2cc4efec99d98be3180207254734d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:26:25 +0700 Subject: [PATCH 090/276] chore: create RowOptionsMenuWrapper component --- .../table/RowOptionsMenuWrapper.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/components/table/RowOptionsMenuWrapper.tsx diff --git a/src/components/table/RowOptionsMenuWrapper.tsx b/src/components/table/RowOptionsMenuWrapper.tsx new file mode 100644 index 00000000..53a8ecf1 --- /dev/null +++ b/src/components/table/RowOptionsMenuWrapper.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; +import { cn } from '@/lib/helper'; + +interface RowOptionsMenuWrapperProps { + children?: ReactNode; + type: 'dropdown' | 'collapse'; +} + +const RowOptionsMenuWrapper = ({ + children, + type, +}: RowOptionsMenuWrapperProps) => { + return ( +
    +
    {children}
    +
    + ); +}; + +export default RowOptionsMenuWrapper; From d853b43e175d4d59d58612f3897e82680cf7d91d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:31:11 +0700 Subject: [PATCH 091/276] fix: use RowOptionsMenuWrapper component for RowOptionsMenu --- .../pages/master-data/area/AreasTable.tsx | 27 +++++------ .../pages/master-data/bank/BanksTable.tsx | 29 ++++++------ .../master-data/customer/CustomersTable.tsx | 38 +++++++-------- .../pages/master-data/fcr/FcrsTable.tsx | 29 ++++++------ .../pages/master-data/flock/FlocksTable.tsx | 32 ++++++------- .../master-data/kandang/KandangsTable.tsx | 29 ++++++------ .../master-data/location/LocationsTable.tsx | 29 ++++++------ .../master-data/nonstock/NonstocksTable.tsx | 29 ++++++------ .../product-category/ProductCategoryTable.tsx | 29 ++++++------ .../master-data/product/ProductTable.tsx | 29 ++++++------ .../master-data/supplier/SupplierTable.tsx | 27 +++++------ .../pages/master-data/uom/UomsTable.tsx | 29 ++++++------ .../master-data/warehouse/WarehousesTable.tsx | 27 +++++------ .../pages/production/chickin/ChickinTable.tsx | 46 +++++++++---------- .../project-flock/ProjectFlockTable.tsx | 27 ++++------- .../production/recording/RecordingTable.tsx | 18 ++------ .../TransferToLayingsTable.tsx | 21 +++------ 17 files changed, 216 insertions(+), 279 deletions(-) diff --git a/src/components/pages/master-data/area/AreasTable.tsx b/src/components/pages/master-data/area/AreasTable.tsx index c1ec1ef5..207fb8a6 100644 --- a/src/components/pages/master-data/area/AreasTable.tsx +++ b/src/components/pages/master-data/area/AreasTable.tsx @@ -14,6 +14,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Area } from '@/types/api/master-data/area'; import { AreaApi } from '@/services/api/master-data'; @@ -32,16 +33,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -150,7 +142,7 @@ const AreasTable = () => { {currentPageSize <= 2 && ( @@ -199,10 +191,15 @@ const AreasTable = () => {
    -
    -
    diff --git a/src/components/pages/master-data/bank/BanksTable.tsx b/src/components/pages/master-data/bank/BanksTable.tsx index 0d084491..58b09ef8 100644 --- a/src/components/pages/master-data/bank/BanksTable.tsx +++ b/src/components/pages/master-data/bank/BanksTable.tsx @@ -14,6 +14,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Bank } from '@/types/api/master-data/bank'; import { BankApi } from '@/services/api/master-data'; @@ -32,16 +33,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -163,7 +155,7 @@ const BanksTable = () => { {currentPageSize <= 2 && ( @@ -212,10 +204,15 @@ const BanksTable = () => {
    -
    -
    diff --git a/src/components/pages/master-data/customer/CustomersTable.tsx b/src/components/pages/master-data/customer/CustomersTable.tsx index d3fde60b..89401638 100644 --- a/src/components/pages/master-data/customer/CustomersTable.tsx +++ b/src/components/pages/master-data/customer/CustomersTable.tsx @@ -8,6 +8,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import Table from '@/components/Table'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { ROWS_OPTIONS } from '@/config/constant'; import { isResponseSuccess } from '@/lib/api-helper'; import { cn } from '@/lib/helper'; @@ -15,10 +16,7 @@ import { CustomerApi } from '@/services/api/master-data'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { Customer } from '@/types/api/master-data/customer'; import { Icon } from '@iconify/react'; -import { - CellContext, - ColumnDef, -} from '@tanstack/react-table'; +import { CellContext, ColumnDef } from '@tanstack/react-table'; import { useState } from 'react'; import toast from 'react-hot-toast'; import useSWR from 'swr'; @@ -33,16 +31,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -174,7 +163,7 @@ const CustomersTable = () => { {currentPageSize <= 2 && ( @@ -210,10 +199,15 @@ const CustomersTable = () => {
    -
    -
    @@ -285,4 +279,4 @@ const CustomersTable = () => { ); }; -export default CustomersTable; \ No newline at end of file +export default CustomersTable; diff --git a/src/components/pages/master-data/fcr/FcrsTable.tsx b/src/components/pages/master-data/fcr/FcrsTable.tsx index 5f0285bb..b582222e 100644 --- a/src/components/pages/master-data/fcr/FcrsTable.tsx +++ b/src/components/pages/master-data/fcr/FcrsTable.tsx @@ -14,6 +14,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Fcr } from '@/types/api/master-data/fcr'; import { FcrApi } from '@/services/api/master-data'; @@ -32,16 +33,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -150,7 +142,7 @@ const FcrsTable = () => { {currentPageSize <= 2 && ( @@ -199,10 +191,15 @@ const FcrsTable = () => {
    -
    -
    diff --git a/src/components/pages/master-data/flock/FlocksTable.tsx b/src/components/pages/master-data/flock/FlocksTable.tsx index b0684a1a..5350c518 100644 --- a/src/components/pages/master-data/flock/FlocksTable.tsx +++ b/src/components/pages/master-data/flock/FlocksTable.tsx @@ -12,6 +12,7 @@ import { FlockApi } from '@/services/api/master-data'; import { useModal } from '@/components/Modal'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import toast from 'react-hot-toast'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; @@ -30,16 +31,7 @@ const RowsOptions = ({ deleteClickHandler: () => void; }) => { return ( -
    + - -
    + ); }; @@ -203,9 +195,15 @@ const FlockTable = () => {
    -
    -
    @@ -275,4 +273,4 @@ const FlockTable = () => { ); }; -export default FlockTable; \ No newline at end of file +export default FlockTable; diff --git a/src/components/pages/master-data/kandang/KandangsTable.tsx b/src/components/pages/master-data/kandang/KandangsTable.tsx index c51eeb21..45c981e1 100644 --- a/src/components/pages/master-data/kandang/KandangsTable.tsx +++ b/src/components/pages/master-data/kandang/KandangsTable.tsx @@ -19,6 +19,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Kandang } from '@/types/api/master-data/kandang'; import { KandangApi } from '@/services/api/master-data'; @@ -37,16 +38,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -173,7 +165,7 @@ const KandangsTable = () => { {currentPageSize <= 2 && ( @@ -238,10 +230,15 @@ const KandangsTable = () => {
    -
    -
    diff --git a/src/components/pages/master-data/location/LocationsTable.tsx b/src/components/pages/master-data/location/LocationsTable.tsx index 2548fb28..19f11298 100644 --- a/src/components/pages/master-data/location/LocationsTable.tsx +++ b/src/components/pages/master-data/location/LocationsTable.tsx @@ -19,6 +19,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Location } from '@/types/api/master-data/location'; import { LocationApi } from '@/services/api/master-data'; @@ -37,16 +38,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -172,7 +164,7 @@ const LocationsTable = () => { {currentPageSize <= 2 && ( @@ -237,10 +229,15 @@ const LocationsTable = () => {
    -
    -
    diff --git a/src/components/pages/master-data/nonstock/NonstocksTable.tsx b/src/components/pages/master-data/nonstock/NonstocksTable.tsx index 462b3488..ae38c573 100644 --- a/src/components/pages/master-data/nonstock/NonstocksTable.tsx +++ b/src/components/pages/master-data/nonstock/NonstocksTable.tsx @@ -19,6 +19,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Nonstock } from '@/types/api/master-data/nonstock'; import { NonstockApi } from '@/services/api/master-data'; @@ -37,16 +38,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -184,7 +176,7 @@ const NonstocksTable = () => { {currentPageSize <= 2 && ( @@ -249,10 +241,15 @@ const NonstocksTable = () => {
    -
    -
    diff --git a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx index 63b1c919..1a6e641c 100644 --- a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx +++ b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx @@ -14,6 +14,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { ProductCategory } from '@/types/api/master-data/product-category'; import { ProductCategoryApi } from '@/services/api/master-data'; @@ -32,16 +33,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -154,7 +146,7 @@ const ProductCategoryTable = () => { {currentPageSize <= 2 && ( @@ -200,10 +192,15 @@ const ProductCategoryTable = () => {
    -
    -
    ; deleteClickHandler: () => void; }) => ( -
    + -
    + ); const ProductsTable = () => { @@ -217,7 +209,7 @@ const ProductsTable = () => { {currentPageSize <= 2 && ( @@ -280,10 +272,15 @@ const ProductsTable = () => {
    -
    -
    void; }) => { return ( -
    + -
    + ); }; @@ -226,10 +218,15 @@ const SuppliersTable = () => {
    -
    -
    diff --git a/src/components/pages/master-data/uom/UomsTable.tsx b/src/components/pages/master-data/uom/UomsTable.tsx index dcec5fe5..edf67f34 100644 --- a/src/components/pages/master-data/uom/UomsTable.tsx +++ b/src/components/pages/master-data/uom/UomsTable.tsx @@ -14,6 +14,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Uom } from '@/types/api/master-data/uom'; import { UomApi } from '@/services/api/master-data'; @@ -32,16 +33,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -150,7 +142,7 @@ const UomsTable = () => { {currentPageSize <= 2 && ( @@ -199,10 +191,15 @@ const UomsTable = () => {
    -
    -
    diff --git a/src/components/pages/master-data/warehouse/WarehousesTable.tsx b/src/components/pages/master-data/warehouse/WarehousesTable.tsx index f6d2d071..a61f6f5b 100644 --- a/src/components/pages/master-data/warehouse/WarehousesTable.tsx +++ b/src/components/pages/master-data/warehouse/WarehousesTable.tsx @@ -19,6 +19,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Warehouse } from '@/types/api/master-data/warehouse'; import { WarehouseApi } from '@/services/api/master-data'; @@ -37,16 +38,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -206,7 +198,7 @@ const WarehousesTable = () => { {currentPageSize <= 2 && ( @@ -277,10 +269,15 @@ const WarehousesTable = () => {
    -
    -
    diff --git a/src/components/pages/production/chickin/ChickinTable.tsx b/src/components/pages/production/chickin/ChickinTable.tsx index 65ab3c16..cd52c154 100644 --- a/src/components/pages/production/chickin/ChickinTable.tsx +++ b/src/components/pages/production/chickin/ChickinTable.tsx @@ -8,6 +8,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import Table from '@/components/Table'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; import { ROWS_OPTIONS } from '@/config/constant'; import { isResponseSuccess } from '@/lib/api-helper'; @@ -87,7 +88,9 @@ const ChickinTable = () => {
    - { - refreshChickins() - chickinModal.closeModal() - }}/> + { + refreshChickins(); + chickinModal.closeModal(); + }} + /> ); @@ -276,16 +285,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; diff --git a/src/components/pages/production/project-flock/ProjectFlockTable.tsx b/src/components/pages/production/project-flock/ProjectFlockTable.tsx index 99f26721..56e3d4df 100644 --- a/src/components/pages/production/project-flock/ProjectFlockTable.tsx +++ b/src/components/pages/production/project-flock/ProjectFlockTable.tsx @@ -9,6 +9,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import Table from '@/components/Table'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { ROWS_OPTIONS } from '@/config/constant'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { cn } from '@/lib/helper'; @@ -37,16 +38,7 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
    + -
    + ); }; @@ -259,6 +251,7 @@ const ProjectFlockTable = () => {
    -
    + ); }; @@ -255,7 +247,7 @@ const RecordingTable = () => { void; }) => { return ( -
    + -
    + ); }; @@ -291,7 +283,7 @@ const TransferToLayingsTable = () => { {currentPageSize <= 2 && ( {
    {selectedRowIds.length > 0 && ( From 8a3c7d35ec566d03ce843241d14f20d07feecefd Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:35:34 +0700 Subject: [PATCH 092/276] chore: update add button styling and copywriting --- .../adjustment/InventoryAdjustmentTable.tsx | 22 +++++++++---------- .../inventory/movement/MovementTable.tsx | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx b/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx index 45cfd3f3..caa4aee4 100644 --- a/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx +++ b/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx @@ -10,11 +10,7 @@ import { inventoryAdjustmentApi } from '@/services/api/inventory'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { Icon } from '@iconify/react'; -import { - ColumnDef, - ColumnSort, - SortingState, -} from '@tanstack/react-table'; +import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table'; import { useCallback, useEffect, useState } from 'react'; import useSWR from 'swr'; @@ -44,10 +40,7 @@ const InventoryAdjustmentTable = () => { }); // Fetch Data - const { - data: inventoryAdjustments, - isLoading, - } = useSWR( + const { data: inventoryAdjustments, isLoading } = useSWR( `${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, inventoryAdjustmentApi.getAllFetcher ); @@ -187,8 +180,13 @@ const InventoryAdjustmentTable = () => {
    -
    - @@ -211,7 +209,7 @@ const InventoryAdjustmentTable = () => { value: tableFilterState.pageSize, }} onChange={pageSizeChangeHandler} - className={{ wrapper: 'max-w-28' }} + className={{ wrapper: 'min-w-28' }} />
    diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index 61be40f8..6926ce4e 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -77,7 +77,7 @@ const MovementTable = () => { Date: Sat, 1 Nov 2025 15:35:49 +0700 Subject: [PATCH 093/276] chore: set min width for RowCollapseOptions --- src/components/table/RowCollapseOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/table/RowCollapseOptions.tsx b/src/components/table/RowCollapseOptions.tsx index 42f9720a..ce90314c 100644 --- a/src/components/table/RowCollapseOptions.tsx +++ b/src/components/table/RowCollapseOptions.tsx @@ -16,7 +16,7 @@ const RowCollapseOptions = ({ children }: RowCollapseOptionsProps) => { } - className='w-fit' + className='w-fit min-w-36' titleClassName='p-0! justify-self-end' > {children} From b2540f1d43e5488944496bf12babf9817c12f67c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:36:11 +0700 Subject: [PATCH 094/276] chore: use RowOptionsMenuWrapper --- src/components/table/TableRowOptions.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/components/table/TableRowOptions.tsx b/src/components/table/TableRowOptions.tsx index 4e2e2c93..6c92c928 100644 --- a/src/components/table/TableRowOptions.tsx +++ b/src/components/table/TableRowOptions.tsx @@ -1,6 +1,6 @@ import { Icon } from '@iconify/react'; import Button from '../Button'; -import { cn } from '@/lib/helper'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; interface TableRowOptionsProps { type?: 'dropdown' | 'collapse'; @@ -21,16 +21,7 @@ export const TableRowOptions = ({ showEdit = true, showDelete = true, }: TableRowOptionsProps) => ( -
    +
    + ); From 46572fd992430bb2b764abb9eb89a1bd37c57467 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:36:21 +0700 Subject: [PATCH 095/276] chore: update add button styling --- src/components/table/TableToolbar.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/table/TableToolbar.tsx b/src/components/table/TableToolbar.tsx index e3b385b1..4ec76931 100644 --- a/src/components/table/TableToolbar.tsx +++ b/src/components/table/TableToolbar.tsx @@ -18,8 +18,13 @@ export const TableToolbar = ({ addButton, search }: TableToolbarProps) => { return (
    {addButton && ( -
    - From 42b4206e6605ae8423eb2cd9da1193bd75065fa6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:53:37 +0700 Subject: [PATCH 096/276] chore: install prettier --- package-lock.json | 17 +++++++++++++++++ package.json | 1 + 2 files changed, 18 insertions(+) diff --git a/package-lock.json b/package-lock.json index e1f28d3e..33b7c640 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "eslint": "^9", "eslint-config-next": "15.5.3", "husky": "^9.1.7", + "prettier": "^3.6.2", "tailwindcss": "^4", "typescript": "^5" } @@ -5669,6 +5670,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index b371e4e7..8250d68f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "eslint": "^9", "eslint-config-next": "15.5.3", "husky": "^9.1.7", + "prettier": "^3.6.2", "tailwindcss": "^4", "typescript": "^5" } From f01dae5f97e88b1a6c83c6843674cf8f440bf405 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:58:03 +0700 Subject: [PATCH 097/276] chore: add format script --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8250d68f..10fe9598 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build --turbopack", "start": "next start", "lint": "eslint", - "prepare": "husky" + "prepare": "husky", + "format": "prettier --write ." }, "dependencies": { "@tanstack/match-sorter-utils": "^8.19.4", From 0ae4fe083127d55f65d92a43a8091f10b24a3859 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 1 Nov 2025 15:58:47 +0700 Subject: [PATCH 098/276] chore: format code using prettier --- eslint.config.mjs | 18 +- postcss.config.mjs | 2 +- src/app/globals.css | 6 +- src/app/inventory/adjustment/add/page.tsx | 10 +- .../inventory/adjustment/detail/layout.tsx | 12 +- src/app/inventory/adjustment/detail/page.tsx | 15 +- src/app/master-data/customer/add/page.tsx | 10 +- src/app/master-data/customer/detail/page.tsx | 32 +- src/app/master-data/customer/page.tsx | 8 +- src/app/master-data/flock/add/page.tsx | 6 +- .../master-data/flock/detail/edit/page.tsx | 16 +- src/app/master-data/flock/detail/layout.tsx | 12 +- src/app/master-data/flock/detail/page.tsx | 35 +- src/app/master-data/flock/page.tsx | 10 +- .../master-data/product-category/add/page.tsx | 6 +- .../product-category/detail/edit/page.tsx | 63 +- .../product-category/detail/page.tsx | 14 +- src/app/master-data/product-category/page.tsx | 6 +- src/app/master-data/product/add/page.tsx | 4 +- .../master-data/product/detail/edit/page.tsx | 7 +- src/app/master-data/product/detail/page.tsx | 7 +- src/app/master-data/product/page.tsx | 8 +- src/app/master-data/supplier/add/page.tsx | 2 +- src/app/master-data/supplier/detail/page.tsx | 2 +- src/app/master-data/supplier/page.tsx | 2 +- src/app/production/chickin/add/layout.tsx | 12 +- src/app/production/chickin/detail/layout.tsx | 12 +- src/app/production/chickin/detail/page.tsx | 10 +- src/app/production/chickin/page.tsx | 10 +- src/app/production/project-flock/add/page.tsx | 12 +- .../project-flock/detail/edit/page.tsx | 39 +- .../project-flock/detail/layout.tsx | 12 +- .../production/project-flock/detail/page.tsx | 6 +- src/app/production/project-flock/page.tsx | 8 +- src/components/Card.tsx | 28 +- src/components/Pagination.tsx | 20 +- src/components/input/DateInput.tsx | 11 +- src/components/input/FileInput.tsx | 5 +- src/components/input/NumberInput.tsx | 16 +- src/components/pages/ApprovalSteps.tsx | 20 +- .../adjustment/InventoryAdjustmentTable.tsx | 4 +- .../form/InventoryAdjustmentForm.schema.ts | 9 +- .../form/InventoryAdjustmentForm.tsx | 45 +- .../customer/form/CustomerForm.tsx | 9 +- .../flock/form/FlockForm.schema.ts | 5 +- .../master-data/flock/form/FlockForm.tsx | 11 +- .../form/ProductCategoryForm.schema.ts | 8 +- .../form/ProductCategoryForm.tsx | 17 +- .../product/form/ProductForm.schema.ts | 65 +- .../master-data/product/form/ProductForm.tsx | 134 +- .../supplier/form/SupplierForm.schema.ts | 67 +- .../supplier/form/SupplierForm.tsx | 16 +- .../chickin/form/ChickinForm.schema.ts | 8 +- .../form/ProjectFlockForm.schema.ts | 3 +- .../project-flock/form/ProjectFlockForm.tsx | 19 +- .../form/ProjectFlockKandangTable.tsx | 7 +- .../recording/form/RecordingForm.tsx | 1403 +++++++++-------- .../TransferToLayingsTable.tsx | 18 +- src/services/api/base.ts | 11 +- src/services/api/master-data.ts | 2 +- src/services/api/production.ts | 2 +- src/types/api/master-data/customer.d.ts | 40 +- src/types/api/master-data/product.d.ts | 2 +- src/types/api/master-data/supplier.d.ts | 60 +- src/types/api/production/chickin.d.ts | 10 +- .../api/production/project-flock-kandang.d.ts | 8 +- 66 files changed, 1319 insertions(+), 1198 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 719cea2b..fa167c8d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,6 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -10,14 +10,14 @@ const compat = new FlatCompat({ }); const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), + ...compat.extends('next/core-web-vitals', 'next/typescript'), { ignores: [ - "node_modules/**", - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + 'node_modules/**', + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', ], }, ]; diff --git a/postcss.config.mjs b/postcss.config.mjs index c7bcb4b1..ba720fe5 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,5 @@ const config = { - plugins: ["@tailwindcss/postcss"], + plugins: ['@tailwindcss/postcss'], }; export default config; diff --git a/src/app/globals.css b/src/app/globals.css index 97be6978..c3d05c67 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,10 +3,10 @@ @import '../styles/daisyui.css'; @plugin "daisyui/theme" { - name: "lti"; + name: 'lti'; default: false; prefersdark: false; - color-scheme: "light"; + color-scheme: 'light'; --color-base-100: oklch(98% 0.001 106.423); --color-base-200: oklch(97% 0.001 106.424); --color-base-300: oklch(92% 0.003 48.717); @@ -37,8 +37,6 @@ --noise: 0; } - - :root { --color-primary: #1f74bf; } diff --git a/src/app/inventory/adjustment/add/page.tsx b/src/app/inventory/adjustment/add/page.tsx index 3bd64573..e20eedfc 100644 --- a/src/app/inventory/adjustment/add/page.tsx +++ b/src/app/inventory/adjustment/add/page.tsx @@ -1,11 +1,11 @@ -import InventoryAdjustmentForm from "@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm"; +import InventoryAdjustmentForm from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm'; const CreateInventoryAdjustment = () => { return ( -
    - +
    +
    ); -} +}; -export default CreateInventoryAdjustment; \ No newline at end of file +export default CreateInventoryAdjustment; diff --git a/src/app/inventory/adjustment/detail/layout.tsx b/src/app/inventory/adjustment/detail/layout.tsx index b41c70f9..7220dfa1 100644 --- a/src/app/inventory/adjustment/detail/layout.tsx +++ b/src/app/inventory/adjustment/detail/layout.tsx @@ -1,11 +1,11 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" +import SuspenseHelper from '@/components/helper/SuspenseHelper'; const Layout = ({ - children + children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) => { - return {children} -} + return {children}; +}; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/src/app/inventory/adjustment/detail/page.tsx b/src/app/inventory/adjustment/detail/page.tsx index 5e96c86a..acb9f8db 100644 --- a/src/app/inventory/adjustment/detail/page.tsx +++ b/src/app/inventory/adjustment/detail/page.tsx @@ -7,11 +7,12 @@ import type { InventoryAdjustment } from '@/types/api/inventory/adjustment'; const DetailInventoryAdjustment = () => { const router = useRouter(); - const [inventoryAdjustment, setInventoryAdjustment] = useState(null); + const [inventoryAdjustment, setInventoryAdjustment] = + useState(null); // Ambil data dari router state useEffect(() => { - console.log("Router State"); + console.log('Router State'); console.log(window.history.state); const state = window.history.state?.usr as | { inventoryAdjustment?: InventoryAdjustment } @@ -24,20 +25,20 @@ const DetailInventoryAdjustment = () => { }, [router]); const finalData = inventoryAdjustment; - - console.log("Final Data"); + + console.log('Final Data'); console.log(finalData); if (!finalData) { return ( -
    - +
    +
    ); } return ( -
    +
    ); diff --git a/src/app/master-data/customer/add/page.tsx b/src/app/master-data/customer/add/page.tsx index a1096f02..dd75c679 100644 --- a/src/app/master-data/customer/add/page.tsx +++ b/src/app/master-data/customer/add/page.tsx @@ -1,11 +1,11 @@ -import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm"; +import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm'; const AddCustomer = () => { return ( -
    - +
    +
    ); -} +}; -export default AddCustomer; \ No newline at end of file +export default AddCustomer; diff --git a/src/app/master-data/customer/detail/page.tsx b/src/app/master-data/customer/detail/page.tsx index 263458c2..d778f83b 100644 --- a/src/app/master-data/customer/detail/page.tsx +++ b/src/app/master-data/customer/detail/page.tsx @@ -1,45 +1,47 @@ -'use client' +'use client'; -import { useRouter, useSearchParams } from "next/navigation"; -import useSWR from "swr"; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; import { CustomerApi } from '@/services/api/master-data'; -import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; -import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm"; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm'; const CustomerDetail = () => { const router = useRouter(); const searchParams = useSearchParams(); - const costumerId = searchParams.get("customerId"); + const costumerId = searchParams.get('customerId'); const { data: costumer, isLoading: isLoadingCostumer } = useSWR( costumerId, (id: number) => CustomerApi.getSingle(id) ); - if(!costumerId){ + if (!costumerId) { router.back(); return ( -
    - +
    +
    ); } - if(!isLoadingCostumer && (!costumer || isResponseError(costumer))){ - router.replace("/404"); + if (!isLoadingCostumer && (!costumer || isResponseError(costumer))) { + router.replace('/404'); return; } return ( -
    - {isLoadingCostumer && } +
    + {isLoadingCostumer && ( + + )} {!isLoadingCostumer && isResponseSuccess(costumer) && ( - + )}
    - ) + ); }; export default CustomerDetail; diff --git a/src/app/master-data/customer/page.tsx b/src/app/master-data/customer/page.tsx index b80401f1..8aec1088 100644 --- a/src/app/master-data/customer/page.tsx +++ b/src/app/master-data/customer/page.tsx @@ -1,11 +1,11 @@ -import CustomersTable from "@/components/pages/master-data/customer/CustomersTable"; +import CustomersTable from '@/components/pages/master-data/customer/CustomersTable'; const Customer = () => { return ( -
    +
    - ) + ); }; -export default Customer; \ No newline at end of file +export default Customer; diff --git a/src/app/master-data/flock/add/page.tsx b/src/app/master-data/flock/add/page.tsx index 5ee3958e..d038d414 100644 --- a/src/app/master-data/flock/add/page.tsx +++ b/src/app/master-data/flock/add/page.tsx @@ -1,11 +1,11 @@ -import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; +import FlockForm from '@/components/pages/master-data/flock/form/FlockForm'; const AddFlock = () => { return ( -
    +
    ); -} +}; export default AddFlock; diff --git a/src/app/master-data/flock/detail/edit/page.tsx b/src/app/master-data/flock/detail/edit/page.tsx index c9651727..babc6653 100644 --- a/src/app/master-data/flock/detail/edit/page.tsx +++ b/src/app/master-data/flock/detail/edit/page.tsx @@ -1,10 +1,10 @@ -'use client' +'use client'; -import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; -import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; -import { FlockApi } from "@/services/api/master-data"; -import { useRouter, useSearchParams } from "next/navigation"; -import useSWR from "swr"; +import FlockForm from '@/components/pages/master-data/flock/form/FlockForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { FlockApi } from '@/services/api/master-data'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; const FlockEdit = () => { const router = useRouter(); @@ -44,6 +44,6 @@ const FlockEdit = () => { )}
    ); -} +}; -export default FlockEdit; \ No newline at end of file +export default FlockEdit; diff --git a/src/app/master-data/flock/detail/layout.tsx b/src/app/master-data/flock/detail/layout.tsx index b41c70f9..7220dfa1 100644 --- a/src/app/master-data/flock/detail/layout.tsx +++ b/src/app/master-data/flock/detail/layout.tsx @@ -1,11 +1,11 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" +import SuspenseHelper from '@/components/helper/SuspenseHelper'; const Layout = ({ - children + children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) => { - return {children} -} + return {children}; +}; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/src/app/master-data/flock/detail/page.tsx b/src/app/master-data/flock/detail/page.tsx index 8a805911..e9620d33 100644 --- a/src/app/master-data/flock/detail/page.tsx +++ b/src/app/master-data/flock/detail/page.tsx @@ -1,10 +1,10 @@ -'use client' +'use client'; -import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; -import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; -import { FlockApi } from "@/services/api/master-data"; -import { useRouter, useSearchParams } from "next/navigation"; -import useSWR from "swr"; +import FlockForm from '@/components/pages/master-data/flock/form/FlockForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { FlockApi } from '@/services/api/master-data'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; const FlockDetail = () => { const router = useRouter(); @@ -14,33 +14,36 @@ const FlockDetail = () => { const flockId = searchParams.get('flockId'); // Fetch Data - const { data: flock, isLoading: isLoadingFlock } = useSWR(flockId, (id: number) => FlockApi.getSingle(id)); + const { data: flock, isLoading: isLoadingFlock } = useSWR( + flockId, + (id: number) => FlockApi.getSingle(id) + ); - if(!flockId){ + if (!flockId) { router.back(); return ( -
    - +
    +
    ); } - if(!isLoadingFlock && (!flock || isResponseError(flock))){ + if (!isLoadingFlock && (!flock || isResponseError(flock))) { router.replace('/404'); return; } return ( -
    +
    {isLoadingFlock && ( - + )} {!isLoadingFlock && isResponseSuccess(flock) && ( - + )}
    ); -} +}; -export default FlockDetail; \ No newline at end of file +export default FlockDetail; diff --git a/src/app/master-data/flock/page.tsx b/src/app/master-data/flock/page.tsx index b317091a..76cc32c1 100644 --- a/src/app/master-data/flock/page.tsx +++ b/src/app/master-data/flock/page.tsx @@ -1,11 +1,11 @@ -import FlockTable from "@/components/pages/master-data/flock/FlocksTable"; +import FlockTable from '@/components/pages/master-data/flock/FlocksTable'; const Flock = () => { return ( -
    - +
    +
    - ); -} + ); +}; export default Flock; diff --git a/src/app/master-data/product-category/add/page.tsx b/src/app/master-data/product-category/add/page.tsx index 0993ba7a..2331159e 100644 --- a/src/app/master-data/product-category/add/page.tsx +++ b/src/app/master-data/product-category/add/page.tsx @@ -1,11 +1,11 @@ -import ProductCategoryForm from "@/components/pages/master-data/product-category/form/ProductCategoryForm"; +import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm'; const AddProductCategory = () => { return ( -
    +
    ); }; -export default AddProductCategory; \ No newline at end of file +export default AddProductCategory; diff --git a/src/app/master-data/product-category/detail/edit/page.tsx b/src/app/master-data/product-category/detail/edit/page.tsx index 6bc10644..4cb7eb5a 100644 --- a/src/app/master-data/product-category/detail/edit/page.tsx +++ b/src/app/master-data/product-category/detail/edit/page.tsx @@ -9,39 +9,44 @@ import { ProductCategoryApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const ProductCategoryEdit = () => { - const router = useRouter(); - const searchParams = useSearchParams(); + const router = useRouter(); + const searchParams = useSearchParams(); - const productCategoryId = searchParams.get('productCategoryId'); + const productCategoryId = searchParams.get('productCategoryId'); - const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR( - productCategoryId, - (id: number) => ProductCategoryApi.getSingle(id) - ); + const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR( + productCategoryId, + (id: number) => ProductCategoryApi.getSingle(id) + ); - if (!productCategoryId) { - router.back(); - - return ( -
    - -
    - ); - } - - if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) { - router.replace('/404'); - return; - } + if (!productCategoryId) { + router.back(); return ( -
    - {isLoadingProductCategory && } - {!isLoadingProductCategory && isResponseSuccess(productCategory) && ( - - )} -
    +
    + +
    ); -} + } -export default ProductCategoryEdit; \ No newline at end of file + if ( + !isLoadingProductCategory && + (!productCategory || isResponseError(productCategory)) + ) { + router.replace('/404'); + return; + } + + return ( +
    + {isLoadingProductCategory && ( + + )} + {!isLoadingProductCategory && isResponseSuccess(productCategory) && ( + + )} +
    + ); +}; + +export default ProductCategoryEdit; diff --git a/src/app/master-data/product-category/detail/page.tsx b/src/app/master-data/product-category/detail/page.tsx index cba06fdb..c1a21aaf 100644 --- a/src/app/master-data/product-category/detail/page.tsx +++ b/src/app/master-data/product-category/detail/page.tsx @@ -29,16 +29,24 @@ const ProductCategoryDetail = () => { ); } - if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) { + if ( + !isLoadingProductCategory && + (!productCategory || isResponseError(productCategory)) + ) { router.replace('/404'); return; } return (
    - {isLoadingProductCategory && } + {isLoadingProductCategory && ( + + )} {!isLoadingProductCategory && isResponseSuccess(productCategory) && ( - + )}
    ); diff --git a/src/app/master-data/product-category/page.tsx b/src/app/master-data/product-category/page.tsx index 5ec6d555..78a4fda3 100644 --- a/src/app/master-data/product-category/page.tsx +++ b/src/app/master-data/product-category/page.tsx @@ -1,11 +1,11 @@ -import ProductCategoryTable from "@/components/pages/master-data/product-category/ProductCategoryTable"; +import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable'; const ProductCategory = () => { return ( -
    +
    ); }; -export default ProductCategory; \ No newline at end of file +export default ProductCategory; diff --git a/src/app/master-data/product/add/page.tsx b/src/app/master-data/product/add/page.tsx index 7cc995b6..37f42691 100644 --- a/src/app/master-data/product/add/page.tsx +++ b/src/app/master-data/product/add/page.tsx @@ -2,10 +2,10 @@ import ProductForm from '@/components/pages/master-data/product/form/ProductForm const AddProduct = () => { return ( -
    +
    ); }; -export default AddProduct; \ No newline at end of file +export default AddProduct; diff --git a/src/app/master-data/product/detail/edit/page.tsx b/src/app/master-data/product/detail/edit/page.tsx index 96cfdc42..8916a98e 100644 --- a/src/app/master-data/product/detail/edit/page.tsx +++ b/src/app/master-data/product/detail/edit/page.tsx @@ -13,9 +13,8 @@ const ProductEdit = () => { const productId = searchParams.get('productId'); - const { data: product, isLoading } = useSWR( - productId, - (id: number) => ProductApi.getSingle(id) + const { data: product, isLoading } = useSWR(productId, (id: number) => + ProductApi.getSingle(id) ); if (!productId) { @@ -42,4 +41,4 @@ const ProductEdit = () => { ); }; -export default ProductEdit; \ No newline at end of file +export default ProductEdit; diff --git a/src/app/master-data/product/detail/page.tsx b/src/app/master-data/product/detail/page.tsx index 916a44d0..34743e1f 100644 --- a/src/app/master-data/product/detail/page.tsx +++ b/src/app/master-data/product/detail/page.tsx @@ -13,9 +13,8 @@ const ProductDetail = () => { const productId = searchParams.get('productId'); - const { data: product, isLoading } = useSWR( - productId, - (id: number) => ProductApi.getSingle(id) + const { data: product, isLoading } = useSWR(productId, (id: number) => + ProductApi.getSingle(id) ); if (!productId) { @@ -42,4 +41,4 @@ const ProductDetail = () => { ); }; -export default ProductDetail; \ No newline at end of file +export default ProductDetail; diff --git a/src/app/master-data/product/page.tsx b/src/app/master-data/product/page.tsx index 6014aeb9..a385d411 100644 --- a/src/app/master-data/product/page.tsx +++ b/src/app/master-data/product/page.tsx @@ -1,11 +1,11 @@ -import ProductsTable from "@/components/pages/master-data/product/ProductTable"; +import ProductsTable from '@/components/pages/master-data/product/ProductTable'; const Product = () => { return ( -
    - +
    +
    ); }; -export default Product; \ No newline at end of file +export default Product; diff --git a/src/app/master-data/supplier/add/page.tsx b/src/app/master-data/supplier/add/page.tsx index 8a95c3c6..37df33b0 100644 --- a/src/app/master-data/supplier/add/page.tsx +++ b/src/app/master-data/supplier/add/page.tsx @@ -8,4 +8,4 @@ const AddSupplier = () => { ); }; -export default AddSupplier; \ No newline at end of file +export default AddSupplier; diff --git a/src/app/master-data/supplier/detail/page.tsx b/src/app/master-data/supplier/detail/page.tsx index 433fa043..a34ad72e 100644 --- a/src/app/master-data/supplier/detail/page.tsx +++ b/src/app/master-data/supplier/detail/page.tsx @@ -46,4 +46,4 @@ const SupplierDetail = () => { ); }; -export default SupplierDetail; \ No newline at end of file +export default SupplierDetail; diff --git a/src/app/master-data/supplier/page.tsx b/src/app/master-data/supplier/page.tsx index 1f54bd0d..8000be0a 100644 --- a/src/app/master-data/supplier/page.tsx +++ b/src/app/master-data/supplier/page.tsx @@ -1,4 +1,4 @@ -import SuppliersTable from "@/components/pages/master-data/supplier/SupplierTable"; +import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable'; const Supplier = () => { return ( diff --git a/src/app/production/chickin/add/layout.tsx b/src/app/production/chickin/add/layout.tsx index b41c70f9..7220dfa1 100644 --- a/src/app/production/chickin/add/layout.tsx +++ b/src/app/production/chickin/add/layout.tsx @@ -1,11 +1,11 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" +import SuspenseHelper from '@/components/helper/SuspenseHelper'; const Layout = ({ - children + children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) => { - return {children} -} + return {children}; +}; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/src/app/production/chickin/detail/layout.tsx b/src/app/production/chickin/detail/layout.tsx index b41c70f9..7220dfa1 100644 --- a/src/app/production/chickin/detail/layout.tsx +++ b/src/app/production/chickin/detail/layout.tsx @@ -1,11 +1,11 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" +import SuspenseHelper from '@/components/helper/SuspenseHelper'; const Layout = ({ - children + children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) => { - return {children} -} + return {children}; +}; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/src/app/production/chickin/detail/page.tsx b/src/app/production/chickin/detail/page.tsx index 96647c55..be8c5332 100644 --- a/src/app/production/chickin/detail/page.tsx +++ b/src/app/production/chickin/detail/page.tsx @@ -20,7 +20,7 @@ import useSWR from 'swr'; /** * TODO: Refactor code - pindahin detail ke reuseable component - * setelah implement approval and reject + * setelah implement approval and reject */ const DetailChickin = () => { @@ -43,9 +43,8 @@ const DetailChickin = () => { // chickin.data?.approval.step_number == 1 ? false : true true ); - const [isRejectedDisabled, setIsRejectedDisabled] = useState( - !isApprovedDisabled - ); + const [isRejectedDisabled, setIsRejectedDisabled] = + useState(!isApprovedDisabled); const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>( !isApprovedDisabled ? 'APPROVED' : 'REJECTED' ); @@ -264,7 +263,8 @@ const DetailChickin = () => { Delete -