diff --git a/package.json b/package.json index edd750f6..34c07ec3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "eslint", "prepare": "husky", - "format": "prettier --write ." + "format": "prettier --write .", + "pre-commit": "npm run format && npm run lint && npx tsc --noEmit && npm run build" }, "dependencies": { "@react-pdf/renderer": "^4.3.1", diff --git a/src/app/daily-checklist/master-data/kandang/page.tsx b/src/app/daily-checklist/master-data/kandang/page.tsx new file mode 100644 index 00000000..dd2b3142 --- /dev/null +++ b/src/app/daily-checklist/master-data/kandang/page.tsx @@ -0,0 +1,11 @@ +import { MasterKandangContent } from '@/figma-make/components/pages/master-data/kandang/MasterKandangContent'; + +const MasterKandangPage = () => { + return ( +
+ +
+ ); +}; + +export default MasterKandangPage; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 0edb3b6d..32f8dbcd 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -24,8 +24,8 @@ import { } from '@/types/api/api-general'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -export interface OptionType { - value: string | number; +export interface OptionType { + value: T; label: string; className?: string; labelClassName?: string; diff --git a/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx b/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx index 90b68b7d..ed34efc2 100644 --- a/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx +++ b/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react'; import { usePathname } from 'next/navigation'; -import useSWR from 'swr'; +import useSWR, { mutate } from 'swr'; import { Icon } from '@iconify/react'; import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table'; import { useFormik } from 'formik'; @@ -26,6 +26,10 @@ import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { WarehouseApi, ProductApi } from '@/services/api/master-data'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useUiStore } from '@/stores/ui/ui.store'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import PopoverButton from '@/components/popover/PopoverButton'; +import PopoverContent from '@/components/popover/PopoverContent'; +import toast from 'react-hot-toast'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { Warehouse } from '@/types/api/master-data/warehouse'; import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant'; @@ -38,6 +42,62 @@ import { AdjustmentFilterType, } from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter'; import SelectInputRadio from '@/components/input/SelectInputRadio'; +import { CellContext } from '@tanstack/react-table'; + +const RowOptionsMenu = ({ + popoverPosition = 'bottom', + props, + deleteClickHandler, +}: { + popoverPosition: 'bottom' | 'top'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + const popoverId = `adjustment#${props.row.original.id}`; + const popoverAnchorName = `--anchor-adjustment#${props.row.original.id}`; + + const closePopover = () => { + document.getElementById(popoverId)?.hidePopover(); + }; + + return ( +
+ + + + + +
+ + + +
+
+
+ ); +}; const InventoryAdjustmentTable = () => { const { searchValue, setSearchValue, setTableState } = useUiStore(); @@ -80,13 +140,13 @@ const InventoryAdjustmentTable = () => { const formik = useFormik({ initialValues: { product_id: null, - warehouse_id: null, + warehouse: null, transaction_type: null, }, validationSchema: AdjustmentFilterSchema, onSubmit: (values, { setSubmitting }) => { updateFilter('productFilter', values.product_id || ''); - updateFilter('warehouseFilter', values.warehouse_id || ''); + updateFilter('warehouseFilter', String(values.warehouse?.value) || ''); updateFilter('transactionTypeFilter', values.transaction_type || ''); filterModal.closeModal(); setSubmitting(false); @@ -142,14 +202,11 @@ const InventoryAdjustmentTable = () => { [formik] ); - const handleFilterWarehouseChange = useCallback( - (val: OptionType | OptionType[] | null) => { - const warehouse = val as OptionType | null; - const warehouseId = warehouse?.value ? String(warehouse.value) : null; - formik.setFieldValue('warehouse_id', warehouseId); - }, - [formik] - ); + const handleFilterWarehouseChange = ( + val: OptionType | OptionType[] | null + ) => { + formik.setFieldValue('warehouse', val); + }; const handleFilterTransactionTypeChange = useCallback( (val: OptionType | OptionType[] | null) => { @@ -170,15 +227,6 @@ const InventoryAdjustmentTable = () => { ); }, [formik.values.product_id, productOptions]); - const warehouseIdValue = useMemo(() => { - if (!formik.values.warehouse_id) return null; - return ( - warehouseOptions.find( - (opt) => String(opt.value) === formik.values.warehouse_id - ) || null - ); - }, [formik.values.warehouse_id, warehouseOptions]); - const transactionTypeValue = useMemo(() => { if (!formik.values.transaction_type) return null; return ( @@ -194,12 +242,39 @@ const InventoryAdjustmentTable = () => { formik.validateForm(); }; - const { data: inventoryAdjustments, isLoading } = useSWR( + const { + data: inventoryAdjustments, + isLoading, + mutate: refreshAdjustments, + } = useSWR( `${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, InventoryAdjustmentApi.getAllFetcher ); + const singleDeleteHandler = async () => { + setIsDeleteLoading(true); + + const response = await InventoryAdjustmentApi.delete( + selectedAdjustment?.id as number + ); + + singleDeleteModal.closeModal(); + setIsDeleteLoading(false); + + if (isResponseSuccess(response)) { + toast.success(response?.message || 'Successfully delete Adjustment!'); + refreshAdjustments(); + } else { + toast.error(response?.message || 'Failed to delete Adjustment'); + } + }; + const [sorting, setSorting] = useState([]); + const [selectedAdjustment, setSelectedAdjustment] = useState< + InventoryAdjustment | undefined + >(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const singleDeleteModal = useModal(); useEffect(() => { updateFilter('search', searchValue); @@ -314,8 +389,39 @@ const InventoryAdjustmentTable = () => { header: 'Oleh', accessorFn: (row) => row.created_user?.name ?? '-', }, + { + id: 'actions', + header: 'Aksi', + cell: (props: CellContext) => { + 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 = () => { + setSelectedAdjustment(props.row.original); + singleDeleteModal.openModal(); + }; + + return ( + + ); + }, + }, ], - [tableFilterState.pageSize, tableFilterState.page] + [ + tableFilterState.pageSize, + tableFilterState.page, + singleDeleteModal, + setSelectedAdjustment, + ] ); const updateSortingFilter = useCallback( @@ -502,7 +608,7 @@ const InventoryAdjustmentTable = () => { label='Gudang' placeholder='Pilih Gudang' options={warehouseOptions} - value={warehouseIdValue} + value={formik.values.warehouse} onChange={handleFilterWarehouseChange} onInputChange={setWarehouseInputValue} isLoading={isLoadingWarehouseOptions} @@ -544,6 +650,21 @@ const InventoryAdjustmentTable = () => { + + ); }; diff --git a/src/components/pages/inventory/adjustment/filter/AdjustmentFilter.ts b/src/components/pages/inventory/adjustment/filter/AdjustmentFilter.ts index 4568618f..e4015e07 100644 --- a/src/components/pages/inventory/adjustment/filter/AdjustmentFilter.ts +++ b/src/components/pages/inventory/adjustment/filter/AdjustmentFilter.ts @@ -1,4 +1,5 @@ import { string, object } from 'yup'; +import { OptionType } from '@/components/input/SelectInput'; export const AdjustmentFilterSchema = object().shape({ product_id: string().nullable(), @@ -8,6 +9,6 @@ export const AdjustmentFilterSchema = object().shape({ export type AdjustmentFilterType = { product_id: string | null; - warehouse_id: string | null; transaction_type: string | null; + warehouse: OptionType | null; }; diff --git a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx index ff710329..1abeab92 100644 --- a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx @@ -15,7 +15,7 @@ import { InventoryAdjustmentFormSchema, InventoryAdjustmentFormValues, } from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema'; -import { KandangApi, LocationApi } from '@/services/api/master-data'; +import { LocationApi } from '@/services/api/master-data'; import { ProjectFlockApi, ProjectFlockKandangApi, @@ -32,8 +32,6 @@ import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import AlertErrorList from '@/components/helper/form/FormErrors'; import { Location } from '@/types/api/master-data/location'; import { ProjectFlock } from '@/types/api/production/project-flock'; -import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; -import { Kandang } from '@/types/api/master-data/kandang'; import { Product } from '@/types/api/master-data/product'; import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; import { BaseApiResponse } from '@/types/api/api-general'; @@ -119,40 +117,19 @@ const InventoryAdjustmentForm = ({ } ); - const { rawData: approvedProjectFlockKandangsRawData } = - useSelect( - ProjectFlockKandangApi.basePath, - 'id', - 'id', - 'search', - { - step_name: 'Disetujui', - limit: '100', - } - ); - - const approvedProjectFlockKandangs = useMemo(() => { - if ( - approvedProjectFlockKandangsRawData && - 'data' in approvedProjectFlockKandangsRawData - ) { - return approvedProjectFlockKandangsRawData.data as ProjectFlockKandang[]; - } - return []; - }, [approvedProjectFlockKandangsRawData]); - const { - setInputValue: setKandangInputValue, - options: kandangOptionsFromApi, - isLoadingOptions: isLoadingKandangOptions, - loadMore: loadMoreKandangs, - } = useSelect( - selectedProjectFlock ? KandangApi.basePath : '', - 'id', - 'name', + options: projectFlockKandangOptions, + loadMore: loadMoreProjectFlockKandangs, + setInputValue: setProjectFlockKandangInputValue, + isLoadingOptions: isLoadingProjectFlockKandangOptions, + } = useSelect( + selectedProjectFlock ? ProjectFlockKandangApi.basePath : '', + 'kandang.id', + 'kandang.name', 'search', { - location_id: selectedProjectFlockLocationId, + step_name: 'Disetujui', + project_flock_id: String(selectedProjectFlock?.value), } ); @@ -222,26 +199,6 @@ const InventoryAdjustmentForm = ({ return (product?.flags as string[]) || []; }, [selectedProduct, productOptions]); - const kandangOptions = useMemo(() => { - let options: OptionType[] = []; - - if (selectedProjectFlock) { - const approvedKandangIds = approvedProjectFlockKandangs - .filter((pfk) => pfk.project_flock_id === selectedProjectFlock.value) - .map((pfk) => pfk.kandang_id); - - options = kandangOptionsFromApi.filter((kandang) => - approvedKandangIds.includes(kandang.value as number) - ); - } - - return options; - }, [ - selectedProjectFlock, - kandangOptionsFromApi, - approvedProjectFlockKandangs, - ]); - const formikInitialValues = useMemo>( () => ({ location: null, @@ -693,10 +650,10 @@ const InventoryAdjustmentForm = ({ label='Kandang' value={selectedKandang} onChange={kandangChangeHandler} - onInputChange={setKandangInputValue} - options={kandangOptions} - onMenuScrollToBottom={loadMoreKandangs} - isLoading={isLoadingKandangOptions} + onInputChange={setProjectFlockKandangInputValue} + options={projectFlockKandangOptions} + onMenuScrollToBottom={loadMoreProjectFlockKandangs} + isLoading={isLoadingProjectFlockKandangOptions} isError={ formik.touched.kandang_id && Boolean(formik.errors.kandang_id) } diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index f953099d..2b6f11e6 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react'; import { usePathname } from 'next/navigation'; -import useSWR from 'swr'; +import useSWR, { mutate } from 'swr'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import { useFormik } from 'formik'; @@ -21,6 +21,8 @@ import { cn } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useUiStore } from '@/stores/ui/ui.store'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import toast from 'react-hot-toast'; import Button from '@/components/Button'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import SelectInput, { useSelect } from '@/components/input/SelectInput'; @@ -41,9 +43,11 @@ import { const RowOptionsMenu = ({ popoverPosition = 'bottom', props, + deleteClickHandler, }: { popoverPosition: 'bottom' | 'top'; props: CellContext; + deleteClickHandler: () => void; }) => { const popoverId = `movement#${props.row.original.id}`; const popoverAnchorName = `--anchor-movement#${props.row.original.id}`; @@ -83,6 +87,20 @@ const RowOptionsMenu = ({ Detail + + + @@ -206,12 +224,37 @@ const MovementTable = () => { }; const [sorting, setSorting] = useState([]); + const [selectedMovement, setSelectedMovement] = useState< + Movement | undefined + >(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const singleDeleteModal = useModal(); - const { data: movements, isLoading } = useSWR( + const { + data: movements, + isLoading, + mutate: refreshMovements, + } = useSWR( `${MovementApi.basePath}${getTableFilterQueryString()}`, MovementApi.getAllFetcher ); + const singleDeleteHandler = async () => { + setIsDeleteLoading(true); + + const response = await MovementApi.delete(selectedMovement?.id as number); + + singleDeleteModal.closeModal(); + setIsDeleteLoading(false); + + if (isResponseSuccess(response)) { + toast.success(response?.message || 'Successfully delete Movement!'); + refreshMovements(); + } else { + toast.error(response?.message || 'Failed to delete Movement'); + } + }; + useEffect(() => { updateFilter('search', searchValue); }, [searchValue, updateFilter]); @@ -275,16 +318,27 @@ const MovementTable = () => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + const deleteClickHandler = () => { + setSelectedMovement(props.row.original); + singleDeleteModal.openModal(); + }; + return ( ); }, }, ], - [tableFilterState.pageSize, tableFilterState.page] + [ + tableFilterState.pageSize, + tableFilterState.page, + singleDeleteModal, + setSelectedMovement, + ] ); return ( @@ -455,6 +509,21 @@ const MovementTable = () => { + + ); }; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index f723e763..1907d498 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -82,6 +82,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { warehouse_id: number; warehouse_name: string; quantity: number; + transfer_available_qty?: number; } // ===== USE SELECT HOOKS ===== @@ -379,6 +380,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { warehouse_id: formik.values.source_warehouse_id ? formik.values.source_warehouse_id.toString() : '', + transfer_context: 'inventory_transfer', + stock_mode: 'exclude_chickin', } ); @@ -391,6 +394,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { warehouse_id: pw.warehouse.id, warehouse_name: pw.warehouse.name, quantity: pw.quantity, + transfer_available_qty: pw.transfer_available_qty, })) : []; }, [productWarehouses]); @@ -834,6 +838,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }, [formik.values.products, formik.values.deliveries]); const getAvailableStock = useCallback( + (productId: number) => { + if (type === 'detail') return 0; + const productWarehouse = productWarehouseOptions.find( + (pw) => pw.product_id === productId + ); + + return ( + productWarehouse?.transfer_available_qty ?? + productWarehouse?.quantity ?? + 0 + ); + }, + [productWarehouseOptions, type] + ); + + const getTotalStock = useCallback( (productId: number) => { if (type === 'detail') return 0; const productWarehouse = productWarehouseOptions.find( @@ -844,6 +864,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [productWarehouseOptions, type] ); + const hasAvailableQty = useCallback( + (productId: number) => { + const productWarehouse = productWarehouseOptions.find( + (pw) => pw.product_id === productId + ); + return productWarehouse?.transfer_available_qty !== undefined; + }, + [productWarehouseOptions] + ); + const getProductQtyBottomLabel = useCallback( (productIdx: number) => { if (type === 'detail') return undefined; @@ -851,16 +881,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { if (!product || !product.product_id) return undefined; const availableStock = getAvailableStock(product.product_id); + const totalStock = getTotalStock(product.product_id); const requestedQty = Number(product.product_qty) || 0; const remainingStock = availableStock - requestedQty; + const isAyamProduct = hasAvailableQty(product.product_id); if (requestedQty > 0) { + if (isAyamProduct) { + return `Sisa: ${formatNumber(remainingStock)} (Total: ${formatNumber(totalStock)})`; + } return `Sisa: ${formatNumber(remainingStock)}`; } + if (isAyamProduct) { + return `Tersedia: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)})`; + } + return `Tersedia: ${formatNumber(availableStock)}`; }, - [formik.values.products, getAvailableStock, type] + [ + formik.values.products, + getAvailableStock, + getTotalStock, + hasAvailableQty, + type, + ] ); const getDeliveryProductQtyBottomLabel = useCallback( @@ -922,15 +967,26 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { if (!product || !product.product_id) return null; const availableStock = getAvailableStock(product.product_id); + const totalStock = getTotalStock(product.product_id); const requestedQty = Number(product.product_qty) || 0; + const isAyamProduct = hasAvailableQty(product.product_id); if (requestedQty > availableStock) { + if (isAyamProduct) { + return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)}, terpakai untuk chickin: ${formatNumber(totalStock - availableStock)})`; + } return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`; } return null; }, - [formik.values.products, getAvailableStock, type] + [ + formik.values.products, + getAvailableStock, + getTotalStock, + hasAvailableQty, + type, + ] ); const validateDeliveryQty = useCallback( diff --git a/src/components/pages/master-data/kandang/KandangsTable.tsx b/src/components/pages/master-data/kandang/KandangsTable.tsx index 9d923cbe..698d3a96 100644 --- a/src/components/pages/master-data/kandang/KandangsTable.tsx +++ b/src/components/pages/master-data/kandang/KandangsTable.tsx @@ -314,6 +314,10 @@ const KandangsTable = () => { accessorFn: (row) => row.pic?.name ?? '-', header: 'PIC', }, + { + accessorFn: (row) => row.kandang_group?.name ?? '-', + header: 'Kandang Group', + }, { header: 'Aksi', cell: (props: CellContext) => { 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 3da93aef..39f9a632 100644 --- a/src/components/pages/master-data/kandang/form/KandangForm.schema.ts +++ b/src/components/pages/master-data/kandang/form/KandangForm.schema.ts @@ -1,3 +1,4 @@ +import { OptionType } from '@/components/input/SelectInput'; import * as Yup from 'yup'; type KandangFormSchemaType = { @@ -19,6 +20,7 @@ type KandangFormSchemaType = { } | undefined | null; + group?: OptionType; }; export const KandangFormSchema: Yup.ObjectSchema = @@ -42,6 +44,11 @@ export const KandangFormSchema: Yup.ObjectSchema = value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), + + group: Yup.object({ + value: Yup.number().min(1).required('Kandang Grup wajib diisi!'), + label: Yup.string().required('Kandang Grup wajib diisi!'), + }).required('Kandang Grup wajib diisi!'), }); export const UpdateKandangFormSchema = KandangFormSchema; diff --git a/src/components/pages/master-data/kandang/form/KandangForm.tsx b/src/components/pages/master-data/kandang/form/KandangForm.tsx index 87ddfd70..9f2d0e88 100644 --- a/src/components/pages/master-data/kandang/form/KandangForm.tsx +++ b/src/components/pages/master-data/kandang/form/KandangForm.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { useFormik } from 'formik'; +import { getIn, useFormik } from 'formik'; import { toast } from 'react-hot-toast'; import { Icon } from '@iconify/react'; @@ -34,6 +34,8 @@ import NumberInput from '@/components/input/NumberInput'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import AlertErrorList from '@/components/helper/form/FormErrors'; import { User } from '@/types/api/api-general'; +import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang'; +import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang'; interface KandangFormProps { type?: 'add' | 'edit' | 'detail'; @@ -96,6 +98,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => { label: initialValues.pic.name, } : null, + group: initialValues?.kandang_group + ? { + value: initialValues.kandang_group.id, + label: initialValues.kandang_group.name, + } + : undefined, }; }, [initialValues]); @@ -111,6 +119,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => { location_id: values.locationId!, capacity: values.capacity ? parseInt(values.capacity.toString()) : 0, pic_id: values.picId!, + group_id: values.group?.value as number, }; switch (type) { @@ -162,6 +171,23 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => { formik.setFieldValue('picId', (val as OptionType)?.value); }; + // Kandang Group + const { + setInputValue: setKandangGroupSelectInputValue, + options: kandangGroupOptions, + isLoadingOptions: isLoadingKandangGroupOptions, + loadMore: loadMoreKandangGroups, + } = useSelect( + DailyChecklistKandangApi.basePath, + 'id', + 'name' + ); + + const kandangGroupChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('group', true); + formik.setFieldValue('group', val); + }; + const deleteKandangClickHandler = () => { deleteModal.openModal(); }; @@ -269,6 +295,24 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => { isDisabled={type === 'detail'} isClearable /> + +
diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 1b7a326d..cea30502 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -21,6 +21,7 @@ import SelectInput, { useSelect } from '@/components/input/SelectInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import PopoverButton from '@/components/popover/PopoverButton'; import PopoverContent from '@/components/popover/PopoverContent'; +import Tooltip from '@/components/Tooltip'; import { useFormik } from 'formik'; import { AreaApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data'; @@ -36,6 +37,7 @@ import { import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton'; import Table from '@/components/Table'; import { type Recording } from '@/types/api/production/recording'; +import { getRecordingRestriction } from './recording-utils'; import { RecordingApi } from '@/services/api/production'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; @@ -105,30 +107,75 @@ const RowOptionsMenu = ({ }; const isRecordingEditable = (recording: Recording) => { - if ( - recording.executed_at && - recording.project_flock?.project_flock_category === 'GROWING' - ) { + const isGrowingCategory = + recording.project_flock?.project_flock_category === 'GROWING'; + const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying; + if (isGrowingLockedByLaying) { + return false; + } + + const currentIsLaying = + recording.project_flock?.project_flock_category === 'LAYING'; + + const restriction = getRecordingRestriction( + recording.is_laying, + recording.is_transition, + currentIsLaying + ); + + if (restriction.isLocked) { return false; } return true; }; + const getRecordingRestrictionInfo = (recording: Recording) => { + const isGrowingCategory = + recording.project_flock?.project_flock_category === 'GROWING'; + const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying; + if (isGrowingLockedByLaying) { + return { + canEditStock: false, + canEditDepletion: false, + canEditEgg: false, + isLocked: true, + lockReason: + 'Recording Growing tidak dapat diubah karena sudah masuk fase laying dan dipakai pada recording laying', + }; + } + + const currentIsLaying = + recording.project_flock?.project_flock_category === 'LAYING'; + + return getRecordingRestriction( + recording.is_laying, + recording.is_transition, + currentIsLaying + ); + }; + const isApproved = isRecordingApproved(props.row.original); const isRejected = isRecordingRejected(props.row.original); const isEditable = isRecordingEditable(props.row.original); + const restrictionInfo = getRecordingRestrictionInfo(props.row.original); return (
- - - + + + + { const singleDeleteHandler = async () => { setIsDeleteLoading(true); - await RecordingApi.delete(selectedRecording?.id as number); - refreshRecordings(); + const response = await RecordingApi.delete(selectedRecording?.id as number); singleDeleteModal.closeModal(); - toast.success('Successfully delete Recording!'); setIsDeleteLoading(false); + + if (isResponseSuccess(response)) { + toast.success(response?.message || 'Successfully delete Recording!'); + refreshRecordings(); + } else { + toast.error(response?.message || 'Failed to delete Recording'); + } }; const approveHandler = async (notes: string) => { @@ -761,11 +813,30 @@ const RecordingTable = () => { { header: 'Kategori', cell: (props) => { + const isTransition = props.row.original.is_transition; const category = - props.row.original.project_flock?.project_flock_category; - if (!category) return '-'; + props.row.original.project_flock?.project_flock_category || + 'GROWING'; const color = category === 'LAYING' ? 'info' : 'warning'; - return ; + + const isGrowingLocked = + category === 'GROWING' && props.row.original.is_laying; + + return ( +
+ + {isTransition && ( + + (Transisi) + + )} + {isGrowingLocked && ( + + (Penguncian) + + )} +
+ ); }, }, { diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index af4ab78b..c9236d19 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -70,7 +70,7 @@ import { } from '@/components/pages/production/recording/form/RecordingForm.schema'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; -import { formatDate, formatNumber } from '@/lib/helper'; +import { formatDate, formatNumber, cn } from '@/lib/helper'; import toast from 'react-hot-toast'; import ApprovalSteps, { useApprovalSteps, @@ -79,7 +79,9 @@ import { GROWING_RECORDING_APPROVAL_LINE, LAYING_RECORDING_APPROVAL_LINE, } from '@/config/approval-line'; +import { PROJECT_FLOCK_STATUS } from '@/config/constant'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; +import { getRecordingRestriction } from '../recording-utils'; interface RecordingFormProps { type?: 'add' | 'edit' | 'detail'; @@ -242,6 +244,23 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [isProductionStandardModalOpen, setIsProductionStandardModalOpen] = useState(false); + const calculateWeek = useCallback( + (day: number): number => { + if ( + productionStandards?.details && + productionStandards.details.length > 0 + ) { + const firstWeek = productionStandards.details[0].week; + + const weekOffset = Math.ceil(day / 7) - 1; + return firstWeek + weekOffset; + } + + return Math.ceil(day / 7); + }, + [productionStandards] + ); + useEffect(() => { const checkProductionStandardModalOpen = () => { const isOpen = productionStandardModal.ref.current?.open || false; @@ -272,73 +291,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return recording?.approval?.action === 'REJECTED'; }, []); - const isRecordingEditable = useCallback((recording?: Recording) => { - if ( - recording?.executed_at && - recording?.project_flock?.project_flock_category === 'GROWING' - ) { - return false; - } - return true; - }, []); - - // ===== PAYLOAD CREATION HELPERS ===== - const createGrowingPayload = useCallback( - (values: RecordingGrowingFormValues) => { - const depletions = values.depletions - ?.filter((d) => d.product_warehouse_id && d.qty) - .map((depletion) => ({ - product_warehouse_id: depletion.product_warehouse_id!, - qty: Number(depletion.qty) || 0, - })); - - return { - project_flock_kandang_id: values.project_flock_kandang_id, - record_date: values.record_date, - stocks: (values.stocks ?? []).map((stock) => ({ - product_warehouse_id: stock.product_warehouse_id, - qty: Number(stock.qty) || 0, - })), - ...(depletions && depletions.length > 0 && { depletions }), - }; - }, - [] - ); - - const createLayingPayload = useCallback( - (values: RecordingLayingFormValues) => { - const depletions = values.depletions - ?.filter((d) => d.product_warehouse_id && d.qty) - .map((depletion) => ({ - product_warehouse_id: depletion.product_warehouse_id!, - qty: Number(depletion.qty) || 0, - })); - - const eggs = values.eggs - ?.filter((e) => e.product_warehouse_id && e.qty && e.weight) - .map((egg) => ({ - product_warehouse_id: egg.product_warehouse_id!, - qty: Number(egg.qty) || 0, - weight: - typeof egg.weight === 'number' - ? egg.weight - : parseFloat(String(egg.weight)) || 0, - })); - - return { - project_flock_kandang_id: values.project_flock_kandang_id, - record_date: values.record_date, - stocks: values.stocks.map((stock) => ({ - product_warehouse_id: stock.product_warehouse_id, - qty: Number(stock.qty) || 0, - })), - ...(depletions && depletions.length > 0 && { depletions }), - ...(eggs && eggs.length > 0 && { eggs }), - }; - }, - [] - ); - // ===== FORM HANDLERS ===== const createRecordingHandler = useCallback( async ( @@ -380,11 +332,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (!initialValues?.id) return; setIsDeleteLoading(true); - await RecordingApi.delete(initialValues.id); + const response = await RecordingApi.delete(initialValues.id); + deleteModal.closeModal(); - toast.success('Successfully delete Recording!'); setIsDeleteLoading(false); - router.push('/production/recording'); + + if (isResponseSuccess(response)) { + toast.success(response?.message || 'Successfully delete Recording!'); + router.push('/production/recording'); + } else { + toast.error(response?.message || 'Failed to delete Recording'); + } }, [deleteModal, initialValues?.id, router]); // ===== API DATA FETCHING ===== @@ -403,16 +361,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { loadMore: loadMoreProjectFlocks, } = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', { location_id: selectedProjectFlockLocationId, + status: PROJECT_FLOCK_STATUS.AKTIF, }); const projectFlockKandangLookupUrl = useMemo(() => { - if (!selectedProjectFlock || !selectedKandang) return null; + if (!selectedProjectFlock || !selectedKandang || !selectedRecordDate) + return null; const params = new URLSearchParams({ project_flock_id: selectedProjectFlock.value.toString(), kandang_id: selectedKandang.value.toString(), + record_date: selectedRecordDate, }); return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; - }, [selectedProjectFlock, selectedKandang]); + }, [selectedProjectFlock, selectedKandang, selectedRecordDate]); const { data: projectFlockKandangLookupData } = useSWR( projectFlockKandangLookupUrl, @@ -444,13 +405,24 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { () => ProductionStandardApi.getSingle(productionStandardId!) ); + const { data: productionStandardForAdd } = useSWR( + type === 'add' && productionStandardId + ? `production-standard-add-${productionStandardId}` + : null, + () => ProductionStandardApi.getSingle(productionStandardId!) + ); + useEffect(() => { if (productionStandard?.status === 'success') { setProductionStandards( productionStandard.data as ProductionStandard | null ); + } else if (productionStandardForAdd?.status === 'success') { + setProductionStandards( + productionStandardForAdd.data as ProductionStandard | null + ); } - }, [productionStandard]); + }, [productionStandard, productionStandardForAdd]); const projectFlockKandangDetailUrl = useMemo(() => { if ( @@ -476,6 +448,159 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ? projectFlockKandangDetailData.data : undefined; + const selectedProjectFlockKandangId = useMemo(() => { + if (type === 'add') { + return projectFlockKandangLookup?.project_flock_kandang_id ?? null; + } + + return ( + projectFlockKandangDetail?.id ?? + initialValues?.project_flock?.project_flock_kandang_id ?? + null + ); + }, [ + type, + projectFlockKandangLookup, + projectFlockKandangDetail, + initialValues, + ]); + + // ===== TRANSITION RESTRICTION LOGIC ===== + const isTransitionPeriod = useMemo(() => { + return ( + initialValues?.is_transition ?? + projectFlockKandangLookup?.is_transition ?? + false + ); + }, [initialValues, projectFlockKandangLookup]); + + const recordingRestriction = useMemo(() => { + let isLaying: boolean; + if (initialValues?.is_laying !== undefined) { + isLaying = initialValues.is_laying; + } else if (projectFlockKandangLookup?.is_laying !== undefined) { + isLaying = projectFlockKandangLookup.is_laying; + } else { + isLaying = + projectFlockKandangDetail?.project_flock?.category === 'LAYING' || + false; + } + + const isTransition = + initialValues?.is_transition ?? + projectFlockKandangLookup?.is_transition ?? + false; + + const currentIsLaying = + type === 'edit' + ? projectFlockKandangDetail?.project_flock?.category === 'LAYING' + : projectFlockKandangLookup?.project_flock?.category === 'LAYING'; + + return getRecordingRestriction(isLaying, isTransition, currentIsLaying); + }, [ + initialValues, + projectFlockKandangLookup, + projectFlockKandangDetail, + type, + ]); + + // ===== PAYLOAD CREATION HELPERS ===== + const createGrowingPayload = useCallback( + (values: RecordingGrowingFormValues) => { + const depletions = recordingRestriction.canEditDepletion + ? values.depletions + ?.filter((d) => d.product_warehouse_id && d.qty) + .map((depletion) => ({ + product_warehouse_id: depletion.product_warehouse_id!, + qty: Number(depletion.qty) || 0, + })) + : []; + + const stocks = recordingRestriction.canEditStock + ? (values.stocks ?? []) + .filter((s) => s.product_warehouse_id && s.qty) + .map((stock) => ({ + product_warehouse_id: stock.product_warehouse_id, + qty: Number(stock.qty) || 0, + })) + : []; + + return { + project_flock_kandang_id: values.project_flock_kandang_id, + record_date: values.record_date, + ...(stocks.length > 0 && { stocks }), + ...(depletions.length > 0 && { depletions }), + }; + }, + [recordingRestriction.canEditStock, recordingRestriction.canEditDepletion] + ); + + const createLayingPayload = useCallback( + (values: RecordingLayingFormValues) => { + const depletions = values.depletions + ?.filter((d) => d.product_warehouse_id && d.qty) + .map((depletion) => ({ + product_warehouse_id: depletion.product_warehouse_id!, + qty: Number(depletion.qty) || 0, + })); + + const eggs = values.eggs + ?.filter((e) => e.product_warehouse_id && e.qty && e.weight) + .map((egg) => ({ + product_warehouse_id: egg.product_warehouse_id!, + qty: Number(egg.qty) || 0, + weight: + typeof egg.weight === 'number' + ? egg.weight + : parseFloat(String(egg.weight)) || 0, + })); + + const stocks = recordingRestriction.canEditStock + ? values.stocks + .filter((s) => s.product_warehouse_id && s.qty) + .map((stock) => ({ + product_warehouse_id: stock.product_warehouse_id, + qty: Number(stock.qty) || 0, + })) + : []; + + return { + project_flock_kandang_id: values.project_flock_kandang_id, + record_date: values.record_date, + ...(stocks.length > 0 && { stocks }), + ...(depletions && depletions.length > 0 && { depletions }), + ...(eggs && eggs.length > 0 && { eggs }), + }; + }, + [recordingRestriction.canEditStock] + ); + + const isRecordingEditable = useCallback((recording?: Recording) => { + if (!recording) return true; + + const isGrowingCategory = + recording.project_flock?.project_flock_category === 'GROWING'; + const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying; + if (isGrowingLockedByLaying) { + return false; + } + + const currentIsLaying = + recording.project_flock?.project_flock_category === 'LAYING'; + + const restriction = getRecordingRestriction( + recording.is_laying, + recording.is_transition, + currentIsLaying + ); + + if (restriction.isLocked) { + return false; + } + + return true; + }, []); + const { options: stockProductOptions, rawData: stockProducts, @@ -581,15 +706,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return approvedProjectFlockKandangsData.data; }, [approvedProjectFlockKandangsData]); - const isLayingCategory = - initialValues?.project_flock?.project_flock_category === 'LAYING' || - projectFlockKandangLookup?.project_flock?.category === 'LAYING' || - projectFlockKandangDetail?.project_flock?.category === 'LAYING'; + const isLayingCategory = useMemo(() => { + // Priority 1: initialValues category (for edit/detail mode) + if (initialValues?.project_flock?.project_flock_category !== undefined) { + return initialValues.project_flock.project_flock_category === 'LAYING'; + } - const isGrowingCategory = - initialValues?.project_flock?.project_flock_category === 'GROWING' || - projectFlockKandangLookup?.project_flock?.category === 'GROWING' || - projectFlockKandangDetail?.project_flock?.category === 'GROWING'; + // Priority 2: projectFlockKandangLookup category (for add mode) + if (projectFlockKandangLookup?.project_flock?.category !== undefined) { + return projectFlockKandangLookup.project_flock.category === 'LAYING'; + } + + // Priority 3: projectFlockKandangDetail (fallback for edit/detail mode) + return ( + projectFlockKandangDetail?.project_flock?.category === 'LAYING' || false + ); + }, [ + initialValues?.project_flock?.project_flock_category, + projectFlockKandangLookup, + projectFlockKandangDetail, + ]); + + const isGrowingCategory = !isLayingCategory; const recordingApprovalLines = useMemo(() => { if (isLayingCategory) { @@ -637,8 +775,36 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return options; }, [locationOptions, projectFlockKandangDetail, type]); + const isProjectFlockActive = useCallback((projectFlock: ProjectFlock) => { + const approvalStepName = projectFlock.approval?.step_name + ?.trim() + .toLowerCase(); + if (approvalStepName) { + return approvalStepName === PROJECT_FLOCK_STATUS.AKTIF.toLowerCase(); + } + + return ( + projectFlock.status?.trim().toLowerCase() === + PROJECT_FLOCK_STATUS.AKTIF.toLowerCase() + ); + }, []); + + const activeProjectFlockIDs = useMemo(() => { + if (!isResponseSuccess(projectFlocksRawData)) return new Set(); + + const data = projectFlocksRawData.data as ProjectFlock[]; + return new Set( + data + .filter((projectFlock) => isProjectFlockActive(projectFlock)) + .map((projectFlock) => projectFlock.id) + ); + }, [projectFlocksRawData, isProjectFlockActive]); + const enhancedProjectFlockOptions = useMemo(() => { - const options = [...projectFlockOptions]; + const options = projectFlockOptions.filter((option) => { + if (type !== 'add') return true; + return activeProjectFlockIDs.has(Number(option.value)); + }); if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) { const currentProjectFlock = projectFlockKandangDetail.project_flock; @@ -654,7 +820,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } return options; - }, [projectFlockOptions, projectFlockKandangDetail, type]); + }, [ + projectFlockOptions, + projectFlockKandangDetail, + type, + activeProjectFlockIDs, + ]); const kandangOptions = useMemo(() => { let options: OptionType[] = []; @@ -762,8 +933,41 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { projectFlockKandangDetail, ]); + const isProductWarehouseBelongsToSelectedProjectFlockKandang = useCallback( + (productWarehouse: ProductWarehouse) => { + if (!selectedProjectFlockKandangId) return false; + + return ( + productWarehouse.project_flock_kandang?.id === + selectedProjectFlockKandangId + ); + }, + [selectedProjectFlockKandangId] + ); + + const scopedStockProductIds = useMemo(() => { + if (!isResponseSuccess(stockProducts) || !selectedProjectFlockKandangId) { + return new Set(); + } + + const data = stockProducts.data as unknown as ProductWarehouse[]; + return new Set( + data + .filter(isProductWarehouseBelongsToSelectedProjectFlockKandang) + .map((product) => product.id) + ); + }, [ + stockProducts, + selectedProjectFlockKandangId, + isProductWarehouseBelongsToSelectedProjectFlockKandang, + ]); + const unifiedStockProducts = useMemo(() => { - const options = [...stockProductOptions]; + const options = selectedProjectFlockKandangId + ? stockProductOptions.filter((option) => + scopedStockProductIds.has(Number(option.value)) + ) + : []; if ( initialValues && @@ -787,19 +991,30 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } return options; - }, [stockProductOptions, initialValues, type]); + }, [ + stockProductOptions, + initialValues, + type, + selectedProjectFlockKandangId, + scopedStockProductIds, + ]); const depletionProducts = useMemo(() => { const options: OptionType[] = []; - if (isResponseSuccess(depletionProductsData) && selectedKandang) { + if ( + isResponseSuccess(depletionProductsData) && + selectedProjectFlockKandangId + ) { const data = depletionProductsData.data as unknown as ProductWarehouse[]; - data.forEach((product) => { - options.push({ - value: product.id, - label: product.product.name, + data + .filter(isProductWarehouseBelongsToSelectedProjectFlockKandang) + .forEach((product) => { + options.push({ + value: product.id, + label: product.product.name, + }); }); - }); } if (initialValues && initialValues.depletions && type !== 'add') { @@ -822,19 +1037,27 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } return options; - }, [depletionProductsData, initialValues, type, selectedKandang]); + }, [ + depletionProductsData, + initialValues, + type, + selectedProjectFlockKandangId, + isProductWarehouseBelongsToSelectedProjectFlockKandang, + ]); const eggProducts = useMemo(() => { const options: OptionType[] = []; - if (isResponseSuccess(eggProductsData) && selectedKandang) { + if (isResponseSuccess(eggProductsData) && selectedProjectFlockKandangId) { const data = eggProductsData.data as unknown as ProductWarehouse[]; - data.forEach((product) => { - options.push({ - value: product.id, - label: product.product.name, + data + .filter(isProductWarehouseBelongsToSelectedProjectFlockKandang) + .forEach((product) => { + options.push({ + value: product.id, + label: product.product.name, + }); }); - }); } if (initialValues && initialValues.eggs && type !== 'add') { @@ -854,7 +1077,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } return options; - }, [eggProductsData, initialValues, type, selectedKandang]); + }, [ + eggProductsData, + initialValues, + type, + selectedProjectFlockKandangId, + isProductWarehouseBelongsToSelectedProjectFlockKandang, + ]); // ===== FORMIK SETUP ===== const formikInitialValues = useMemo(() => { @@ -924,6 +1153,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; } + if (!recordingRestriction.canEditStock) { + baseValues.stocks = []; + } + + if (!recordingRestriction.canEditDepletion) { + baseValues.depletions = []; + } + return baseValues; }, [ initialValues, @@ -934,6 +1171,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { selectedLocation, selectedProjectFlock, selectedKandang, + recordingRestriction.canEditStock, + recordingRestriction.canEditDepletion, ]); const formik = useFormik< @@ -954,7 +1193,36 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ? UpdateRecordingGrowingFormSchema : RecordingGrowingFormSchema; } - return schema.clone().concat( + + if (!recordingRestriction.canEditStock) { + schema = schema.shape({ + stocks: Yup.array() + .of( + Yup.object({ + product_warehouse_id: Yup.number().optional(), + qty: Yup.number().optional(), + }) + ) + .optional() + .default([]) as unknown as Yup.Schema, + }); + } + + if (!recordingRestriction.canEditDepletion) { + schema = schema.shape({ + depletions: Yup.array() + .of( + Yup.object({ + product_warehouse_id: Yup.number().optional(), + qty: Yup.number().optional(), + }) + ) + .optional() + .default([]) as unknown as Yup.Schema, + }); + } + + return schema.concat( Yup.object().shape({ project_flock_kandang_id: Yup.number().test( 'not-already-recorded', @@ -1299,6 +1567,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setSelectedLocation(location); setSelectedProjectFlock(null); setSelectedKandang(null); + setProductionStandards(null); + setNextDayRecording(null); if (duplicateErrorShown) { toast.dismiss(); setDuplicateErrorShown(false); @@ -1323,6 +1593,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setSelectedProjectFlock(projectFlock); setSelectedKandang(null); + setProductionStandards(null); + setNextDayRecording(null); if (duplicateErrorShown) { toast.dismiss(); setDuplicateErrorShown(false); @@ -1343,6 +1615,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('kandang_id', kandangId); setSelectedKandang(kandang); + setProductionStandards(null); + setNextDayRecording(null); if (duplicateErrorShown) { toast.dismiss(); setDuplicateErrorShown(false); @@ -1497,7 +1771,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, [ projectFlockKandangDetail, type, - enhancedProjectFlockOptions, formik.values.project_flock_kandang_id, setFieldValue, ]); @@ -1879,10 +2152,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {

{type === 'add' ? nextDayRecording - ? `Hari ke-${nextDayRecording.next_day} (Minggu ke-${Math.ceil(nextDayRecording.next_day / 7)})` + ? `Hari ke-${nextDayRecording.next_day} (Minggu ke-${calculateWeek(nextDayRecording.next_day)})` : '-' : initialValues?.day - ? `Hari ke-${initialValues.day} (Minggu ke-${Math.ceil(initialValues.day / 7)})` + ? `Hari ke-${initialValues.day} (Minggu ke-${calculateWeek(initialValues.day)})` : '-'}

@@ -1956,18 +2229,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
Kategori

- - {initialValues.project_flock?.project_flock_category} - + {(() => { + const category = + initialValues.project_flock?.project_flock_category || + 'GROWING'; + const color = + category === 'LAYING' ? 'info' : 'warning'; + return ( + + {category} + + ); + })()}

@@ -2103,9 +2376,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {type === 'detail' && initialValues && (
{/* FCR Section */} @@ -2196,8 +2467,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {/* Egg Production Section - Only for LAYING category */} {type === 'detail' && initialValues && - initialValues.project_flock?.project_flock_category === - 'LAYING' && ( + initialValues.is_laying && (
@@ -2292,239 +2562,293 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)} - {/* Stocks Table */} - -
- - - - {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - - )} - - - {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - - )} - - - - {formik.values.stocks?.map((stock, idx) => ( - + {/* Stocks Table - Only show if can edit stock or has data */} + {(recordingRestriction.canEditStock || + (type === 'detail' && formik.values.stocks?.length > 0)) && ( + +
+
- 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 - - * - - - Jumlah Pakai - - * - - Action
+ + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - )} - - + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - + )} - ))} - -
+ 0 + } onChange={( e: React.ChangeEvent ) => { if (e.target.checked) { - setSelectedStocks([...selectedStocks, idx]); - } else { setSelectedStocks( - selectedStocks.filter((i) => i !== idx) + formik.values.stocks?.map((_, idx) => idx) ?? + [] ); + } else { + setSelectedStocks([]); } }} + disabled={!recordingRestriction.canEditStock} 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={getAvailableStockProductOptions(idx)} - placeholder={ - !formik.values.project_flock_kandang_id - ? 'Pilih kandang terlebih dahulu' - : 'Pilih Produk' - } - isLoading={isLoadingStockProducts} - onMenuScrollToBottom={loadMoreStockProducts} - isError={ - isRepeaterInputError( - 'stocks', - 'product_warehouse_id', - idx - ).isError - } - errorMessage={ - isRepeaterInputError( - 'stocks', - 'product_warehouse_id', - idx - ).errorMessage - } - className={{ - wrapper: 'w-full min-w-48', - }} - isSearchable - isDisabled={ - type === 'detail' || - !formik.values.project_flock_kandang_id - } - isClearable={type !== 'detail'} - inputPrefix={ - stock.product_warehouse_id - ? getProductFlagBadgeAdornment( - stock.product_warehouse_id - ) - : undefined - } - /> - -
- - {getStockUsageAdornment(idx)} -
-
+ Persediaan + + * + + + Jumlah Pakai + + * + + -
- -
-
Action
-
- {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( -
- {selectedStocks.length > 0 && ( - - )} - + + + {formik.values.stocks?.map((stock, idx) => ( + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + + ) => { + if (e.target.checked) { + setSelectedStocks([...selectedStocks, idx]); + } else { + setSelectedStocks( + selectedStocks.filter((i) => i !== idx) + ); + } + }} + disabled={!recordingRestriction.canEditStock} + 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={getAvailableStockProductOptions(idx)} + placeholder={ + !formik.values.project_flock_kandang_id + ? 'Pilih kandang terlebih dahulu' + : 'Pilih Produk' + } + isLoading={isLoadingStockProducts} + onMenuScrollToBottom={loadMoreStockProducts} + isError={ + isRepeaterInputError( + 'stocks', + 'product_warehouse_id', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'stocks', + 'product_warehouse_id', + idx + ).errorMessage + } + className={{ + wrapper: 'w-full min-w-48', + }} + isSearchable + isDisabled={ + type === 'detail' || + !formik.values.project_flock_kandang_id || + !recordingRestriction.canEditStock + } + isClearable={type !== 'detail'} + inputPrefix={ + stock.product_warehouse_id + ? getProductFlagBadgeAdornment( + stock.product_warehouse_id + ) + : undefined + } + /> + + +
+ + {getStockUsageAdornment(idx)} +
+ + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + +
+ +
+ + )} + + ))} + +
- )} -
+ {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( +
+ {selectedStocks.length > 0 && + recordingRestriction.canEditStock && ( + + )} + + + +
+ )} + + )} + + {/* Transition Warning Banner -- MOVED UP -- */} + {isTransitionPeriod && ( +
+ + + {isLayingCategory + ? 'Masa Transisi Laying: Hanya Deplesi yang dapat diisi. Stock (Pakan/OVK) tidak dapat diinput.' + : 'Masa Transisi Growing: Hanya Stock (Pakan/OVK) yang dapat diisi. Deplesi tidak dapat diinput.'} + +
+ )} + + {/* Locked Recording Warning */} + {recordingRestriction.isLocked && ( +
+ + {recordingRestriction.lockReason} +
+ )} {/* Depletions Table */} {((type as 'add' | 'edit' | 'detail') !== 'detail' || @@ -2532,7 +2856,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { @@ -2562,6 +2890,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setSelectedDepletions([]); } }} + disabled={!recordingRestriction.canEditDepletion} classNames={{ wrapper: 'flex justify-center', checkbox: 'checkbox checkbox-sm', @@ -2598,6 +2927,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); } }} + disabled={!recordingRestriction.canEditDepletion} classNames={{ wrapper: 'flex justify-center', checkbox: 'checkbox checkbox-sm', @@ -2623,7 +2953,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); }} options={getAvailableDepletionProductOptions(idx)} - placeholder='Pilih Kondisi' + placeholder={ + !formik.values.project_flock_kandang_id + ? 'Pilih kandang terlebih dahulu' + : 'Pilih Kondisi' + } isLoading={isLoadingDepletionProducts} onMenuScrollToBottom={loadMoreDepletionProducts} isError={ @@ -2640,7 +2974,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { idx ).errorMessage } - isDisabled={type === 'detail'} + isDisabled={ + type === 'detail' || + !formik.values.project_flock_kandang_id || + !recordingRestriction.canEditDepletion + } className={{ wrapper: 'w-full min-w-48', }} @@ -2679,7 +3017,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ) : null } - disabled={type === 'detail'} + disabled={ + type === 'detail' || + !recordingRestriction.canEditDepletion + } /> {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( @@ -2689,6 +3030,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='button' color='error' onClick={() => removeDepletion(idx)} + disabled={ + !recordingRestriction.canEditDepletion + } > {
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
- {selectedDepletions.length > 0 && ( + {selectedDepletions.length > 0 && + recordingRestriction.canEditDepletion && ( + + )} + - )} - +
)} @@ -2847,7 +3205,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); }} options={getAvailableEggProductOptions(idx)} - placeholder='Pilih Kondisi Telur' + placeholder={ + !formik.values.project_flock_kandang_id + ? 'Pilih kandang terlebih dahulu' + : 'Pilih Kondisi Telur' + } isLoading={isLoadingEggProducts} onMenuScrollToBottom={loadMoreEggProducts} isError={ @@ -2864,7 +3226,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { idx ).errorMessage } - isDisabled={type === 'detail'} + isDisabled={ + type === 'detail' || + !formik.values.project_flock_kandang_id + } className={{ wrapper: 'w-full min-w-48', }} @@ -2969,6 +3334,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { color='success' onClick={addEgg} className='w-fit' + disabled={!formik.values.project_flock_kandang_id} > Tambah Telur diff --git a/src/components/pages/production/recording/recording-utils.ts b/src/components/pages/production/recording/recording-utils.ts new file mode 100644 index 00000000..fe7d07f8 --- /dev/null +++ b/src/components/pages/production/recording/recording-utils.ts @@ -0,0 +1,73 @@ +export type RecordingRestriction = { + canEditStock: boolean; + canEditDepletion: boolean; + canEditEgg: boolean; + isLocked: boolean; + lockReason?: string; +}; + +export const getRecordingRestriction = ( + isLaying: boolean, + isTransition: boolean, + currentIsLaying?: boolean +): RecordingRestriction => { + if (isTransition && !isLaying) { + const isLayingKandangInTransition = currentIsLaying === true; + + if (isLayingKandangInTransition) { + return { + canEditStock: false, + canEditDepletion: true, + canEditEgg: true, + isLocked: false, + lockReason: undefined, + }; + } else { + return { + canEditStock: true, + canEditDepletion: false, + canEditEgg: false, + isLocked: false, + lockReason: undefined, + }; + } + } + + if (!isLaying && !isTransition && currentIsLaying) { + return { + canEditStock: false, + canEditDepletion: false, + canEditEgg: false, + isLocked: true, + lockReason: + 'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying', + }; + } + + if (!isLaying && !isTransition) { + return { + canEditStock: true, + canEditDepletion: true, + canEditEgg: false, + isLocked: false, + lockReason: undefined, + }; + } + if (isLaying && !isTransition) { + return { + canEditStock: true, + canEditDepletion: true, + canEditEgg: true, + isLocked: false, + lockReason: undefined, + }; + } + + return { + canEditStock: false, + canEditDepletion: false, + canEditEgg: false, + isLocked: true, + lockReason: 'Kondisi transisi tidak valid', + }; +}; diff --git a/src/config/constant.ts b/src/config/constant.ts index 99c5ff9d..99594b65 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -20,6 +20,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ 'lti.daily_checklist.master_data.employee', 'lti.daily_checklist.master_data.activity', 'lti.daily_checklist.master_data.configuration', + 'lti.daily_checklist.master_data.kandang', ], submenu: [ { @@ -66,6 +67,11 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ link: '/daily-checklist/master-data/activity', permission: ['lti.daily_checklist.master_data.activity'], }, + { + text: 'Kandang', + link: '/daily-checklist/master-data/kandang', + permission: ['lti.daily_checklist.master_data.kandang'], + }, { text: 'Konfigurasi', link: '/daily-checklist/master-data/configuration', @@ -549,6 +555,12 @@ export const APPROVAL_WORKFLOWS = { ], }; +export const PROJECT_FLOCK_STATUS = { + PENGAJUAN: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[0].step_name, + AKTIF: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[1].step_name, + SELESAI: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[2].step_name, +} as const; + export const ACCEPTED_FILE_TYPE = { PDF: { 'application/pdf': ['.pdf'], diff --git a/src/config/route-permission.ts b/src/config/route-permission.ts index dc638b29..8c65a611 100644 --- a/src/config/route-permission.ts +++ b/src/config/route-permission.ts @@ -21,6 +21,9 @@ export const ROUTE_PERMISSIONS: Record = { '/daily-checklist/master-data/configuration/': [ 'lti.daily_checklist.master_data.configuration', ], + '/daily-checklist/master-data/kandang/': [ + 'lti.daily_checklist.master_data.kandang', + ], // Production // Production - Project Flock diff --git a/src/figma-make/components/base/multi-select.tsx b/src/figma-make/components/base/multi-select.tsx index 63ebdd36..656073c6 100644 --- a/src/figma-make/components/base/multi-select.tsx +++ b/src/figma-make/components/base/multi-select.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { Check, ChevronsUpDown, X } from 'lucide-react'; +import { Check, ChevronsUpDown, X, Loader2 } from 'lucide-react'; import { cn } from '@/lib/helper'; import { Button } from '@/figma-make/components/base/button'; import { @@ -29,6 +29,8 @@ interface MultiSelectProps { selected: string[]; onChange: (selected: string[]) => void; onSearchChange?: (value: string) => void; + onLoadMore?: () => void; + isLoadingMore?: boolean; placeholder?: string; className?: string; disabled?: boolean; @@ -39,6 +41,8 @@ export function MultiSelect({ selected, onChange, onSearchChange, + onLoadMore, + isLoadingMore, placeholder = 'Select items...', className, disabled, @@ -115,7 +119,18 @@ export function MultiSelect({ onValueChange={onSearchChange} /> No item found. - + { + const target = e.currentTarget; + if ( + target.scrollHeight - target.scrollTop <= + target.clientHeight + 1 + ) { + onLoadMore?.(); + } + }} + > {options.map((option) => ( ))} + {isLoadingMore && ( +
+ +
+ )}
diff --git a/src/figma-make/components/base/select.tsx b/src/figma-make/components/base/select.tsx index 625cf3d7..16725c04 100644 --- a/src/figma-make/components/base/select.tsx +++ b/src/figma-make/components/base/select.tsx @@ -55,7 +55,11 @@ function SelectContent({ children, position = 'popper', ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + onScroll?: React.UIEventHandler; +}) { + const { onScroll, ...restProps } = props; + return ( {children} diff --git a/src/figma-make/components/pages/daily-checklist/DailyChecklistContent.tsx b/src/figma-make/components/pages/daily-checklist/DailyChecklistContent.tsx index 601025ad..1a9b4406 100644 --- a/src/figma-make/components/pages/daily-checklist/DailyChecklistContent.tsx +++ b/src/figma-make/components/pages/daily-checklist/DailyChecklistContent.tsx @@ -2,7 +2,16 @@ import * as React from 'react'; import { useState, useEffect } from 'react'; -import { Plus, X, Save, Send, Info, FilePlus, ListChecks } from 'lucide-react'; +import { + Plus, + X, + Save, + Send, + Info, + FilePlus, + ListChecks, + Loader2, +} from 'lucide-react'; import { Card, CardContent } from '@/figma-make/components/base/card'; import { Button } from '@/figma-make/components/base/button'; import { Label } from '@/figma-make/components/base/label'; @@ -26,7 +35,6 @@ import { import { DatePicker } from '@/figma-make/components/base/date-picker'; import { toast } from 'sonner'; import { useSelect } from '@/components/input/SelectInput'; -import { KandangApi } from '@/services/api/master-data'; import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import useSWR from 'swr'; @@ -43,6 +51,7 @@ import DropFileInput from '@/components/input/DropFileInput'; import Link from 'next/link'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { Icon } from '@iconify/react'; +import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang'; // Static categories const CATEGORIES = [ @@ -86,16 +95,11 @@ export function DailyChecklistContent() { searchParams.get('category') || '' ); - const { options: kandangOptions } = useSelect( - KandangApi.basePath, - 'id', - 'name', - 'search', - { - page: '1', - limit: '100', - } - ); + const { + options: kandangOptions, + isLoadingMore: isLoadingMoreKandang, + loadMore: loadMoreKandang, + } = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name'); const { data: phases } = useSWR< BaseApiResponse, @@ -168,6 +172,16 @@ export function DailyChecklistContent() { const [documents, setDocuments] = useState([]); const [deletedDocumentIds, setDeletedDocumentIds] = useState([]); + const handleKandangScroll = (e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + + if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) { + if (!isLoadingMoreKandang) { + loadMoreKandang(); + } + } + }; + // Sync state to URL query params useEffect(() => { const params = new URLSearchParams(searchParams.toString()); @@ -994,7 +1008,7 @@ export function DailyChecklistContent() { > - + {kandangOptions.map((kandang) => ( ))} + + {isLoadingMoreKandang && ( +
+ +
+ )}
diff --git a/src/figma-make/components/pages/dashboard/Dashboard.tsx b/src/figma-make/components/pages/dashboard/Dashboard.tsx index a924c2b3..f3e4b6d0 100644 --- a/src/figma-make/components/pages/dashboard/Dashboard.tsx +++ b/src/figma-make/components/pages/dashboard/Dashboard.tsx @@ -16,7 +16,7 @@ import { SelectValue, } from '@/figma-make/components/base/select'; import { Badge } from '@/figma-make/components/base/badge'; -import { Users, AlertCircle, Info } from 'lucide-react'; +import { Users, AlertCircle, Info, Loader2 } from 'lucide-react'; import { DateRangePicker } from '@/figma-make/components/base/date-range-picker'; import { BarChart, @@ -36,10 +36,10 @@ import { DailyChecklistSummary } from '@/types/api/daily-checklist/daily-checkli import { AxiosError } from 'axios'; import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist'; -import { KandangApi } from '@/services/api/master-data'; import { useSelect } from '@/components/input/SelectInput'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { formatDate } from '@/lib/helper'; +import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang'; const KANDANG_COLORS = [ '#0069e0', // Blue (primary) @@ -77,16 +77,20 @@ export function Dashboard() { httpClientFetcher ); - const { options: kandangOptions } = useSelect( - KandangApi.basePath, - 'id', - 'name', - 'search', - { - page: '1', - limit: '100', + const { + options: kandangOptions, + loadMore: loadMoreKandang, + isLoadingMore: isLoadingMoreKandang, + } = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name'); + + const handleKandangScroll = (e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) { + if (!isLoadingMoreKandang) { + loadMoreKandang(); + } } - ); + }; const kandangColorMap: { [key: string]: string } = {}; (kandangOptions || []).forEach((k, index) => { @@ -164,7 +168,7 @@ export function Dashboard() { > - + Semua Kandang {kandangOptions.map((kandang) => ( ))} + {isLoadingMoreKandang && ( +
+ +
+ )}
diff --git a/src/figma-make/components/pages/list-daily-checklist/ListDailyChecklistContent.tsx b/src/figma-make/components/pages/list-daily-checklist/ListDailyChecklistContent.tsx index 6509a91d..e6127cf0 100644 --- a/src/figma-make/components/pages/list-daily-checklist/ListDailyChecklistContent.tsx +++ b/src/figma-make/components/pages/list-daily-checklist/ListDailyChecklistContent.tsx @@ -1,7 +1,15 @@ 'use client'; import { useState } from 'react'; -import { Eye, CheckCircle, XCircle, Search, Trash2, Edit } from 'lucide-react'; +import { + Eye, + CheckCircle, + XCircle, + Search, + Trash2, + Edit, + Loader2, +} from 'lucide-react'; import { Card, CardContent } from '@/figma-make/components/base/card'; import { Button } from '@/figma-make/components/base/button'; import { Badge } from '@/figma-make/components/base/badge'; @@ -34,9 +42,9 @@ import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist'; import { cn } from '@/lib/helper'; import { ColumnDef } from '@tanstack/react-table'; import { useSelect } from '@/components/input/SelectInput'; -import { KandangApi } from '@/services/api/master-data'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import RequirePermission from '@/components/helper/RequirePermission'; +import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang'; const STATUS_OPTIONS = [ { value: 'ALL', label: 'Semua Status' }, @@ -93,21 +101,25 @@ export function ListDailyChecklistContent() { } ); - const { options: kandangOptions } = useSelect( - KandangApi.basePath, - 'id', - 'name', - 'search', - { - page: '1', - limit: '100', - } - ); + const { + options: kandangOptions, + isLoadingMore: isLoadingMoreKandang, + loadMore: loadMoreKandang, + } = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name'); const checklistList = isResponseSuccess(checklistListRes) ? checklistListRes.data || [] : []; + const handleKandangScroll = (e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) { + if (!isLoadingMoreKandang) { + loadMoreKandang(); + } + } + }; + // Modals const [showApproveModal, setShowApproveModal] = useState(false); const [showRejectModal, setShowRejectModal] = useState(false); @@ -490,7 +502,7 @@ export function ListDailyChecklistContent() { > - + Semua Kandang {kandangOptions.map((kandang) => ( ))} + {isLoadingMoreKandang && ( +
+ +
+ )}
diff --git a/src/figma-make/components/pages/master-data/employee/MasterEmployeeContent.tsx b/src/figma-make/components/pages/master-data/employee/MasterEmployeeContent.tsx index 099aa32a..9944ed21 100644 --- a/src/figma-make/components/pages/master-data/employee/MasterEmployeeContent.tsx +++ b/src/figma-make/components/pages/master-data/employee/MasterEmployeeContent.tsx @@ -1,7 +1,14 @@ 'use client'; import { useState } from 'react'; -import { Plus, MoreVertical, Pencil, Trash2, Search } from 'lucide-react'; +import { + Plus, + MoreVertical, + Pencil, + Trash2, + Search, + Loader2, +} from 'lucide-react'; import { Card, CardContent } from '@/figma-make/components/base/card'; import { Button } from '@/figma-make/components/base/button'; import { Label } from '@/figma-make/components/base/label'; @@ -49,8 +56,8 @@ import { cn } from '@/lib/helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ColumnDef } from '@tanstack/react-table'; import { useSelect } from '@/components/input/SelectInput'; -import { KandangApi } from '@/services/api/master-data'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang'; export function MasterEmployeeContent() { const { @@ -85,16 +92,20 @@ export function MasterEmployeeContent() { keepPreviousData: true, } ); - const { options: kandangOptions } = useSelect( - KandangApi.basePath, - 'id', - 'name', - 'search', - { - page: '1', - limit: '100', + const { + options: kandangOptions, + loadMore: loadMoreKandang, + isLoadingMore: isLoadingMoreKandang, + } = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name'); + + const handleKandangScroll = (e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) { + if (!isLoadingMoreKandang) { + loadMoreKandang(); + } } - ); + }; const [showModal, setShowModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -351,7 +362,7 @@ export function MasterEmployeeContent() { - + Semua Kandang {kandangOptions.map((kandang) => ( ))} + {isLoadingMoreKandang && ( +
+ +
+ )}
@@ -471,6 +487,12 @@ export function MasterEmployeeContent() { kandang_ids: selected.map((id) => Number(id)), }) } + onLoadMore={() => { + if (!isLoadingMoreKandang) { + loadMoreKandang(); + } + }} + isLoadingMore={isLoadingMoreKandang} placeholder='Pilih kandang' className='mt-1.5' /> diff --git a/src/figma-make/components/pages/master-data/kandang/MasterKandangContent.tsx b/src/figma-make/components/pages/master-data/kandang/MasterKandangContent.tsx new file mode 100644 index 00000000..3d3ba1c2 --- /dev/null +++ b/src/figma-make/components/pages/master-data/kandang/MasterKandangContent.tsx @@ -0,0 +1,585 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, MoreVertical, Pencil, Trash2, Search } from 'lucide-react'; +import { Card, CardContent } from '@/figma-make/components/base/card'; +import { Button } from '@/figma-make/components/base/button'; +import { Label } from '@/figma-make/components/base/label'; +import { Input } from '@/figma-make/components/base/input'; +import { Badge } from '@/figma-make/components/base/badge'; +import { MultiSelect } from '@/figma-make/components/base/multi-select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/figma-make/components/base/select'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/figma-make/components/base/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/figma-make/components/base/alert-dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/figma-make/components/base/dropdown-menu'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang'; +import Table from '@/components/Table'; +import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { cn } from '@/lib/helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ColumnDef } from '@tanstack/react-table'; +import { useSelect } from '@/components/input/SelectInput'; +import { KandangApi, LocationApi } from '@/services/api/master-data'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import { BaseDailyChecklistKandang } from '@/types/api/daily-checklist/kandang'; +import { UserApi } from '@/services/api/user'; + +export function MasterKandangContent() { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + location_id: '', + status: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + search: 'search', + location_id: 'location_id', + }, + }); + + const { + data: dailyChecklistKandangs, + isLoading: isLoadingDailyChecklistKandangs, + mutate: refreshDailyChecklistKandangs, + } = useSWR( + `${DailyChecklistKandangApi.basePath}${getTableFilterQueryString()}`, + DailyChecklistKandangApi.getAllFetcher, + { + keepPreviousData: true, + } + ); + const { options: locationOptions } = useSelect( + LocationApi.basePath, + 'id', + 'name', + 'search', + { + page: '1', + limit: '100', + } + ); + + const { options: picOptions } = useSelect( + UserApi.basePath, + 'id', + 'name', + 'search', + { + page: '1', + limit: '100', + } + ); + + const { + options: kandangOptions, + isLoadingMore: isLoadingKandangOptionsMore, + loadMore: loadMoreKandang, + } = useSelect(KandangApi.basePath, 'id', 'name'); + + const [showModal, setShowModal] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [kandangToDelete, setKandangToDelete] = useState(null); + const [loading, setLoading] = useState(false); + const [modalMode, setModalMode] = useState<'create' | 'edit'>('create'); + const [kandangForm, setKandangForm] = useState({ + id: 0, + name: '', + location_id: 0, + pic_id: 0, + // recording_kandangs: [] as number[], + }); + + const dailyChecklistKandangColumns: ColumnDef[] = [ + { + id: 'name', + header: 'Nama', + accessorKey: 'name', + enableSorting: false, + }, + { + id: 'location', + header: 'Lokasi', + accessorKey: 'location', + enableSorting: false, + cell: ({ row }) => row.original.location.name ?? '-', + }, + { + id: 'pic', + header: 'PIC', + accessorKey: 'pic', + enableSorting: false, + cell: ({ row }) => row.original.pic.name ?? '-', + }, + { + id: 'recording_kandangs', + header: 'Kandang Recording', + accessorKey: 'recording_kandangs', + enableSorting: false, + cell: ({ row }) => + row.original.recording_kandangs?.length > 0 + ? row.original.recording_kandangs.map((item) => item.name).join(', ') + : '-', + }, + { + id: 'action', + header: 'Aksi', + accessorKey: 'action', + enableSorting: false, + cell: ({ row }) => ( + + + + + + handleEdit(row.original)}> + + Edit + + handleDeleteClick(row.original.id)} + className='text-red-600' + > + + Hapus + + + + ), + }, + ]; + + const handleAdd = () => { + setModalMode('create'); + setKandangForm({ + id: 0, + name: '', + location_id: 0, + pic_id: 0, + // recording_kandangs: [] + }); + setShowModal(true); + }; + + const handleEdit = (dailyChecklistKandang: DailyChecklistKandang) => { + setModalMode('edit'); + setKandangForm({ + id: dailyChecklistKandang.id, + name: dailyChecklistKandang.name, + location_id: dailyChecklistKandang.location.id, + pic_id: dailyChecklistKandang.pic.id, + // recording_kandangs: + // dailyChecklistKandang.recording_kandangs.map((item) => item.id) ?? [], + }); + setShowModal(true); + }; + + const handleSave = async () => { + if (!kandangForm.name.trim()) { + toast.error('Nama harus diisi'); + return; + } + + if (!kandangForm.location_id) { + toast.error('Lokasi wajib diisi'); + return; + } + + // if (!kandangForm.recording_kandangs.length) { + // toast.error('Kandang recording wajib diisi'); + // return; + // } + + setLoading(true); + + try { + if (modalMode === 'create') { + const createDailyChecklistKandangResponse = + await DailyChecklistKandangApi.create({ + name: kandangForm.name.trim(), + location_id: kandangForm.location_id, + pic_id: kandangForm.pic_id, + // recording_kandang_ids: kandangForm.recording_kandangs, + }); + + if (isResponseError(createDailyChecklistKandangResponse)) { + console.error( + 'Error creating kandang:', + createDailyChecklistKandangResponse.message + ); + toast.error('Gagal menambahkan kandang'); + return; + } + + refreshDailyChecklistKandangs(); + toast.success('Kandang berhasil ditambahkan'); + } else { + const updateDailyChecklistKandangResponse = + await DailyChecklistKandangApi.update(kandangForm.id, { + name: kandangForm.name.trim(), + location_id: kandangForm.location_id, + pic_id: kandangForm.pic_id, + // recording_kandang_ids: kandangForm.recording_kandangs, + }); + + if (isResponseError(updateDailyChecklistKandangResponse)) { + console.error( + 'Error updating kandang:', + updateDailyChecklistKandangResponse.message + ); + toast.error('Gagal menambahkan Kandang'); + return; + } + + refreshDailyChecklistKandangs(); + toast.success('Kandang berhasil diubah'); + } + + setShowModal(false); + setKandangForm({ + id: 0, + name: '', + location_id: 0, + pic_id: 0, + // recording_kandangs: [], + }); + } catch (error) { + console.error('Error saving kandang:', error); + toast.error('Terjadi kesalahan saat menyimpan kandang'); + } finally { + setLoading(false); + } + }; + + const handleDeleteClick = (kandangId: number) => { + setKandangToDelete(kandangId); + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = async () => { + if (!kandangToDelete) return; + + setLoading(true); + + try { + const deleteKandangResponse = + await DailyChecklistKandangApi.delete(kandangToDelete); + + if (isResponseError(deleteKandangResponse)) { + console.error('Error deleting kandang:', deleteKandangResponse.message); + toast.error('Gagal menghapus kandang'); + return; + } + + refreshDailyChecklistKandangs(); + toast.success('Kandang berhasil dihapus'); + setShowDeleteConfirm(false); + setKandangToDelete(null); + } catch (error) { + console.error('Error deleting kandang:', error); + toast.error('Terjadi kesalahan saat menghapus kandang'); + } finally { + setLoading(false); + } + }; + + if (isLoadingDailyChecklistKandangs && !dailyChecklistKandangs) { + return ( +
+
+
+

+ Master Kandang +

+

+ Master Data • Kandang +

+
+ + + Memuat data... + + +
+
+ ); + } + + return ( +
+
+ {/* Page Title */} +
+

+ Master Kandang +

+

+ Master Data • Kandang +

+
+ + {/* Main Card */} + + + {/* Single Toolbar Row */} +
+ {/* LEFT: Search + Filters */} +
+
+ + + updateFilter('search', e.target.value)} + className={{ + wrapper: 'w-full sm:w-[280px] border-gray-200', + inputWrapper: 'px-3 py-2 h-fit rounded-md', + input: 'text-sm', + }} + startAdornment={ + + } + /> +
+ + +
+ + {/* RIGHT: Export + Add */} +
+ +
+
+ + {/* Table */} + + data={ + isResponseSuccess(dailyChecklistKandangs) + ? dailyChecklistKandangs?.data + : [] + } + columns={dailyChecklistKandangColumns} + pageSize={tableFilterState.pageSize} + onPageSizeChange={setPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(dailyChecklistKandangs) + ? dailyChecklistKandangs?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(dailyChecklistKandangs) + ? dailyChecklistKandangs?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoadingDailyChecklistKandangs} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(dailyChecklistKandangs) && + dailyChecklistKandangs?.data?.length === 0, + }), + tableWrapperClassName: + 'overflow-x-auto border border-solid border-base-content/10 rounded-none', + headerRowClassName: 'bg-gray-50/50', + headerColumnClassName: + 'text-left py-3.5 px-6 text-sm font-semibold text-gray-700', + paginationClassName: 'px-4', + }} + /> +
+
+
+ + {/* Add/Edit Modal */} + + + + + {modalMode === 'create' ? 'Tambah Kandang' : 'Edit Kandang'} + + + {modalMode === 'create' + ? 'Masukkan detail Kandang baru' + : 'Ubah detail Kandang'} + + +
+
+ + + setKandangForm({ ...kandangForm, name: e.target.value }) + } + placeholder='Masukkan nama Kandang' + className='mt-1.5' + disabled={loading} + /> +
+
+ + +
+ +
+ + +
+
+ + + + +
+
+ + {/* Delete Confirmation */} + + + + Hapus Kandang? + + Data Kandang akan dihapus secara permanen. + + + + Batal + + {loading ? 'Menghapus...' : 'Hapus'} + + + + +
+ ); +} diff --git a/src/figma-make/components/pages/reports/DailyChecklistReportsContent.tsx b/src/figma-make/components/pages/reports/DailyChecklistReportsContent.tsx index 9c040e33..1f162c5c 100644 --- a/src/figma-make/components/pages/reports/DailyChecklistReportsContent.tsx +++ b/src/figma-make/components/pages/reports/DailyChecklistReportsContent.tsx @@ -11,7 +11,7 @@ import { SelectValue, } from '@/figma-make/components/base/select'; import { useSelect } from '@/components/input/SelectInput'; -import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data'; +import { AreaApi, LocationApi } from '@/services/api/master-data'; import useSWR from 'swr'; import { BaseApiResponse } from '@/types/api/api-general'; import { DailyChecklistReport } from '@/types/api/daily-checklist/daily-checklist'; @@ -26,7 +26,8 @@ import { ColumnDef } from '@tanstack/react-table'; import { PhaseApi } from '@/services/api/daily-checklist/phase'; import { EmployeeApi } from '@/services/api/daily-checklist/employee'; import { Button } from '@/figma-make/components/base/button'; -import { Download } from 'lucide-react'; +import { Download, Loader2 } from 'lucide-react'; +import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang'; const MONTH_OPTIONS = [ { value: '1', label: 'Januari' }, @@ -129,18 +130,23 @@ export function DailyChecklistReportsContent() { } ); - const { options: kandangOptions } = useSelect( - KandangApi.basePath, - 'id', - 'name', - 'search', - { - page: '1', - limit: '100', - area_id: tableFilterState.area_id, - location_id: tableFilterState.location_id, + const { + options: kandangOptions, + loadMore: loadMoreKandang, + isLoadingMore: isLoadingMoreKandang, + } = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', { + area_id: tableFilterState.area_id, + location_id: tableFilterState.location_id, + }); + + const handleKandangScroll = (e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) { + if (!isLoadingMoreKandang) { + loadMoreKandang(); + } } - ); + }; const { options: phaseOptions } = useSelect( PhaseApi.basePath, @@ -435,7 +441,7 @@ export function DailyChecklistReportsContent() { > - + Semua Kandang {kandangOptions.map((kandang) => ( ))} + {isLoadingMoreKandang && ( +
+ +
+ )}
diff --git a/src/services/api/daily-checklist/kandang.ts b/src/services/api/daily-checklist/kandang.ts new file mode 100644 index 00000000..e92ccd50 --- /dev/null +++ b/src/services/api/daily-checklist/kandang.ts @@ -0,0 +1,20 @@ +import { BaseApiService } from '@/services/api/base'; +import { + DailyChecklistKandang, + CreateDailyChecklistKandangPayload, + UpdateDailyChecklistKandangPayload, +} from '@/types/api/daily-checklist/kandang'; + +export class DailyChecklistKandangApiService extends BaseApiService< + DailyChecklistKandang, + CreateDailyChecklistKandangPayload, + UpdateDailyChecklistKandangPayload +> { + constructor(basePath: string = '/master-data/kandang-groups') { + super(basePath); + } +} + +export const DailyChecklistKandangApi = new DailyChecklistKandangApiService( + '/master-data/kandang-groups' +); diff --git a/src/types/api/daily-checklist/kandang.d.ts b/src/types/api/daily-checklist/kandang.d.ts new file mode 100644 index 00000000..a67344b0 --- /dev/null +++ b/src/types/api/daily-checklist/kandang.d.ts @@ -0,0 +1,24 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { BaseKandang } from '@/types/api/master-data/kandang'; +import { BaseLocation } from '@/types/api/master-data/location'; +import { BaseUser } from '@/types/api/user'; + +export type BaseDailyChecklistKandang = { + id: number; + name: string; + location: BaseLocation; + recording_kandangs: Pick[]; + pic: BaseUser; +}; + +export type DailyChecklistKandang = BaseMetadata & BaseDailyChecklistKandang; + +export type CreateDailyChecklistKandangPayload = { + name: string; + location_id: number; + pic_id: number; + // recording_kandang_ids: number[]; +}; + +export type UpdateDailyChecklistKandangPayload = + CreateDailyChecklistKandangPayload; diff --git a/src/types/api/inventory/product-warehouse.d.ts b/src/types/api/inventory/product-warehouse.d.ts index 060be2ab..726cc135 100644 --- a/src/types/api/inventory/product-warehouse.d.ts +++ b/src/types/api/inventory/product-warehouse.d.ts @@ -9,8 +9,19 @@ export type BaseProductWarehouse = { warehouse_id: number; uom: Uom; quantity: number; + transfer_available_qty?: number; product: Product; warehouse: Warehouse; + project_flock_kandang?: { + id: number; + project_flock_id: number; + kandang_id: number; + period: number; + project_flock?: { + id: number; + flock_name: string; + }; + }; week?: number | null; }; diff --git a/src/types/api/master-data/kandang.d.ts b/src/types/api/master-data/kandang.d.ts index eafa0334..032f67a4 100644 --- a/src/types/api/master-data/kandang.d.ts +++ b/src/types/api/master-data/kandang.d.ts @@ -1,6 +1,7 @@ import { BaseMetadata } from '@/types/api/api-general'; import { BaseLocation } from '@/types/api/master-data/location'; import { BaseUser } from '@/types/api/user'; +import { BaseDailyChecklistKandang } from '@/types/api/daily-checklist/kandang'; export type BaseKandang = { id: number; @@ -10,6 +11,7 @@ export type BaseKandang = { capacity: number; pic: BaseUser; project_flock_kandang_id?: number; + kandang_group: Pick; }; export type Kandang = BaseMetadata & BaseKandang; @@ -19,6 +21,7 @@ export type CreateKandangPayload = { location_id: number; capacity: number; pic_id: number; + group_id: number; }; export type UpdateKandangPayload = CreateKandangPayload; diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index 204e7b49..41a6a1c0 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -74,6 +74,8 @@ export type ProjectFlockKandangLookup = { available_quantity?: number; population: number; chick_in_date: string; + is_transition: boolean; + is_laying: boolean; }; export type ProjectFlockAvailableQuantity = { diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index b78642b8..8ce0ef15 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -49,7 +49,8 @@ export type BaseRecording = { project_flock: ProjectFlock; record_datetime: string; day: number; - executed_at: string; + is_transition: boolean; + is_laying: boolean; } & ProductionMetrics; export type RecordingDepletion = {