diff --git a/.gitignore b/.gitignore index 82965e2d..d86875dd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,8 +40,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# prettier -.prettierrc - # idea .idea diff --git a/package-lock.json b/package-lock.json index f64e3a8f..fc5a5ebf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -33,7 +32,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1647,13 +1645,6 @@ "@types/react": "*" } }, - "node_modules/@types/inputmask": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz", - "integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1689,6 +1680,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1758,6 +1750,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2275,6 +2268,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2817,7 +2811,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/daisyui": { "version": "5.3.10", @@ -3261,6 +3256,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3434,6 +3430,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4248,12 +4245,6 @@ "node": ">=0.8.19" } }, - "node_modules/inputmask": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz", - "integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==", - "license": "MIT" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4725,9 +4716,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5790,6 +5781,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5820,6 +5812,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6635,6 +6628,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6802,6 +6796,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index c2f4f4e6..a6372994 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -36,7 +35,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/production/recording/add/layout.tsx b/src/app/production/recording/add/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/recording/add/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/add/page.tsx b/src/app/production/recording/grading/add/page.tsx new file mode 100644 index 00000000..9b918d98 --- /dev/null +++ b/src/app/production/recording/grading/add/page.tsx @@ -0,0 +1,49 @@ +'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..0a65f528 --- /dev/null +++ b/src/app/production/recording/grading/detail/edit/page.tsx @@ -0,0 +1,53 @@ +'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') + )} + /> + )} +
+ ); +}; + +export default EditGrading; 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..6a5fbcba --- /dev/null +++ b/src/app/production/recording/grading/detail/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 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) + )} + /> + )} +
+ ); +}; + +export default DetailGrading; diff --git a/src/app/production/recording/grading/layout.tsx b/src/app/production/recording/grading/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/recording/grading/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/components/Card.tsx b/src/components/Card.tsx index cde902f0..7b022971 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -3,6 +3,7 @@ import { HTMLAttributes, ReactNode } from 'react'; import { cn } from '@/lib/helper'; +import Image from 'next/image'; export interface CardProps extends Omit, 'className'> { @@ -106,7 +107,7 @@ const Card = ({ return (
- {imageAlt {image && (
- {imageAlt { 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/input/NumberInput.tsx b/src/components/input/NumberInput.tsx index dd6c8c2b..e6e0e773 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -49,8 +49,8 @@ const NumberInput = ({ onValueChange={valueChangeHandler} decimalScale={decimalScale} allowNegative={allowNegative} - startAdornment={inputPrefix} - endAdornment={inputSuffix} + inputPrefix={inputPrefix} + inputSuffix={inputSuffix} {...restProps} /> ); diff --git a/src/components/input/TextInput.tsx b/src/components/input/TextInput.tsx index 4ee1f558..5936c85a 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,117 @@ 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}

diff --git a/src/components/pages/ApprovalSteps.tsx b/src/components/pages/ApprovalSteps.tsx index 7185e31b..b9590bc2 100644 --- a/src/components/pages/ApprovalSteps.tsx +++ b/src/components/pages/ApprovalSteps.tsx @@ -158,6 +158,7 @@ export const formatGroupedApprovalsToApprovalSteps = ( if (approvalGroup.approvals) { switch (approvalGroup?.approvals[0]?.action) { case 'CREATED': + case 'UPDATED': case 'APPROVED': approvalStatus = 'APPROVED'; break; @@ -256,7 +257,7 @@ const useApprovalSteps = ({ moduleName: string; moduleId: string; params?: { - page: number; + page?: number; limit: number; search?: string; group_step_number?: boolean; diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index 6926ce4e..8ff39e3d 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -1,24 +1,46 @@ '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'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; +}) => ( + + + +); const MovementTable = () => { const { @@ -28,30 +50,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 +99,179 @@ 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', - }} - /> - - -
+ ); }; diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index ed8fb479..39f7c669 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -1,34 +1,82 @@ import * as Yup from 'yup'; import { Movement } from '@/types/api/inventory/movement'; +type MovementFormSchemaType = { + transfer_reason: string; + transfer_date: string; + source_warehouse?: { + value: number; + label: string; + area?: string; + location?: string; + } | null; + source_warehouse_id: number; + destination_warehouse?: { + value: number; + label: string; + area?: string; + location?: string; + } | null; + destination_warehouse_id: number; + products: { + product?: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number | string; + }[]; + deliveries: { + delivery_cost?: number | string; + delivery_cost_per_item?: number | string; + document?: File | string | null; + document_path?: string | null; + driver_name: string; + vehicle_plate: string; + supplier?: { + value: number; + label: string; + } | null; + supplier_id: number; + products: { + product?: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number | string; + }[]; + }[]; +}; + export type ProductSchema = { - product: { + product?: { value: number; label: string; } | null; product_id: number; - product_qty: number; + product_qty: number | string; }; export type DeliverySchema = { - delivery_cost?: number | undefined; - delivery_cost_per_item?: number | undefined; + delivery_cost?: number | string; + delivery_cost_per_item?: number | string; document?: File | string | null; document_path?: string | null; driver_name: string; vehicle_plate: string; - supplier: { + supplier?: { value: number; label: string; } | null; supplier_id: number; products: { - product: { + product?: { value: number; label: string; } | null; product_id: number; - product_qty: number; + product_qty: number | string; }[]; }; @@ -102,38 +150,37 @@ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ .required('Produk wajib diisi!'), }); -export const MovementFormSchema = Yup.object({ - transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), - transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), - source_warehouse: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - area: Yup.string().optional(), - location: Yup.string().optional(), - }).nullable(), - source_warehouse_id: Yup.number() - .required('Gudang asal wajib diisi!') - .typeError('Gudang asal wajib diisi!'), - destination_warehouse: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - area: Yup.string().optional(), - location: Yup.string().optional(), - }).nullable(), - destination_warehouse_id: Yup.number() - .required('Gudang tujuan wajib diisi!') - .typeError('Gudang tujuan wajib diisi!'), - products: Yup.array() - .of(ProductObjectSchema) - .min(1, 'Minimal harus ada 1 produk!') - .required('Produk wajib diisi!'), - deliveries: Yup.array() - .of(DeliveryObjectSchema) - .min(1, 'Minimal harus ada 1 pengiriman!') - .required('Pengiriman wajib diisi!'), -}); - -export const UpdateMovementFormSchema = MovementFormSchema; +export const MovementFormSchema: Yup.ObjectSchema = + Yup.object({ + transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), + transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), + source_warehouse: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + area: Yup.string().optional(), + location: Yup.string().optional(), + }).nullable(), + source_warehouse_id: Yup.number() + .required('Gudang asal wajib diisi!') + .typeError('Gudang asal wajib diisi!'), + destination_warehouse: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + area: Yup.string().optional(), + location: Yup.string().optional(), + }).nullable(), + destination_warehouse_id: Yup.number() + .required('Gudang tujuan wajib diisi!') + .typeError('Gudang tujuan wajib diisi!'), + products: Yup.array() + .of(ProductObjectSchema) + .min(1, 'Minimal harus ada 1 produk!') + .required('Produk wajib diisi!'), + deliveries: Yup.array() + .of(DeliveryObjectSchema) + .min(1, 'Minimal harus ada 1 pengiriman!') + .required('Pengiriman wajib diisi!'), + }); export type MovementFormValues = Yup.InferType; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index d8239d14..438c09c6 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -8,28 +8,31 @@ 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 { FormHeader } from '@/components/helper/form/FormHeader'; -import { FormActions } from '@/components/helper/form/FormActions'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; 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'; +import Card from '@/components/Card'; interface MovementFormProps { type?: 'add' | 'edit' | 'detail'; @@ -37,7 +40,10 @@ interface MovementFormProps { } const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { - const [, setMovementFormErrorMessage] = useState(''); + const router = useRouter(); + + // ===== STATE MANAGEMENT ===== + const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); const [ productWarehouseSelectInputValue, setProductWarehouseSelectInputValue, @@ -45,16 +51,112 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); - const { - deleteModal, - movementFormErrorMessage, - isDeleteLoading, - createMovementHandler, - updateMovementHandler, - deleteMovementClickHandler, - confirmationModalDeleteClickHandler, - } = useMovementFormHandlers(initialValues?.id); + // ===== FORM HANDLERS ===== + 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 { + 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 + ); + + // ===== USE SELECT HOOKS ===== + const { + inputValue: warehouseSelectInputValue, + setInputValue: setWarehouseSelectInputValue, + isLoadingOptions: isLoadingWarehouses, + } = useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); + + const { + setInputValue: setSupplierSelectInputValue, + options: supplierOptions, + isLoadingOptions: isLoadingSuppliers, + } = useSelect(SupplierApi.basePath, 'id', 'name', 'search'); + + const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; + const { data: warehouses } = useSWR( + warehousesUrl, + WarehouseApi.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 = useMemo(() => { + if (!isResponseSuccess(warehouses)) return []; + + return ( + warehouses?.data.map((w) => { + warehouseStockMap.get(w.id); + return { + value: w.id, + label: w.name, + area: w.area?.name, + location: + 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') + ? w.location?.name + : undefined, + }; + }) || [] + ); + }, [warehouses, warehouseStockMap]); + + // ===== FORM INITIALIZATION ===== const formikInitialValues = useMemo( () => getMovementFormInitialValues(initialValues), [initialValues] @@ -62,8 +164,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, @@ -71,18 +172,18 @@ 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) { documents.push(d.document); documentIndex = documents.length - 1; - } else { } return { - delivery_cost: d.delivery_cost ?? 0, - delivery_cost_per_item: d.delivery_cost_per_item ?? 0, + delivery_cost: parseInt((d.delivery_cost || '').toString()) || 0, + delivery_cost_per_item: + parseInt((d.delivery_cost_per_item || '').toString()) || 0, document_index: documentIndex, document_path: d.document_path, driver_name: d.driver_name, @@ -90,7 +191,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { supplier_id: d.supplier_id, products: d.products.map((p) => ({ product_id: p.product_id, - product_qty: p.product_qty, + product_qty: parseInt(p.product_qty.toString()) || 0, })), }; }); @@ -102,7 +203,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { destination_warehouse_id: values.destination_warehouse_id, products: values.products.map((p) => ({ product_id: p.product_id, - product_qty: p.product_qty, + product_qty: parseInt(p.product_qty.toString()) || 0, })), deliveries: deliveriesPayload, }; @@ -111,102 +212,43 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { case 'add': await createMovementHandler(payload, documents); break; - case 'edit': - await updateMovementHandler( - initialValues?.id as number, - payload, - documents - ); - break; } }, }); - 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, + 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,132 +305,112 @@ 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: '', + }, + ]; + 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: '', + delivery_cost_per_item: '', + document: null, + driver_name: '', + vehicle_plate: '', + supplier: null, + supplier_id: 0, + products: [ + { + product: null, + product_id: 0, + product_qty: '', + }, + ], + }, + ]); + }; - 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) { const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), 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,25 +418,20 @@ 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; - formik.setFieldValue( - `deliveries.${idx}.delivery_cost_per_item`, - numValue - ); + (idx: number, value: number) => { + formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value); const delivery = formik.values.deliveries?.[idx]; if (delivery) { const productQty = delivery.products.reduce( - (sum, p) => sum + p.product_qty, + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), 0 ); - if (productQty > 0 && 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 +439,23 @@ 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 value = parseFloat(e.target.value) || 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 value = parseFloat(e.target.value) || 0; + handleDeliveryCostPerItemChange(idx, value); + }, + [handleDeliveryCostPerItemChange] + ); + // UTILITY FUNCTIONS const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products @@ -495,31 +478,92 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [productWarehouseOptions, type] ); - const getProductQtyAdornment = useCallback( + const getProductQtyBottomLabel = useCallback( (productIdx: number) => { - if (type === 'detail') return null; + if (type === 'detail') return undefined; const product = formik.values.products?.[productIdx]; - if (!product || !product.product_id) return null; + if (!product || !product.product_id) return undefined; const availableStock = getAvailableStock(product.product_id); const requestedQty = Number(product.product_qty) || 0; const remainingStock = availableStock - requestedQty; if (requestedQty > 0) { + return `Sisa: ${remainingStock.toLocaleString('en-US')}`; + } + + return `Tersedia: ${availableStock.toLocaleString('en-US')}`; + }, + [formik.values.products, getAvailableStock, type] + ); + + const getDeliveryProductQtyBottomLabel = useCallback( + (deliveryIdx: number, productIdx: number) => { + if (type === 'detail') return undefined; + const delivery = formik.values.deliveries?.[deliveryIdx]; + if (!delivery) return undefined; + + const deliveryProduct = delivery.products[productIdx]; + if (!deliveryProduct || !deliveryProduct.product_id) return undefined; + + const relatedProduct = formik.values.products?.find( + (p) => p.product_id === deliveryProduct.product_id + ); + if (!relatedProduct) return undefined; + + const totalQtyUsed = + formik.values.deliveries?.reduce((total, d, dIdx) => { + const productQty = d.products.reduce((sum, p, pIdx) => { + if ( + p.product_id === deliveryProduct.product_id && + !(dIdx === deliveryIdx && pIdx === productIdx) + ) { + return sum + (Number(p.product_qty) || 0); + } + return sum; + }, 0); + return total + productQty; + }, 0) || 0; + + const availableQty = Number(relatedProduct.product_qty) - totalQtyUsed; + return `Tersedia: ${availableQty.toLocaleString('en-US')}`; + }, + [formik.values.deliveries, formik.values.products, type] + ); + + const getWarehouseStockAdornment = useCallback( + (warehouseId: number) => { + const stockInfo = warehouseStockMap.get(warehouseId); + if (!stockInfo) { return ( - - (sisa: {remainingStock.toLocaleString('id-ID')}) - + + Kosong + ); } + const { productCount } = stockInfo; + let color: 'neutral' | 'success' | 'warning' = 'neutral'; + if (productCount === 0) color = 'warning'; + else if (productCount > 0) color = 'success'; + return ( - - (tersedia: {availableStock.toLocaleString('id-ID')}) - + + Tersedia {productCount} produk + ); }, - [formik.values.products, getAvailableStock, type] + [warehouseStockMap] ); const getProductQtyError = useCallback( @@ -532,7 +576,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const requestedQty = Number(product.product_qty) || 0; if (requestedQty > availableStock) { - return `Qty melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`; + return `Qty melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('en-US')}`; } return null; @@ -618,6 +662,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik.values.deliveries, formik.values.products, type] ); + // ===== COMPUTED VALUES ===== const invalidQtyRows = useMemo( () => type === 'detail' @@ -650,847 +695,941 @@ 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 + (parseInt(p.product_qty.toString()) || 0), + 0 + ); + + const deliveryCost = + parseInt((delivery.delivery_cost || '').toString()) || 0; + const deliveryCostPerItem = + parseInt((delivery.delivery_cost_per_item || '').toString()) || 0; + + if (deliveryCost > 0 && productQty > 0) { + const perItem = deliveryCost / productQty; + if (Math.abs(deliveryCostPerItem - perItem) > 0.01) { + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + perItem + ); + } + } else if (deliveryCostPerItem > 0 && productQty > 0) { + const totalCost = deliveryCostPerItem * productQty; + if (Math.abs(deliveryCost - totalCost) > 0.01) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); + } + } + }); + }, [ + formik.values.deliveries + ?.map((d) => + d.products.reduce( + (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0), + 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 ( <>
- +
+ +

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

+
{/* Top card - Movement details */} -
-
-
- - -
+ +
+ +
-
+ {/* Warehouse cards */}
-
-
-

Gudang Asal

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

Gudang Tujuan

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

Produk

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

Pengiriman

-
- - - + +
+
+ + + {type !== 'detail' && ( + + )} + + + + + + + + + {type !== 'detail' && } + + + + {formik.values.deliveries?.map((delivery, idx) => ( + {type !== 'detail' && ( - )} - - - - - - - - - {type !== 'detail' && } - - - - {formik.values.deliveries?.map((delivery, idx) => ( - - {type !== 'detail' && ( - - )} - - - - - + + - - - - {type !== 'detail' && ( - + + + + ) : ( + { + const file = e.target.files?.[0]; + if (file) { + if (file.size > 2 * 1024 * 1024) { + toast.error('Ukuran dokumen maksimal 2 MB!'); + e.target.value = ''; + return; + } + formik.setFieldValue( + `deliveries.${idx}.document`, + file + ); + } + }} + {...isRepeaterInputError( + 'deliveries', + 'document', + idx + )} + className={{ + wrapper: + 'w-full min-w-72 md:w-min-80 lg:w-min-96', + }} + /> )} - - ))} - -
+ 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedDeliveries( + formik.values.deliveries?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedDeliveries([]); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Produk + + * + + + Qty + + * + + + Supplier + + * + + + Plat Nomor + + * + + Dokumen + Biaya Pengiriman (Rp.) + + * + + + Biaya Per Item (Rp.) + + * + + + Nama Sopir + + * + + Aksi
-
- 0 +
+ + ) => { + if (e.target.checked) { + setSelectedDeliveries([ + ...selectedDeliveries, + idx, + ]); + } else { + setSelectedDeliveries( + selectedDeliveries.filter((i) => i !== idx) + ); } - onChange={( - e: React.ChangeEvent - ) => { - if (e.target.checked) { - setSelectedDeliveries( - formik.values.deliveries?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedDeliveries([]); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - - + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + - Produk - - * - - - Qty - - * - - - Supplier - - * - - - Plat Nomor - - * - - Dokumen - Biaya Pengiriman (Rp.) - - * - - - Biaya Per Item (Rp.) - - * - - - Nama Sopir - - * - - Aksi
-
- - ) => { - if (e.target.checked) { - setSelectedDeliveries([ - ...selectedDeliveries, - idx, - ]); - } else { - setSelectedDeliveries( - selectedDeliveries.filter( - (i) => i !== idx - ) - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> -
-
- { - formik.setFieldTouched( - `deliveries.${idx}.products.0.product`, - true - ); - formik.setFieldValue( - `deliveries.${idx}.products.0.product`, - val - ); - formik.setFieldTouched( - `deliveries.${idx}.products.0.product_id`, - true - ); - formik.setFieldValue( - `deliveries.${idx}.products.0.product_id`, - (val as OptionType)?.value - ); - }} - options={getFilteredProductWarehouseOptions()} - isDisabled={type === 'detail'} - isClearable - isError={ - isDeliveryProductInputError(idx, 0, 'product_id') - .isError - } - errorMessage={ - isDeliveryProductInputError(idx, 0, 'product_id') - .errorMessage - } - className={{ - wrapper: - 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - - { - formik.setFieldTouched( - `deliveries.${idx}.supplier`, - true - ); - formik.setFieldValue( - `deliveries.${idx}.supplier`, - val - ); - formik.setFieldTouched( - `deliveries.${idx}.supplier_id`, - true - ); - formik.setFieldValue( - `deliveries.${idx}.supplier_id`, - (val as OptionType)?.value - ); - }} - options={supplierOptions} - onInputChange={setSupplierSelectInputValue} - isLoading={isLoadingSuppliers} - isDisabled={type === 'detail'} - isClearable - {...isRepeaterInputError( - 'deliveries', - 'supplier_id', - idx - )} - className={{ - wrapper: - 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - - {type === 'detail' ? ( - <> -
- -
- - ) : ( - { - const file = e.target.files?.[0]; - if (file) { - if (file.size > 2 * 1024 * 1024) { - toast.error( - 'Ukuran dokumen maksimal 2 MB!' - ); - e.target.value = ''; - return; - } - formik.setFieldValue( - `deliveries.${idx}.document`, - file - ); - } - }} - {...isRepeaterInputError( - 'deliveries', - 'document', - idx - )} - className={{ - wrapper: - 'w-full min-w-72 md:w-min-80 lg:w-min-96', - }} - /> +
+ { + formik.setFieldTouched( + `deliveries.${idx}.products.0.product`, + true + ); + formik.setFieldValue( + `deliveries.${idx}.products.0.product`, + val + ); + formik.setFieldTouched( + `deliveries.${idx}.products.0.product_id`, + true + ); + formik.setFieldValue( + `deliveries.${idx}.products.0.product_id`, + (val as OptionType)?.value + ); + }} + options={getFilteredProductWarehouseOptions()} + isDisabled={type === 'detail'} + isClearable + isError={ + isDeliveryProductInputError(idx, 0, 'product_id') + .isError + } + errorMessage={ + isDeliveryProductInputError(idx, 0, 'product_id') + .errorMessage + } + className={{ + wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + { + formik.setFieldTouched( + `deliveries.${idx}.supplier`, + true + ); + formik.setFieldValue( + `deliveries.${idx}.supplier`, + val + ); + formik.setFieldTouched( + `deliveries.${idx}.supplier_id`, + true + ); + formik.setFieldValue( + `deliveries.${idx}.supplier_id`, + (val as OptionType)?.value + ); + }} + options={supplierOptions} + onInputChange={setSupplierSelectInputValue} + isLoading={isLoadingSuppliers} + isDisabled={type === 'detail'} + isClearable + {...isRepeaterInputError( + 'deliveries', + 'supplier_id', + idx )} - - - handleDeliveryCostChange(idx, e.target.value) - } - onBlur={formik.handleBlur} - {...isRepeaterInputError( - 'deliveries', - 'delivery_cost', - idx - )} - readOnly={type === 'detail'} - className={{ - wrapper: - 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - handleDeliveryCostPerItemChange( - idx, - e.target.value - ) - } - onBlur={formik.handleBlur} - {...isRepeaterInputError( - 'deliveries', - 'delivery_cost_per_item', - idx - )} - readOnly={type === 'detail'} - className={{ - wrapper: - 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - + className={{ + wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + {type === 'detail' ? ( + <>
-
-
- {type !== 'detail' && ( -
- {selectedDeliveries.length > 0 && ( - - )} + + + + + + + + + + + {type !== 'detail' && ( + +
+ +
+ + )} + + ))} + + +
+ {type !== 'detail' && ( +
+ {selectedDeliveries.length > 0 && ( -
- )} -
-
+ )} + +
+ )} + {/* Action buttons */} - - type={type} - formik={formik} - disableSubmit={hasInvalidQty || hasExceededStock} - /> +
+ {type !== 'detail' && ( +
+ + + +
+ )} +
{movementFormErrorMessage && (
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/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts index d28f7d7b..c9cb2b7b 100644 --- a/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts +++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts @@ -1,11 +1,17 @@ import * as Yup from 'yup'; -export const ProductCategoryFormSchema = Yup.object({ - code: Yup.string() - .required('Kode wajib diisi!') - .max(3, 'Kode kategori produk melebihi 3 karakter!'), - name: Yup.string().required('Nama wajib diisi!'), -}); +type ProductCategoryFormSchemaType = { + code: string; + name: string; +}; + +export const ProductCategoryFormSchema: Yup.ObjectSchema = + Yup.object({ + code: Yup.string() + .required('Kode wajib diisi!') + .max(3, 'Kode kategori produk melebihi 3 karakter!'), + name: Yup.string().required('Nama wajib diisi!'), + }); export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema; diff --git a/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx b/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx index a11a9992..7331cbb5 100644 --- a/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx +++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx @@ -71,12 +71,13 @@ const ProductCategoryForm = ({ [router] ); - const formikInitialValues = useMemo(() => { - return { + const formikInitialValues = useMemo( + () => ({ code: initialValues?.code ?? '', name: initialValues?.name ?? '', - }; - }, [initialValues]); + }), + [initialValues] + ); const formik = useFormik({ initialValues: formikInitialValues, @@ -118,7 +119,7 @@ const ProductCategoryForm = ({ await ProductCategoryApi.delete(initialValues?.id as number); deleteModal.closeModal(); - toast.success('Successfully delete Product Category!'); + toast.success('Berhasil menghapus data Kategori Produk!'); setIsDeleteLoading(false); router.push('/master-data/product-category'); }; @@ -129,7 +130,7 @@ const ProductCategoryForm = ({ return ( <> -
+
@@ -157,7 +158,7 @@ const ProductCategoryForm = ({ required label='Kode' name='code' - placeholder='Masukkan kode kategori produk' + placeholder='Masukkan kode...' value={formik.values.code} onChange={formik.handleChange} onBlur={formik.handleBlur} @@ -169,7 +170,7 @@ const ProductCategoryForm = ({ required label='Nama' name='name' - placeholder='Masukkan nama kategori produk' + placeholder='Masukkan nama...' value={formik.values.name} onChange={formik.handleChange} onBlur={formik.handleBlur} @@ -256,7 +257,7 @@ const ProductCategoryForm = ({ = + Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), + brand: Yup.string().required('Merek wajib diisi!'), + sku: Yup.string().required('SKU wajib diisi!'), + + uom: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }) + .nullable() + .required('Satuan wajib diisi!'), + + uom_id: Yup.number() + .required('Satuan wajib diisi!') + .typeError('Satuan wajib diisi!'), + + product_category: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }) + .nullable() + .required('Kategori produk wajib diisi!'), + + product_category_id: Yup.number() + .required('Kategori produk wajib diisi!') + .typeError('Kategori produk wajib diisi!'), + + product_price: Yup.number() + .required('Harga produk wajib diisi!') + .typeError('Harga produk wajib diisi!') + .min(0, 'Harga produk tidak boleh kurang dari 0!'), + + selling_price: Yup.number() + .required('Harga jual wajib diisi!') + .typeError('Harga jual wajib diisi!') + .min(0, 'Harga jual tidak boleh kurang dari 0!'), + + tax: Yup.number() + .required('Pajak wajib diisi!') + .typeError('Pajak wajib diisi!') + .min(0, 'Pajak tidak boleh kurang dari 0!') + .max(100, 'Pajak tidak boleh lebih dari 100%!'), + + expiry_period: Yup.number() + .required('Periode kadaluarsa wajib diisi!') + .typeError('Periode kadaluarsa wajib diisi!') + .min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'), + + supplier_ids: Yup.array() + .of(Yup.number().required().typeError('Supplier tidak valid!')) + .min(1, 'Minimal harus ada 1 supplier!') + .required('Supplier wajib diisi!'), + + flags: Yup.array() + .of(Yup.string().required()) + .min(1, 'Minimal harus ada 1 flag!') + .required('Flag wajib diisi!'), + }); export const UpdateProductFormSchema = ProductFormSchema; diff --git a/src/components/pages/master-data/product/form/ProductForm.tsx b/src/components/pages/master-data/product/form/ProductForm.tsx index d08ffe27..44457c81 100644 --- a/src/components/pages/master-data/product/form/ProductForm.tsx +++ b/src/components/pages/master-data/product/form/ProductForm.tsx @@ -9,7 +9,11 @@ import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; -import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import NumberInput from '@/components/input/NumberInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; @@ -79,20 +83,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { sku: initialValues?.sku ?? '', uom: initialValues?.uom ? { value: initialValues.uom.id, label: initialValues.uom.name } - : null, + : undefined, uom_id: initialValues?.uom?.id ?? 0, product_category: initialValues?.product_category ? { value: initialValues.product_category.id, label: initialValues.product_category.name, } - : null, + : undefined, product_category_id: initialValues?.product_category?.id ?? 0, - product_price: initialValues?.product_price ?? 0, - selling_price: initialValues?.selling_price ?? 0, - tax: initialValues?.tax ?? 0, - expiry_period: initialValues?.expiry_period ?? 0, - supplier: null, // not used for payload, just for UI + product_price: initialValues?.product_price ?? '', + selling_price: initialValues?.selling_price ?? '', + tax: initialValues?.tax ?? '', + expiry_period: initialValues?.expiry_period ?? '', supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [], flags: initialValues?.flags ?? [], }), @@ -111,16 +114,14 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { sku: values.sku, uom_id: values.uom_id, product_category_id: values.product_category_id, - product_price: values.product_price, - selling_price: values.selling_price, - tax: values.tax, - expiry_period: values.expiry_period, - supplier_ids: (values.supplier_ids ?? []).filter( + product_price: parseInt(values.product_price.toString()) || 0, + selling_price: parseInt(values.selling_price.toString()) || 0, + tax: parseInt(values.tax.toString()) || 0, + expiry_period: parseInt(values.expiry_period.toString()) || 0, + supplier_ids: values.supplier_ids.filter( (id): id is number => typeof id === 'number' ), - flags: (values.flags ?? []).filter( - (f): f is string => typeof f === 'string' - ), + flags: values.flags.filter((f): f is string => typeof f === 'string'), }; switch (type) { case 'add': @@ -136,15 +137,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { const { setValues: formikSetValues } = formik; // UOM - const [uomSelectInputValue, setUomSelectInputValue] = useState(''); - const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`; - const { data: uoms, isLoading: isLoadingUoms } = useSWR( - uomsUrl, - UomApi.getAllFetcher - ); - const uomOptions = isResponseSuccess(uoms) - ? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name })) - : []; + const { + setInputValue: setUomSelectInputValue, + options: uomOptions, + isLoadingOptions: isLoadingUoms, + } = useSelect(UomApi.basePath, 'id', 'name'); const uomChangeHandler = (val: OptionType | OptionType[] | null) => { formik.setFieldTouched('uom', true); formik.setFieldValue('uom', val); @@ -153,15 +150,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { }; // Product Category - const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); - const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`; - const { data: categories, isLoading: isLoadingCategories } = useSWR( - categoriesUrl, - ProductCategoryApi.getAllFetcher - ); - const categoryOptions = isResponseSuccess(categories) - ? categories?.data.map((cat) => ({ value: cat.id, label: cat.name })) - : []; + const { + setInputValue: setCategorySelectInputValue, + options: categoryOptions, + isLoadingOptions: isLoadingCategories, + } = useSelect(ProductCategoryApi.basePath, 'id', 'name'); const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { formik.setFieldTouched('product_category', true); formik.setFieldValue('product_category', val); @@ -169,7 +162,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { formik.setFieldValue('product_category_id', (val as OptionType)?.value); }; - // Supplier (multi select) + // Supplier (multi select) - using SWR to filter by category const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( @@ -209,7 +202,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { return ( <> -
+
+ {!isApproved && !(isLayingCategory && !isGradingDone) && ( + + )} + {!isApproved && !(isLayingCategory && !isGradingDone) && ( + + )} +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + {/* Current Status */} + {currentApproval && ( +
+

Status Saat Ini

+
+ + {currentApproval.step_name} + + + {currentApproval.action === 'APPROVED' && 'Disetujui'} + {currentApproval.action === 'REJECTED' && 'Ditolak'} + {currentApproval.action === 'CREATED' && 'Dibuat'} + {currentApproval.action === 'UPDATED' && 'Diperbarui'} + +
+ {currentApproval.notes && ( +

+ Catatan:{' '} + {currentApproval.notes} +

+ )} +

+ Oleh: {currentApproval.action_by.name} •{' '} + {formatDate(currentApproval.action_at, 'DD MMMM YYYY HH:mm')} +

+
+ )} + + {/* Full History */} + {approvalHistory.length > 0 && ( +
+

Riwayat Lengkap

+
+ + + + + + + + + + + + {approvalHistory + .sort( + (a: BaseApproval, b: BaseApproval) => + new Date(b.action_at).getTime() - + new Date(a.action_at).getTime() + ) + .map((approval: BaseApproval, index: number) => ( + + + + + + + + ))} + +
TahapAksiCatatanOlehWaktu
{approval.step_name} + + {approval.action === 'APPROVED' && 'Disetujui'} + {approval.action === 'REJECTED' && 'Ditolak'} + {approval.action === 'CREATED' && 'Dibuat'} + {approval.action === 'UPDATED' && 'Diperbarui'} + + +
+ {approval.notes || '-'} +
+
{approval.action_by.name} + {formatDate( + approval.action_at, + 'DD MMMM YYYY HH:mm' + )} +
+
+
+ )} + + )} +
+ + ); +}; + 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 [selectedRecordings, setSelectedRecordings] = useState([]); - const [, setSelectedRecording] = useState(undefined); + const [rowSelection, setRowSelection] = useState>({}); + const selectedRowIds = Object.keys(rowSelection).map((item) => + parseInt(item) + ); + const [selectedRecording, setSelectedRecording] = useState< + Recording | undefined + >(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false); - const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + const [approvalNotes, setApprovalNotes] = useState(''); const singleDeleteModal = useModal(); - const bulkApproveModal = useModal(); - const bulkRejectModal = useModal(); + const approveModal = useModal(); + const rejectModal = useModal(); + const approvalHistoryModal = useModal(); + + const { + data: recordings, + isLoading, + mutate: refreshRecordings, + } = useSWR( + `${RecordingApi.basePath}${getTableFilterQueryString()}`, + RecordingApi.getAllFetcher + ); + + const isRecordingFullyApproved = useCallback( + (recording: Recording): boolean => { + return ( + recording.approval?.action === 'APPROVED' && + recording.approval?.step_name === 'Disetujui' && + Number(recording.approval?.step_number) === 3 + ); + }, + [] + ); + + const isRecordingApproved = useCallback( + (recording: Recording) => { + return isRecordingFullyApproved(recording); + }, + [isRecordingFullyApproved] + ); + + const isGradingCompleted = useCallback((recording: Recording): boolean => { + if (recording.project_flock_category !== 'LAYING') { + return true; + } + + return ( + recording.egg_grading_status === 'COMPLETED' || + (recording.approval?.action === 'UPDATED' && + recording.approval?.step_number === 2) + ); + }, []); const searchChangeHandler = useCallback( (e: React.ChangeEvent) => { - setSearch(e.target.value); + updateFilter('search', e.target.value); setPage(1); }, - [] + [updateFilter, setPage] ); const pageSizeChangeHandler = useCallback( @@ -194,213 +430,415 @@ const RecordingTable = () => { setPageSize(newVal.value as number); setPage(1); }, - [] + [setPageSize, setPage] ); - 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()) - ); - const start = (page - 1) * pageSize; - return filteredData.slice(start, start + pageSize); - }, [page, pageSize, search]); - - const bulkApproveHandler = async () => { - setIsBulkApproveLoading(true); - console.log( - 'Approved recordings:', - paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) - ); - setTimeout(() => { - setIsBulkApproveLoading(false); - setSelectedRecordings([]); - bulkApproveModal.closeModal(); - }, 1000); - }; - - const bulkRejectHandler = async () => { - setIsBulkRejectLoading(true); - console.log( - 'Rejected recordings:', - paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) - ); - setTimeout(() => { - setIsBulkRejectLoading(false); - setSelectedRecordings([]); - bulkRejectModal.closeModal(); - }, 1000); - }; - 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); }; + const approveHandler = async (notes: string) => { + setIsApproveLoading(true); + + if (eligibleRowIds.length === 0) { + toast.error( + 'Tidak ada recording yang bisa disetujui (sudah disetujui final)' + ); + setIsApproveLoading(false); + return; + } + + const approveResponse = await RecordingApi.approve(eligibleRowIds, notes); + + if (isResponseSuccess(approveResponse)) { + toast.success( + `Berhasil approve ${eligibleRowIds.length} data recording!` + ); + approveModal.closeModal(); + refreshRecordings(); + setApprovalNotes(''); + setRowSelection({}); + } else { + toast.error( + (approveResponse?.message as string) || 'Gagal menyetujui recording' + ); + } + + setIsApproveLoading(false); + }; + + const rejectHandler = async (notes: string) => { + setIsRejectLoading(true); + + if (eligibleRowIds.length === 0) { + toast.error( + 'Tidak ada recording yang bisa ditolak (sudah disetujui final)' + ); + setIsRejectLoading(false); + return; + } + + const rejectResponse = await RecordingApi.reject(eligibleRowIds, notes); + + if (isResponseSuccess(rejectResponse)) { + toast.success(`Berhasil reject ${eligibleRowIds.length} data recording!`); + rejectModal.closeModal(); + refreshRecordings(); + setApprovalNotes(''); + setRowSelection({}); + } else { + toast.error( + (rejectResponse?.message as string) || 'Gagal menolak recording' + ); + } + + setIsRejectLoading(false); + }; + + const eligibleRowIds = useMemo(() => { + if (!isResponseSuccess(recordings) || !recordings.data) return []; + return selectedRowIds.filter((id) => { + const recording = recordings.data.find((r) => r.id === id); + if (!recording || isRecordingApproved(recording)) return false; + + if (recording.project_flock_category === 'GROWING') { + return true; + } + + if (recording.project_flock_category === 'LAYING') { + return isGradingCompleted(recording); + } + + return false; + }); + }, [selectedRowIds, recordings, isRecordingApproved, isGradingCompleted]); + + useEffect(() => { + if (isResponseSuccess(recordings) && recordings.data) { + const newSelection: Record = {}; + + Object.entries(rowSelection).forEach(([rowId, isSelected]) => { + if (isSelected) { + const recording = recordings.data.find( + (r) => r.id === parseInt(rowId) + ); + if (recording && !isRecordingApproved(recording)) { + if (recording.project_flock_category === 'GROWING') { + newSelection[rowId] = true; + } else if ( + recording.project_flock_category === 'LAYING' && + isGradingCompleted(recording) + ) { + newSelection[rowId] = true; + } + } + } + }); + + if ( + Object.keys(newSelection).length !== Object.keys(rowSelection).length + ) { + setRowSelection(newSelection); + } + } + }, [ + recordings, + rowSelection, + isRecordingApproved, + isGradingCompleted, + setRowSelection, + ]); + return ( -
+
- - -
+
+
+ - {/* Bulk action buttons */} -
- {selectedRecordings.length > 0 && ( -
- - + {selectedRowIds.length > 0 && ( + <> + + + + + )}
- )} - + +
- +
+ +
- + data={isResponseSuccess(recordings) ? recordings?.data : []} 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([]); + header: ({ table }) => { + const allRows = table.getRowModel().rows; + + const selectableGrowingRows = allRows.filter((row) => { + const recording = row.original; + return ( + recording.project_flock_category === 'GROWING' && + !isRecordingApproved(recording) + ); + }); + + const hasNoSelectableGrowing = selectableGrowingRows.length === 0; + + const handleSelectAllGrowing = () => { + const isAllSelected = selectableGrowingRows.every((row) => + row.getIsSelected() + ); + + allRows.forEach((row) => { + const recording = row.original; + if ( + recording.project_flock_category === 'GROWING' && + !isRecordingApproved(recording) + ) { + row.toggleSelected(!isAllSelected); + } else if (recording.project_flock_category === 'LAYING') { + row.toggleSelected(false); } - }} - /> - ), - cell: ({ row }) => ( - { - if (e.target.checked) { - setSelectedRecordings([...selectedRecordings, row.index]); - } else { - setSelectedRecordings( - selectedRecordings.filter((i) => i !== row.index) - ); - } - }} - /> - ), + }); + }; + + const isAllGrowingSelected = + selectableGrowingRows.length > 0 && + selectableGrowingRows.every((row) => row.getIsSelected()); + + const isSomeGrowingSelected = selectableGrowingRows.some((row) => + row.getIsSelected() + ); + + return ( +
+ +
+ ); + }, + cell: ({ row }) => { + const isApproved = isRecordingApproved(row.original); + const isLayingCategory = + row.original.project_flock_category === 'LAYING'; + + if (isLayingCategory) { + return null; + } + + const isDisabled = !row.getCanSelect() || isApproved; + + return ( +
+ +
+ ); + }, }, { header: '#', - cell: (props) => pageSize * (page - 1) + props.row.index + 1, - }, - { - accessorKey: 'flock.name', - header: 'Flock', - }, - { - accessorKey: 'recording_date', - header: 'Tanggal Recording', cell: (props) => - new Date(props.row.original.recording_date).toLocaleDateString(), + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, }, { - accessorKey: 'location.name', - header: 'Lokasi', - }, - { - accessorKey: 'coop.name', - header: 'Kandang', - }, - { - accessorKey: 'mortality', - header: 'Total Mortality', + header: 'Nama Project', cell: (props) => - props.row.original.mortality.reduce( - (acc, curr) => acc + curr.count, - 0 - ), + `Project ${props.row.original.project_flock_kandang_id}`, + }, + { + header: 'Kategori', + cell: (props) => { + const category = props.row.original.project_flock_category; + if (!category) return '-'; + const color = category === 'LAYING' ? 'info' : 'warning'; + return ( + + {category} + + ); + }, + }, + { + header: 'Umur (hari)', + cell: (props) => props.row.original.day, + }, + { + accessorKey: 'record_date', + header: 'Waktu Recording', + cell: (props) => + formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'), + }, + { + header: 'Populasi Awal', + cell: (props) => + props.row.original.total_chick_qty?.toLocaleString() || '-', + }, + { + header: 'Status Approval', + cell: (props) => { + const approval = props.row.original.approval; + if (!approval) return '-'; + + const statusColor = + approval.action === 'APPROVED' + ? 'success' + : approval.action === 'REJECTED' + ? 'error' + : approval.action === 'UPDATED' + ? 'warning' + : 'info'; + + const openApprovalHistory = () => { + setSelectedRecording(props.row.original); + approvalHistoryModal.openModal(); + }; + + const getStatusText = (action: string) => { + switch (action) { + case 'APPROVED': + return 'Disetujui'; + case 'REJECTED': + return 'Ditolak'; + case 'CREATED': + return 'Dibuat'; + case 'UPDATED': + return 'Diperbarui'; + default: + return action; + } + }; + + return ( + + {getStatusText(approval.action)} + + ); + }, + }, + { + header: 'Catatan Approval', + cell: (props) => { + const approval = props.row.original.approval; + if (!approval?.notes) return '-'; + + return ( +
+

{approval.notes}

+
+ ); + }, + }, + // { + // header: 'Status Grading Telur', + // cell: (props) => { + // const status = props.row.original.egg_grading_status; + // if (!status) return '-'; + // const color = status === 'COMPLETED' ? 'success' : 'warning'; + // return ( + // + // {status} + // + // ); + // }, + // }, + { + header: 'Dibuat Oleh', + cell: (props) => props.row.original.created_user?.name || '-', + }, + { + header: 'Tanggal Submit', + cell: (props) => + formatDate(props.row.original.created_at, 'DD MMMM YYYY'), }, { header: 'Aksi', @@ -419,6 +857,22 @@ const RecordingTable = () => { singleDeleteModal.openModal(); }; + const approveClickHandler = () => { + setRowSelection({ + [String(props.row.original.id)]: true, + }); + setApprovalNotes(''); + approveModal.openModal(); + }; + + const rejectClickHandler = () => { + setRowSelection({ + [String(props.row.original.id)]: true, + }); + setApprovalNotes(''); + rejectModal.openModal(); + }; + return ( <> {currentPageSize > 2 && ( @@ -427,6 +881,9 @@ const RecordingTable = () => { type='dropdown' props={props} deleteClickHandler={deleteClickHandler} + approveClickHandler={approveClickHandler} + rejectClickHandler={rejectClickHandler} + isGradingCompleted={isGradingCompleted} /> )} @@ -437,6 +894,9 @@ const RecordingTable = () => { type='collapse' props={props} deleteClickHandler={deleteClickHandler} + approveClickHandler={approveClickHandler} + rejectClickHandler={rejectClickHandler} + isGradingCompleted={isGradingCompleted} /> )} @@ -445,18 +905,23 @@ const RecordingTable = () => { }, }, ]} - pageSize={pageSize} - page={page} - totalItems={dummyRecordings.length} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(recordings) ? recordings?.meta?.page : 0} + totalItems={ + isResponseSuccess(recordings) ? recordings?.meta?.total_results : 0 + } onPageChange={setPage} - isLoading={false} + isLoading={isLoading} sorting={sorting} setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} className={{ containerClassName: cn({ - 'mb-20': paginatedData.length === 0, + 'mb-20': + isResponseSuccess(recordings) && recordings?.data?.length === 0, }), - tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableWrapperClassName: 'overflow-x-auto min-h-full overflow-visible!', tableClassName: 'font-inter w-full table-auto min-h-full!', headerRowClassName: 'border-b border-b-gray-200', headerColumnClassName: @@ -481,6 +946,49 @@ const RecordingTable = () => { onClick: singleDeleteHandler, }} /> + + setApprovalNotes(''), + }} + primaryButton={{ + text: 'Ya', + color: 'success', + isLoading: isApproveLoading, + onClick: approveHandler, + }} + placeholder='(Opsional) Tambahkan catatan untuk approval ini...' + rows={3} + /> + + setApprovalNotes(''), + }} + primaryButton={{ + text: 'Ya', + color: 'error', + isLoading: isRejectLoading, + onClick: rejectHandler, + }} + placeholder='(Opsional) Tambahkan catatan untuk reject ini...' + rows={3} + /> + + ); }; diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 4b0b37dd..4d72e053 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -1,212 +1,320 @@ import * as Yup from 'yup'; -import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; -import { Recording } from '@/types/api/production/recording'; +import { + Recording, + CreateGrowingRecordingPayload, + CreateLayingRecordingPayload, + CreateEggPayload, + CreateGradingPayload, +} from '@/types/api/production/recording'; -export const RecordingFormSchema = Yup.object({ - flock: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - flock_id: Yup.number() - .default(0) - .typeError('Flock wajib diisi!') - .test( - 'is-valid-flock', - 'Flock 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() - .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() - .required('Berat ayam wajib diisi!') - .min(1, 'Berat ayam minimal 1 gram!') - .typeError('Berat ayam harus berupa angka!'), - chicken_count: 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!'), - }) - ) - .min(1, 'Minimal harus ada 1 data bobot badan!') - .required('Data bobot badan wajib diisi!'), - vaccination: 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; - } - ), - }) - ) - .min(1, 'Minimal harus ada 1 data vaksinasi!') - .required('Data vaksinasi wajib diisi!'), - mortality: Yup.array() - .of( - Yup.object({ - condition: Yup.mixed() - .oneOf( - RECORDING_FLAG_OPTIONS.map((opt) => opt.value), - 'Kondisi 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!'), - }) - ) - .min(1, 'Minimal harus ada 1 data mortalitas!') - .required('Data mortalitas wajib diisi!'), +type RecordingGrowingFormSchemaType = { + project_flock_kandang: { + value: number; + label: string; + } | null; + project_flock_kandang_id: number; + body_weights: { + weight: number | string; + avg_weight: number | string; + qty: number | string; + }[]; + stocks: { + product_warehouse_id: number; + qty: number | string; + }[]; + depletions: { + product_warehouse_id: number; + qty: number | string; + }[]; +}; + +type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & { + eggs: { + product_warehouse_id: number; + qty: number | string; + }[]; +}; + +type RecordingGradingFormSchemaType = { + eggs_grading: { + recording_egg_id: number; + grade: string; + qty: number | string; + }[]; +}; + +export type BodyWeightSchema = { + weight: number | string; + avg_weight: number | string; + qty: number | string; +}; + +export type StockSchema = { + product_warehouse_id: number; + qty: number | string; +}; + +export type DepletionSchema = { + product_warehouse_id: number; + qty: number | string; +}; + +export type EggSchema = { + product_warehouse_id: number; + qty: number | string; +}; + +const BodyWeightObjectSchema: Yup.ObjectSchema = Yup.object({ + weight: Yup.number() + .required('Berat ayam total wajib diisi!') + .min(1, 'Berat ayam total minimal 1 gram!') + .typeError('Berat ayam total harus berupa angka!'), + avg_weight: Yup.number() + .required('Berat ayam rata-rata wajib diisi!') + .typeError('Berat ayam rata-rata harus berupa angka!'), + qty: Yup.number() + .required('Jumlah ayam wajib diisi!') + .min(1, 'Jumlah ayam minimal 1 ekor!') + .typeError('Jumlah ayam harus berupa angka!'), }); -export const UpdateRecordingFormSchema = RecordingFormSchema; +const StockObjectSchema: Yup.ObjectSchema = Yup.object({ + product_warehouse_id: Yup.number() + .required('Produk wajib diisi!') + .min(1, 'Produk wajib diisi!') + .typeError('Produk harus berupa angka!'), + qty: Yup.number() + .required('Jumlah penggunaan wajib diisi!') + .min(1, 'Jumlah penggunaan tidak boleh 0!') + .typeError('Jumlah penggunaan harus berupa angka!'), +}); -export type RecordingFormValues = Yup.InferType; +const DepletionObjectSchema: Yup.ObjectSchema = Yup.object({ + product_warehouse_id: Yup.number() + .required('Produk depletions wajib diisi!') + .min(1, 'Produk depletions wajib diisi!') + .typeError('Produk depletions harus berupa angka!'), + qty: Yup.number() + .required('Jumlah depletions wajib diisi!') + .min(1, 'Jumlah depletions minimal 1!') + .typeError('Jumlah depletions harus berupa angka!'), +}); -export const getRecordingFormInitialValues = ( - initialValues?: Recording -): RecordingFormValues => ({ - flock: initialValues?.flock +const EggObjectSchema: Yup.ObjectSchema = Yup.object({ + product_warehouse_id: Yup.number() + .required('Kondisi telur wajib diisi!') + .min(1, 'Kondisi telur wajib diisi!') + .typeError('Kondisi telur harus berupa angka!'), + qty: Yup.number() + .required('Jumlah telur wajib diisi!') + .min(1, 'Jumlah telur tidak boleh 0!') + .typeError('Jumlah telur harus berupa angka!'), +}); + +export const RecordingGrowingFormSchema: Yup.ObjectSchema = + Yup.object({ + project_flock_kandang: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + project_flock_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(BodyWeightObjectSchema) + .min(1, 'Minimal harus ada 1 data bobot badan!') + .required('Data bobot badan wajib diisi!'), + stocks: Yup.array() + .of(StockObjectSchema) + .min(1, 'Minimal harus ada 1 data stok!') + .required('Data stok wajib diisi!'), + depletions: Yup.array() + .of(DepletionObjectSchema) + .min(1, 'Minimal harus ada 1 data depletions!') + .required('Data depletions wajib diisi!'), + }); + +export const RecordingLayingFormSchema: Yup.ObjectSchema = + RecordingGrowingFormSchema.shape({ + eggs: Yup.array() + .of(EggObjectSchema) + .min(1, 'Minimal harus ada 1 data telur!') + .required('Data telur wajib diisi!'), + }); + +export const UpdateRecordingGrowingFormSchema = + RecordingGrowingFormSchema.shape({ + project_flock_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!'), + }); + +export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({ + 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!'), +}); + +export const RecordingGradingFormSchema: Yup.ObjectSchema = + Yup.object({ + eggs_grading: Yup.array() + .of( + Yup.object({ + recording_egg_id: Yup.number() + .required('Recording Egg ID wajib diisi!') + .min(1, 'Recording Egg ID minimal 1!') + .typeError('Recording Egg ID harus berupa angka!'), + grade: Yup.string() + .required('Grade telur wajib diisi!') + .typeError('Grade telur harus berupa string!'), + 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; + +export type RecordingGrowingFormValues = Yup.InferType< + typeof RecordingGrowingFormSchema +>; + +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'] | Recording['stocks']; + depletions?: + | CreateGrowingRecordingPayload['depletions'] + | Recording['depletions']; + eggs?: CreateLayingRecordingPayload['eggs'] | Recording['eggs']; + project_flock_kandang_id?: number; + project_flock_category?: string; +}; + +export const getRecordingGrowingFormInitialValues = ( + initialValues?: RecordingFormData +): RecordingGrowingFormValues => ({ + 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 #${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) - : 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 ?? [ + project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0, + body_weights: initialValues?.body_weights?.map( + (bw: NonNullable[0]) => ({ + weight: bw.avg_weight * bw.qty, + avg_weight: bw.avg_weight, + qty: bw.qty, + }) + ) ?? [ { - chicken_weight: 0, - chicken_count: 0, - average_chicken_weight: 0, + weight: '', + avg_weight: '', + qty: '', }, ], - 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) => ({ + product_warehouse_id: stock.product_warehouse_id, + qty: + (stock as { qty?: number; usage_amount?: number }).qty || + (stock as { qty?: number; usage_amount?: number }).usage_amount || + '', + })) ?? [ { - condition: '', - count: 0, + product_warehouse_id: 0, + qty: '', + }, + ], + depletions: initialValues?.depletions?.map( + ( + depletion: NonNullable[0] + ) => ({ + product_warehouse_id: depletion.product_warehouse_id, + qty: depletion.qty, + }) + ) ?? [ + { + product_warehouse_id: 0, + qty: '', + }, + ], +}); + +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: '', + }, + ], +}); + +export const getRecordingGradingFormInitialValues = ( + initialValues?: Partial & { recording_egg_id?: number } +): RecordingGradingFormValues => ({ + eggs_grading: initialValues?.eggs_grading?.map((grading) => ({ + recording_egg_id: grading.recording_egg_id, + grade: grading.grade, + qty: grading.qty, + })) ?? [ + { + recording_egg_id: initialValues?.recording_egg_id ?? 0, + grade: '', + qty: '', }, ], }); diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index e3668237..5900c84a 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1,36 +1,65 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +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'; 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'; -import { - CreateRecordingPayload, - Recording, -} from '@/types/api/production/recording'; -import { - RecordingFormSchema, - RecordingFormValues, - getRecordingFormInitialValues, - UpdateRecordingFormSchema, -} from './RecordingForm.schema'; -import { useRecordingFormHandlers } from './useRecordingFormHandlers'; -import { ProjectFlockApi } from '@/services/api/production/project-flock'; -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 Card from '@/components/Card'; +import Badge from '@/components/Badge'; +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 ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; +import { useModal } from '@/components/Modal'; +import Tooltip from '@/components/Tooltip'; + +import { + ProjectFlockKandangApi, + RecordingApi, + ProjectFlockApi, +} from '@/services/api/production'; +import { LocationApi } from '@/services/api/master-data'; +import { ProductWarehouseApi } from '@/services/api/inventory'; + +import { + CreateGrowingRecordingPayload, + CreateLayingRecordingPayload, + UpdateGrowingRecordingPayload, + UpdateLayingRecordingPayload, + Recording, + NextDayRecording, +} from '@/types/api/production/recording'; +import { type BaseApiResponse } from '@/types/api/api-general'; +import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; +import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; +import { Kandang } from '@/types/api/master-data/kandang'; + +import { + RecordingGrowingFormSchema, + RecordingLayingFormSchema, + RecordingGrowingFormValues, + RecordingLayingFormValues, + getRecordingGrowingFormInitialValues, + getRecordingLayingFormInitialValues, + UpdateRecordingGrowingFormSchema, + UpdateRecordingLayingFormSchema, +} from './RecordingForm.schema'; + +import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; +import { formatDate, formatNumber } from '@/lib/helper'; +import toast from 'react-hot-toast'; +import ApprovalSteps, { + useApprovalSteps, +} from '@/components/pages/ApprovalSteps'; +import { + GROWING_RECORDING_APPROVAL_LINE, + LAYING_RECORDING_APPROVAL_LINE, +} from '@/config/approval-line'; interface RecordingFormProps { type?: 'add' | 'edit' | 'detail'; @@ -38,747 +67,1781 @@ 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(''); + // ===== HOOKS & ROUTER ===== + const router = useRouter(); - const { - deleteModal, - recordingFormErrorMessage, - isDeleteLoading, - createRecordingHandler, - updateRecordingHandler, - deleteRecordingClickHandler, - confirmationModalDeleteClickHandler, - } = useRecordingFormHandlers(initialValues?.id); + // ===== STATE MANAGEMENT ===== + const [selectedBodyWeights, setSelectedBodyWeights] = useState([]); + const [selectedStocks, setSelectedStocks] = useState([]); + const [selectedDepletions, setSelectedDepletions] = useState([]); + const [selectedEggs, setSelectedEggs] = useState([]); - const formikInitialValues = useMemo( - () => getRecordingFormInitialValues(initialValues), - [initialValues] + const [editingAverageIndex] = useState(null); + const [manuallyEditedRows, setManuallyEditedRows] = useState>( + new Set() ); - const formik = useFormik({ - initialValues: formikInitialValues, - validationSchema: - type === 'edit' ? UpdateRecordingFormSchema : RecordingFormSchema, - 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() - : '', - 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, + const [locationSearchValue, setLocationSearchValue] = useState(''); + const [selectedLocation, setSelectedLocation] = useState( + null + ); + const [projectFlockSearchValue, setProjectFlockSearchValue] = useState(''); + const [selectedProjectFlock, setSelectedProjectFlock] = + useState(null); + const [selectedKandang, setSelectedKandang] = useState( + null + ); + + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + const [, setApprovalNotes] = useState(''); + const [recordingFormErrorMessage, setRecordingFormErrorMessage] = + useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [newRecordingData, setNewRecordingData] = useState( + null + ); + const [nextDayRecording, setNextDayRecording] = + useState(null); + + const approveModal = useModal(); + const rejectModal = useModal(); + const deleteModal = useModal(); + + const isRecordingApproved = useCallback((recording?: Recording) => { + return ( + recording?.approval?.action === 'APPROVED' && + recording?.approval?.step_name === 'Disetujui' && + recording?.approval?.step_number === 3 + ); + }, []); + + const hasGradingData = useCallback((recording?: Recording) => { + if (!recording || !recording.eggs) return false; + return recording.eggs.some( + (egg) => + egg.gradings && + egg.gradings.length > 0 && + egg.gradings.some((grading) => grading.qty > 0) + ); + }, []); + + // ===== PAYLOAD CREATION HELPERS ===== + const createGrowingPayload = useCallback( + (values: RecordingGrowingFormValues) => { + return { + project_flock_kandang_id: values.project_flock_kandang_id, + body_weights: (values.body_weights ?? []).map((bw) => { + const qty = Number(bw.qty) || 0; + const weight = Number(bw.weight) || 0; + const totalWeight = qty * weight; + return { + avg_weight: + typeof bw.avg_weight === 'number' + ? bw.avg_weight + : parseFloat(String(bw.avg_weight)) || 0, + qty: qty, + total_weight: parseFloat(String(totalWeight)) || 0, + }; + }), + stocks: (values.stocks ?? []).map((stock) => ({ + product_warehouse_id: stock.product_warehouse_id, + qty: Number(stock.qty) || 0, })), - 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, - })), - 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, + qty: Number(depletion.qty) || 0, })), }; - - switch (type) { - case 'add': - await createRecordingHandler(payload); - break; - case 'edit': - await updateRecordingHandler(initialValues?.id as number, payload); - break; - } }, - }); + [] + ); - // Locations - const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue }).toString()}`; + const createLayingPayload = useCallback( + (values: RecordingLayingFormValues) => { + return { + project_flock_kandang_id: values.project_flock_kandang_id, + body_weights: (values.body_weights ?? []).map((bw) => { + return { + avg_weight: + typeof bw.avg_weight === 'number' + ? bw.avg_weight + : parseFloat(String(bw.avg_weight)) || 0, + qty: Number(bw.qty) || 0, + }; + }), + stocks: (values.stocks ?? []).map((stock) => ({ + product_warehouse_id: stock.product_warehouse_id, + qty: Number(stock.qty) || 0, + })), + depletions: (values.depletions ?? []).map((depletion) => ({ + product_warehouse_id: depletion.product_warehouse_id, + qty: Number(depletion.qty) || 0, + })), + eggs: (values.eggs ?? []).map((egg) => ({ + product_warehouse_id: egg.product_warehouse_id, + qty: Number(egg.qty) || 0, + })), + }; + }, + [] + ); + + // ===== FORM HANDLERS ===== + const createRecordingHandler = useCallback( + async ( + payload: CreateGrowingRecordingPayload | CreateLayingRecordingPayload + ) => { + const res = await RecordingApi.create(payload); + if (isResponseError(res)) { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/production/recording'); + }, + [router] + ); + + const createRecordingHandlerWithRedirect = useCallback( + async ( + payload: CreateGrowingRecordingPayload | CreateLayingRecordingPayload, + redirectToGrading: boolean = false + ) => { + const res = await RecordingApi.create(payload); + if (isResponseError(res)) { + setRecordingFormErrorMessage(res.message); + return null; + } + + toast.success(res?.message as string); + + if (res?.status === 'success' && res.data) { + setNewRecordingData(res.data); + return res.data; + } + + if (redirectToGrading) { + toast.error( + 'Gagal mendapatkan ID recording. Silakan coba dari halaman list.' + ); + router.push('/production/recording'); + } + return null; + }, + [router] + ); + + const updateRecordingHandler = useCallback( + async ( + recordingId: number, + payload: UpdateGrowingRecordingPayload | UpdateLayingRecordingPayload + ) => { + const res = await RecordingApi.update(recordingId, payload); + if (res?.status === 'error') { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/production/recording'); + }, + [router] + ); + + const deleteRecordingClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!initialValues?.id) return; + + setIsDeleteLoading(true); + await RecordingApi.delete(initialValues.id); + deleteModal.closeModal(); + toast.success('Successfully delete Recording!'); + setIsDeleteLoading(false); + router.push('/production/recording'); + }, [deleteModal, initialValues?.id, router]); + + // ===== API DATA FETCHING ===== + const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ + search: locationSearchValue || '', + limit: '100', + }).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( + const projectFlocksUrl = `${ProjectFlockApi.basePath}?${new URLSearchParams({ + search: projectFlockSearchValue || '', + limit: '100', + ...(selectedLocation + ? { location_id: selectedLocation.value.toString() } + : {}), + }).toString()}`; + const { data: projectFlocks, isLoading: isLoadingProjectFlocks } = useSWR( projectFlocksUrl, ProjectFlockApi.getAllFetcher ); - // Pakan Products - const pakanUrl = useMemo(() => { - if (!formik.values.location_id) return null; + const projectFlockKandangLookupUrl = useMemo(() => { + if (!selectedProjectFlock || !selectedKandang) return null; const params = new URLSearchParams({ - flag: 'PAKAN', - search: '', - location_id: formik.values.location_id.toString(), + project_flock_id: selectedProjectFlock.value.toString(), + kandang_id: selectedKandang.value.toString(), }); - return `${ProductWarehouseApi.basePath}?${params.toString()}`; - }, [formik.values.location_id]); + return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; + }, [selectedProjectFlock, selectedKandang]); - const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR( - pakanUrl, - ProductWarehouseApi.getAllFetcher + const { data: projectFlockKandangLookupData } = useSWR( + projectFlockKandangLookupUrl, + projectFlockKandangLookupUrl + ? () => + ProjectFlockApi.getAllFetcher( + projectFlockKandangLookupUrl + ) as Promise> + : null ); - // 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 projectFlockKandangLookup = + projectFlockKandangLookupData?.status === 'success' + ? projectFlockKandangLookupData.data + : undefined; - const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR( - ovkUrl, - ProductWarehouseApi.getAllFetcher + const projectFlockKandangDetailUrl = useMemo(() => { + if (type === 'add' || !initialValues?.project_flock_kandang_id) return null; + return `${ProjectFlockKandangApi.basePath}/${initialValues.project_flock_kandang_id}`; + }, [type, initialValues?.project_flock_kandang_id]); + + const { data: projectFlockKandangDetailData } = useSWR( + projectFlockKandangDetailUrl, + projectFlockKandangDetailUrl + ? () => + ProjectFlockKandangApi.getAllFetcher( + projectFlockKandangDetailUrl + ) as Promise> + : null ); - // COMPUTED VALUES - const buildWarehouseLabel = useCallback((warehouse: Warehouse) => { - const parts: string[] = [warehouse.name]; + const projectFlockKandangDetail = + projectFlockKandangDetailData?.status === 'success' + ? projectFlockKandangDetailData.data + : undefined; - if ('kandang' in warehouse && warehouse.kandang) { - parts.push(warehouse.kandang.name); + const stockProductsUrl = useMemo(() => { + if (!selectedLocation || !selectedKandang) return null; + const params = new URLSearchParams({ + flags: 'PAKAN,OVK', + search: '', + limit: '100', + location_id: selectedLocation.value.toString(), + }); + + if (projectFlockKandangLookup?.kandang?.id) { + params.append( + 'kandang_id', + projectFlockKandangLookup.kandang.id.toString() + ); + } else if (selectedKandang) { + params.append('kandang_id', selectedKandang.value.toString()); } - if ('location' in warehouse && warehouse.location) { - parts.push(warehouse.location.name); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [selectedLocation, selectedKandang, projectFlockKandangLookup]); + + const depletionProductsUrl = useMemo(() => { + if (!selectedLocation || !selectedKandang) return null; + const params = new URLSearchParams({ + search: '', + limit: '100', + location_id: selectedLocation.value.toString(), + }); + + if (projectFlockKandangLookup?.kandang?.id) { + params.append( + 'kandang_id', + projectFlockKandangLookup.kandang.id.toString() + ); + } else if (selectedKandang) { + params.append('kandang_id', selectedKandang.value.toString()); } - if (warehouse.area) { - parts.push(warehouse.area.name); - } + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [selectedLocation, selectedKandang, projectFlockKandangLookup]); - return parts.join(' - '); - }, []); + const today = new Date().toISOString().split('T')[0]; + const existingRecordingsUrl = `${RecordingApi.basePath}`; - const locationOptions = isResponseSuccess(locations) - ? locations.data.map((loc) => ({ value: loc.id, label: loc.name })) - : []; + const { data: existingRecordings } = useSWR( + existingRecordingsUrl, + RecordingApi.getAllFetcher + ); - 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, - })); + const nextDayRecordingUrl = useMemo(() => { + if (!selectedProjectFlock) return null; + const projectFlockId = + typeof selectedProjectFlock.value === 'string' + ? parseInt(selectedProjectFlock.value, 10) + : selectedProjectFlock.value; + return `${RecordingApi.basePath}/next-day?project_flock_id=${projectFlockId}`; }, [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 { data: nextDayRecordingData } = useSWR( + nextDayRecordingUrl, + nextDayRecordingUrl + ? () => { + const projectFlockId = + typeof selectedProjectFlock!.value === 'string' + ? parseInt(selectedProjectFlock!.value, 10) + : selectedProjectFlock!.value; + return RecordingApi.nextDayRecording(projectFlockId); + } + : null ); - const pakanStockMap = useMemo(() => { - const map = new Map(); - filteredPakanProducts.forEach((product) => { - map.set(product.id, product.quantity); - }); - return map; - }, [filteredPakanProducts]); + useEffect(() => { + if (nextDayRecordingData?.status === 'success') { + setNextDayRecording( + nextDayRecordingData.data as unknown as NextDayRecording + ); + } else { + setNextDayRecording(null); + } + }, [nextDayRecordingData]); - 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 { data: stockProducts, isLoading: isLoadingStockProducts } = useSWR( + stockProductsUrl, + ProductWarehouseApi.getAllFetcher ); - const ovkStockMap = useMemo(() => { - const map = new Map(); - filteredOvkProducts.forEach((product) => { - map.set(product.id, product.quantity); + const { data: depletionProductsData, isLoading: isLoadingDepletionProducts } = + useSWR(depletionProductsUrl, ProductWarehouseApi.getAllFetcher); + + const eggProductsUrl = useMemo(() => { + if (!selectedLocation || !selectedKandang) return null; + const params = new URLSearchParams({ + search: 'telur', + limit: '100', + location_id: selectedLocation.value.toString(), }); - return map; - }, [filteredOvkProducts]); - // EFFECTS - useEffect(() => { - if (initialValues?.flock && isResponseSuccess(projectFlocks)) { - const flock = projectFlocks.data.find( - (f) => f.id === initialValues.flock.id + if (projectFlockKandangLookup?.kandang?.id) { + params.append( + 'kandang_id', + projectFlockKandangLookup.kandang.id.toString() ); - if (flock) { - setSelectedProjectFlock(flock); + } else if (selectedKandang) { + params.append('kandang_id', selectedKandang.value.toString()); + } + + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [selectedLocation, selectedKandang, projectFlockKandangLookup]); + + const { data: eggProductsData, isLoading: isLoadingEggProducts } = useSWR( + eggProductsUrl, + ProductWarehouseApi.getAllFetcher + ); + + const approvedProjectFlockKandangsUrl = useMemo(() => { + const params = new URLSearchParams({ + step_name: 'Disetujui', + limit: '100', + }); + return `${ProjectFlockKandangApi.basePath}?${params.toString()}`; + }, []); + + const { data: approvedProjectFlockKandangsData } = useSWR( + approvedProjectFlockKandangsUrl, + ProjectFlockKandangApi.getAllFetcher + ); + + const approvedProjectFlockKandangs = useMemo(() => { + if (!isResponseSuccess(approvedProjectFlockKandangsData)) return []; + return approvedProjectFlockKandangsData.data; + }, [approvedProjectFlockKandangsData]); + + const isLayingCategory = + initialValues?.project_flock_category === 'LAYING' || + projectFlockKandangLookup?.project_flock?.category === 'LAYING' || + projectFlockKandangDetail?.project_flock?.category === 'LAYING'; + + const isGrowingCategory = + initialValues?.project_flock_category === 'GROWING' || + projectFlockKandangLookup?.project_flock?.category === 'GROWING' || + projectFlockKandangDetail?.project_flock?.category === 'GROWING'; + + const recordingApprovalLines = useMemo(() => { + if (isLayingCategory) { + return LAYING_RECORDING_APPROVAL_LINE; + } + if (isGrowingCategory) { + return GROWING_RECORDING_APPROVAL_LINE; + } + return GROWING_RECORDING_APPROVAL_LINE; + }, [isLayingCategory, isGrowingCategory]); + + // ===== APPROVAL DATA FETCHING USING HOOK ===== + const { + approvals, + isLoading: approvalsLoading, + refresh: refreshApprovals, + } = useApprovalSteps({ + latestApproval: initialValues?.approval, + approvalLines: recordingApprovalLines, + moduleName: 'RECORDINGS', + moduleId: initialValues?.id?.toString() ?? '', + params: { + limit: 100, + group_step_number: true, + }, + }); + + // ===== DATA PROCESSING ===== + const locationOptions = useMemo(() => { + let options: OptionType[] = []; + + if (isResponseSuccess(locations)) { + const locationOptionsList = + locations?.data.map((location) => ({ + value: location.id, + label: location.name || '', + })) || []; + options = options.concat(locationOptionsList); + } + + if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) { + const currentLocation = projectFlockKandangDetail.project_flock.location; + if ( + currentLocation && + !options.find((opt) => opt.value === currentLocation.id) + ) { + options.push({ + value: currentLocation.id, + label: currentLocation.name || '', + }); } } - }, [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, - })); + return options; + }, [locations, projectFlockKandangDetail, type]); - // 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 - ); + const projectFlockOptions = useMemo(() => { + let options: OptionType[] = []; - if (hasChanges) { - formik.setFieldValue('body_weight', updatedBodyWeight); + if (isResponseSuccess(projectFlocks)) { + const flockOptions = + projectFlocks?.data.map((projectFlock) => ({ + value: projectFlock.id, + label: projectFlock.flock_name || '', + })) || []; + options = options.concat(flockOptions); + } + + if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) { + const currentProjectFlock = projectFlockKandangDetail.project_flock; + if ( + currentProjectFlock && + !options.find((opt) => opt.value === currentProjectFlock.id) + ) { + options.push({ + value: currentProjectFlock.id, + label: currentProjectFlock.flock_name || '', + }); } } + + return options; + }, [projectFlocks, projectFlockKandangDetail, type]); + + const kandangOptions = useMemo(() => { + let options: OptionType[] = []; + + if (selectedProjectFlock && isResponseSuccess(projectFlocks)) { + const selectedProjectFlockData = projectFlocks.data.find( + (pf) => pf.id === selectedProjectFlock.value + ); + + if (selectedProjectFlockData?.kandangs) { + const approvedKandangIds = approvedProjectFlockKandangs + .filter((pfk) => pfk.project_flock_id === selectedProjectFlock.value) + .map((pfk) => pfk.kandang_id); + + const kandangOptions = selectedProjectFlockData.kandangs + .filter((kandang: Kandang) => { + if (type === 'add') { + return approvedKandangIds.includes(kandang.id); + } + return true; + }) + .map((kandang: Kandang) => ({ + value: kandang.id, + label: kandang.name || '', + })); + options = options.concat(kandangOptions); + } + } + + if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) { + const currentKandang = projectFlockKandangDetail.kandang; + if ( + currentKandang && + !options.find((opt) => opt.value === currentKandang.id) + ) { + options.push({ + value: currentKandang.id, + label: currentKandang.name || '', + }); + } + } + + return options; }, [ - formik.values.body_weight?.map((w) => w.chicken_weight), - formik.values.body_weight?.map((w) => w.chicken_count), + selectedProjectFlock, + projectFlocks, + projectFlockKandangDetail, + type, + approvedProjectFlockKandangs, ]); - // EVENT HANDLERS - Select Inputs - const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - const locationValue = (val as OptionType)?.value; + const recordedProjectFlockKandangIds = useMemo(() => { + if (!isResponseSuccess(existingRecordings)) return new Set(); - formik.setFieldValue('location', val, false); - formik.setFieldValue('location_id', locationValue || 0, false); + const todayRecordings = existingRecordings.data; + const recordedIds = new Set(); - 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(''); - }; + todayRecordings.forEach((recording) => { + const recordingDate = recording.record_datetime?.split('T')[0]; + if (recordingDate === today) { + recordedIds.add(recording.project_flock_kandang_id); + } + }); - const flockChangeHandler = (val: OptionType | OptionType[] | null) => { - const flockValue = (val as OptionType)?.value; + return recordedIds; + }, [existingRecordings, today]); - const selected = isResponseSuccess(projectFlocks) - ? projectFlocks.data.find((flock) => flock.id === flockValue) - : null; + const getLatestTotalChickQty = useCallback( + (projectFlockKandangId: number) => { + if (!isResponseSuccess(existingRecordings)) return null; - setSelectedProjectFlock(selected || null); + const projectFlockRecordings = existingRecordings.data.filter( + (recording) => + recording.project_flock_kandang_id === projectFlockKandangId + ); - formik.setFieldValue('flock', val, false); - formik.setFieldValue('flock_id', flockValue || 0, false); + if (projectFlockRecordings.length === 0) return null; - formik.setFieldValue('coop', null, false); - formik.setFieldValue('coop_id', 0, false); - }; + projectFlockRecordings.sort( + (a, b) => + new Date(b.record_datetime).getTime() - + new Date(a.record_datetime).getTime() + ); - const coopChangeHandler = (val: OptionType | OptionType[] | null) => { - const coopValue = (val as OptionType)?.value; + return projectFlockRecordings[0].total_chick_qty; + }, + [existingRecordings] + ); - formik.setFieldValue('coop', val, false); - formik.setFieldValue('coop_id', coopValue || 0, false); - }; + const unifiedStockProducts = useMemo(() => { + const options: OptionType[] = []; + if (isResponseSuccess(stockProducts) && selectedKandang) { + stockProducts.data.forEach((product) => { + const hasPakanFlag = product.product.flags?.includes('PAKAN'); + const hasOvkFlag = product.product.flags?.includes('OVK'); - // 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); - }; + // Only include products that are in the same location as the selected kandang + if (hasPakanFlag || hasOvkFlag) { + options.push({ + value: product.id, + label: product.product.name, + }); + } + }); + } - 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 - const addBodyWeight = () => { - const newBodyWeight = [ - ...(formik.values.body_weight || []), - { - chicken_weight: 0, - chicken_count: 0, - average_chicken_weight: 0, - }, - ]; - formik.setFieldValue('body_weight', newBodyWeight); - }; - - // 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 + if ( + initialValues && + 'stocks' in initialValues && + initialValues.stocks && + type !== 'add' + ) { + initialValues.stocks?.forEach((stock) => { + if (stock.product_warehouse && stock.product_warehouse.product) { + const existingOption = options.find( + (opt) => opt.value === stock.product_warehouse_id ); - } else { - formik.setFieldValue(`body_weight.${idx}.average_chicken_weight`, ''); + if (!existingOption) { + options.push({ + value: stock.product_warehouse_id, + label: stock.product_warehouse.product.name, + }); + } + } + }); + } + + return options; + }, [stockProducts, initialValues, type, selectedKandang]); + + const depletionProducts = useMemo(() => { + const options: OptionType[] = []; + if (isResponseSuccess(depletionProductsData) && selectedKandang) { + depletionProductsData.data.forEach((product) => { + const productName = product.product.name; + + // Filter for depletion-related products (culling, mati, afkir) + if ( + productName.toLowerCase().includes('culling') || + productName.toLowerCase().includes('mati') || + productName.toLowerCase().includes('afkir') + ) { + options.push({ + value: product.id, + label: product.product.name, + }); + } + }); + } + + if (initialValues && initialValues.depletions && type !== 'add') { + initialValues.depletions.forEach((depletion) => { + if ( + depletion.product_warehouse && + depletion.product_warehouse.product + ) { + const existingOption = options.find( + (opt) => opt.value === depletion.product_warehouse_id + ); + if (!existingOption) { + options.push({ + value: depletion.product_warehouse_id, + label: depletion.product_warehouse.product.name, + }); + } + } + }); + } + + return options; + }, [depletionProductsData, initialValues, type, selectedKandang]); + + const eggProducts = useMemo(() => { + const options: OptionType[] = []; + if (isResponseSuccess(eggProductsData) && selectedKandang) { + eggProductsData.data.forEach((product) => { + const productName = product.product.name; + + // Filter for egg-related products + if ( + productName.toLowerCase().includes('telur') || + productName.toLowerCase().includes('egg') || + productName.toLowerCase().includes('pecah') || + productName.toLowerCase().includes('konsumsi') || + productName.toLowerCase().includes('baik') + ) { + options.push({ + value: product.id, + label: product.product.name, + }); + } + }); + } + + if (initialValues && initialValues.eggs && type !== 'add') { + initialValues.eggs.forEach((egg) => { + if (egg.product_warehouse && egg.product_warehouse.product) { + const existingOption = options.find( + (opt) => opt.value === egg.product_warehouse_id + ); + if (!existingOption) { + options.push({ + value: egg.product_warehouse_id, + label: egg.product_warehouse.product.name, + }); + } + } + }); + } + + return options; + }, [eggProductsData, initialValues, type, selectedKandang]); + + // ===== FORMIK SETUP ===== + const formikInitialValues = useMemo(() => { + let baseValues; + if (isLayingCategory) { + baseValues = getRecordingLayingFormInitialValues( + initialValues + ) as RecordingLayingFormValues; + } else { + baseValues = getRecordingGrowingFormInitialValues(initialValues); + } + + if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) { + baseValues.project_flock_kandang = { + value: projectFlockKandangDetail.project_flock.id, + label: projectFlockKandangDetail.project_flock.flock_name || '', + }; + } + + return baseValues; + }, [initialValues, isLayingCategory, projectFlockKandangDetail, type]); + + const formik = useFormik< + RecordingGrowingFormValues | RecordingLayingFormValues + >({ + initialValues: formikInitialValues, + enableReinitialize: true, + validationSchema: (() => { + if (isLayingCategory) { + return type === 'edit' + ? UpdateRecordingLayingFormSchema + : RecordingLayingFormSchema; + } + return type === 'edit' + ? UpdateRecordingGrowingFormSchema + : RecordingGrowingFormSchema; + })(), + validateOnChange: true, + validateOnBlur: true, + onSubmit: async (values) => { + if (isLayingCategory) { + const layingValues = values as RecordingLayingFormValues; + const layingPayload = createLayingPayload(layingValues); + + 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 = createGrowingPayload(growingValues); + + switch (type) { + case 'add': + await createRecordingHandler( + growingPayload as CreateGrowingRecordingPayload + ); + break; + case 'edit': + await updateRecordingHandler( + initialValues?.id as number, + growingPayload as UpdateGrowingRecordingPayload + ); + break; } } }, - [formik] - ); + }); - // Handle calculation when chicken_count changes - const handleChickenCountChange = useCallback( - (idx: number, value: number) => { - formik.setFieldValue(`body_weight.${idx}.chicken_count`, value); + // ===== HELPER FUNCTIONS ===== + const getTotalChickQtyError = useCallback( + (qty: number) => { + if (type === 'detail') return null; + if (!formik.values.project_flock_kandang_id) return null; - 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`, ''); - } + const totalChickQty = getLatestTotalChickQty( + formik.values.project_flock_kandang_id + ); + if (!totalChickQty) return null; + + if (qty > totalChickQty) { + return `Jumlah ayam tidak boleh melebihi total ayam tersedia! Maksimal: ${formatNumber(totalChickQty)}`; } + + return null; }, - [formik] + [formik.values.project_flock_kandang_id, getLatestTotalChickQty, type] ); - // Handle calculation when average_weight changes - const handleAverageWeightChange = useCallback( - (idx: number, value: number) => { - formik.setFieldValue(`body_weight.${idx}.average_chicken_weight`, value); + useCallback((): OptionType | null => { + if ( + !formik.values.project_flock_kandang || + !isResponseSuccess(projectFlocks) + ) { + return selectedLocation; + } + 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]); - 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`, ''); - } + const getAvailableStock = useCallback( + (productWarehouseId: number) => { + if ((type as 'add' | 'edit' | 'detail') === '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 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); + const requestedUsage = Number(stock.qty) || 0; + if (requestedUsage > availableStock) { + return `Jumlah pakai melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`; } + return null; }, - [formik] + [formik.values.stocks, getAvailableStock, type] ); - // Create wrapper handlers that match NumberInput's onChange signature - const handleChickenWeightChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = - parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || - 0; - handleChickenWeightChange(idx, value); + const getStockUsageAdornment = useCallback( + (stockIdx: number) => { + 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); + const requestedUsage = Number(stock.qty) || 0; + const remainingStock = availableStock - requestedUsage; + if (requestedUsage > 0) { + return ( + + (sisa: {formatNumber(remainingStock)}) + + ); + } + return ( + + (tersedia: {formatNumber(availableStock)}) + + ); }, - [handleChickenWeightChange] + [formik.values.stocks, getAvailableStock, type] ); - const handleChickenCountChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = - parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || - 0; - handleChickenCountChange(idx, value); - }, - [handleChickenCountChange] - ); + const getProjectFlockBadgeAdornment = useCallback(() => { + if (!projectFlockKandangLookup) return null; - const handleAverageWeightChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = - parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || - 0; - handleAverageWeightChange(idx, value); - }, - [handleAverageWeightChange] - ); - - const handleVaccinationStockChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; - formik.setFieldValue(`vaccination.${idx}.used_stock`, value); - }, - [formik] - ); - - const handleMortalityCountChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; - formik.setFieldValue(`mortality.${idx}.count`, value); - }, - [formik] - ); - - const removeBodyWeight = (idx: number) => { - const updatedBodyWeight = formik.values.body_weight?.filter( - (_, i) => i !== idx + const isAlreadyRecorded = recordedProjectFlockKandangIds.has( + projectFlockKandangLookup.project_flock_kandang_id ); - formik.setFieldValue('body_weight', updatedBodyWeight); - }; + let color: 'neutral' | 'success' | 'warning' | 'error'; - const removeSelectedBodyWeight = () => { - const updatedBodyWeight = formik.values.body_weight?.filter( - (_, idx) => !selectedWeight.includes(idx) + if (isAlreadyRecorded) { + color = 'warning'; + } else { + color = 'success'; + } + + return ( + + Periode {projectFlockKandangLookup.project_flock?.period} + ); - formik.setFieldValue('body_weight', updatedBodyWeight); - setSelectedWeight([]); - }; + }, [recordedProjectFlockKandangIds, projectFlockKandangLookup]); - // EVENT HANDLERS - Vaccination - const addVaccination = () => { - const newVaccination = [ - ...(formik.values.vaccination || []), - { - vaccine: null, - vaccine_id: '', - total_stock: '', - used_stock: 0, - }, - ]; - formik.setFieldValue('vaccination', newVaccination); - }; + const getProductFlagBadgeAdornment = useCallback( + (productWarehouseId: number) => { + if (!isResponseSuccess(stockProducts)) return null; - const removeVaccination = (idx: number) => { - const updatedVaccination = formik.values.vaccination?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('vaccination', updatedVaccination); - }; + const productWarehouse = stockProducts.data.find( + (pw) => pw.id === productWarehouseId + ); + if (!productWarehouse) return null; - const removeSelectedVaccination = () => { - const updatedVaccination = formik.values.vaccination?.filter( - (_, idx) => !selectedVaccine.includes(idx) - ); - formik.setFieldValue('vaccination', updatedVaccination); - setSelectedVaccine([]); - }; + const hasPakanFlag = productWarehouse.product.flags?.includes('PAKAN'); + const hasOvkFlag = productWarehouse.product.flags?.includes('OVK'); - // EVENT HANDLERS - Mortality - const addMortality = () => { - const newMortality = [ - ...(formik.values.mortality || []), - { - condition: RECORDING_FLAG_OPTIONS[0].value, - count: 0, - }, - ]; - formik.setFieldValue('mortality', newMortality); - }; + if (hasPakanFlag) { + return ( + + PAKAN + + ); + } - const removeMortality = (idx: number) => { - const updatedMortality = formik.values.mortality?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('mortality', updatedMortality); - }; + if (hasOvkFlag) { + return ( + + OVK + + ); + } - const removeSelectedMortality = () => { - const updatedMortality = formik.values.mortality?.filter( - (_, idx) => !selectedMortality.includes(idx) - ); - formik.setFieldValue('mortality', updatedMortality); - setSelectedMortality([]); - }; - - const handleFeedStockChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0; - formik.setFieldValue(`feed_data.${idx}.feed_stock`, value); + return null; }, - [formik] + [stockProducts] ); - // HELPER FUNCTIONS - const isRepeaterInputError = < - T extends 'feed_data' | 'body_weight' | 'vaccination' | 'mortality', - >( - 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, + const hasExceededStock = useMemo(() => { + if ((type as 'add' | 'edit' | 'detail') === 'detail') return false; + return ( + formik.values.stocks?.some((stock, idx) => { + return getStockUsageError(idx) !== null; + }) ?? false + ); + }, [formik.values.stocks, getStockUsageError, type]); + + const hasConsumableEggs = useMemo(() => { + if (!isLayingCategory) return false; + const layingValues = formik.values as RecordingLayingFormValues; + if (!layingValues.eggs || layingValues.eggs.length === 0) return false; + + return layingValues.eggs.some((egg) => { + if (!egg.product_warehouse_id || Number(egg.qty) <= 0) return false; + + const product = eggProducts.find( + (opt) => opt.value === egg.product_warehouse_id + ); + + if (!product) return false; + + const productName = product.label.toLowerCase(); + return ( + productName.includes('konsumsi') && + productName.includes('baik') && + Number(egg.qty) > 0 + ); + }); + }, [isLayingCategory, formik.values, eggProducts]); + + const hasConsumableEggsInRecording = useCallback((recording?: Recording) => { + if (!recording || !recording.eggs || recording.eggs.length === 0) + return false; + + return recording.eggs.some((egg) => { + if (!egg.product_warehouse || !egg.product_warehouse.product) + return false; + if (Number(egg.qty) <= 0) return false; + + const productName = egg.product_warehouse.product.name.toLowerCase(); + return ( + productName.includes('konsumsi') && + productName.includes('baik') && + Number(egg.qty) > 0 + ); + }); + }, []); + + const hasConsumableEggsInCurrentRecording = useMemo(() => { + return ( + hasConsumableEggsInRecording(initialValues) || + hasConsumableEggsInRecording(newRecordingData || undefined) + ); + }, [initialValues, newRecordingData, hasConsumableEggsInRecording]); + + 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) : '', }; }; + // ===== EVENT HANDLERS ===== + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation(val as OptionType); + setSelectedProjectFlock(null); + setSelectedKandang(null); + formik.setFieldValue('project_flock_kandang', null); + formik.setFieldValue('project_flock_kandang_id', 0); + }; + + const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedProjectFlock(val as OptionType); + setSelectedKandang(null); + formik.setFieldValue('project_flock_kandang', null); + formik.setFieldValue('project_flock_kandang_id', 0); + }; + + const kandangChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedKandang(val as OptionType); + formik.setFieldTouched('project_flock_kandang', true); + formik.setFieldTouched('project_flock_kandang_id', true); + }; + + useEffect(() => { + if (projectFlockKandangLookup?.project_flock_kandang_id) { + const projectFlockKandangId = + projectFlockKandangLookup.project_flock_kandang_id; + + if (type === 'add') { + if (recordedProjectFlockKandangIds.has(projectFlockKandangId)) { + toast.error('Project Flock Kandang ini sudah direcord hari ini!'); + return; + } + + if ( + nextDayRecording && + nextDayRecording.project_flock_kandang_id === projectFlockKandangId + ) { + const hasSameDayRecording = isResponseSuccess(existingRecordings) + ? existingRecordings.data?.some( + (recording: Recording) => + recording.project_flock_kandang_id === + projectFlockKandangId && + recording.day === nextDayRecording.next_day + ) + : false; + + if (hasSameDayRecording) { + toast.error( + `Recording untuk hari ${nextDayRecording.next_day} sudah ada. + Tidak bisa membuat recording duplikat, mohon perbarui recording yang sudah ada terlebih dahulu.` + ); + return; + } + } + } + + if (formik.values.project_flock_kandang_id !== projectFlockKandangId) { + formik.setFieldValue('project_flock_kandang_id', projectFlockKandangId); + + formik.setFieldValue('project_flock_kandang', { + value: projectFlockKandangId, + label: projectFlockKandangLookup + ? `${projectFlockKandangLookup.project_flock.flock_name} - ${projectFlockKandangLookup.kandang.name}` + : `${selectedProjectFlock?.label || ''} - ${selectedKandang?.label || ''}`, + }); + } + } + }, [ + projectFlockKandangLookup, + selectedProjectFlock, + selectedKandang, + type, + recordedProjectFlockKandangIds, + formik.values.project_flock_kandang_id, + nextDayRecording, + existingRecordings, + today, + ]); + + useEffect(() => { + if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) { + const location = projectFlockKandangDetail.project_flock.location; + const projectFlock = projectFlockKandangDetail.project_flock; + const kandang = projectFlockKandangDetail.kandang; + + if (location) { + const locationOption = { + value: location.id, + label: location.name || '', + }; + setSelectedLocation(locationOption); + + if (projectFlock) { + const projectFlockOption = { + value: projectFlock.id, + label: projectFlock.flock_name || '', + }; + setSelectedProjectFlock(projectFlockOption); + + if (kandang) { + const kandangOption = { + value: kandang.id, + label: kandang.name || '', + }; + setSelectedKandang(kandangOption); + + if ( + formik.values.project_flock_kandang_id !== + projectFlockKandangDetail.id + ) { + formik.setFieldValue( + 'project_flock_kandang_id', + projectFlockKandangDetail.id + ); + formik.setFieldValue('project_flock_kandang', { + value: projectFlockKandangDetail.id, + label: `${projectFlock.flock_name} - ${kandang.name}`, + }); + } + } + } + } + } + }, [ + projectFlockKandangDetail, + type, + projectFlockOptions, + formik.values.project_flock_kandang_id, + ]); + + const approveHandler = async (notes: string) => { + setIsApproveLoading(true); + + const approveResponse = await RecordingApi.approve( + initialValues?.id as number, + notes + ); + + if (isResponseSuccess(approveResponse)) { + toast.success('Recording berhasil disetujui!'); + approveModal.closeModal(); + setApprovalNotes(''); + await refreshApprovals(); + router.push('/production/recording'); + } else { + toast.error( + (approveResponse?.message as string) || 'Gagal menyetujui recording' + ); + approveModal.closeModal(); + } + + setIsApproveLoading(false); + }; + + const rejectHandler = async (notes: string) => { + setIsRejectLoading(true); + + const rejectResponse = await RecordingApi.reject( + initialValues?.id as number, + notes + ); + + if (isResponseSuccess(rejectResponse)) { + toast.success('Recording berhasil ditolak!'); + rejectModal.closeModal(); + setApprovalNotes(''); + await refreshApprovals(); + router.push('/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: '', + avg_weight: '', + qty: '', + }, + ]; + 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 = Number(currentWeight.qty) || 0; + const totalWeight = qty * value; + + if (qty > 0 && value > 0) { + const avgWeight = parseFloat((value / qty).toFixed(2)); + formik.setFieldValue(`body_weights.${idx}.avg_weight`, avgWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.avg_weight`, ''); + } + + formik.setFieldValue(`body_weights.${idx}.total_weight`, totalWeight); + } + }; + + const handleAvgWeightChange = (idx: number, value: number) => { + formik.setFieldValue(`body_weights.${idx}.avg_weight`, value); + + setManuallyEditedRows((prev) => { + const newSet = new Set(prev); + newSet.add(idx); + return newSet; + }); + + const currentWeight = formik.values.body_weights?.[idx]; + if (currentWeight) { + const qty = Number(currentWeight.qty) || 0; + if (qty > 0 && value > 0) { + const totalWeight = value * qty; + formik.setFieldValue(`body_weights.${idx}.weight`, totalWeight); + formik.setFieldValue(`body_weights.${idx}.total_weight`, totalWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.weight`, 0); + formik.setFieldValue(`body_weights.${idx}.total_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 = Number(currentWeight.weight) || 0; + const totalWeight = value * weight; + + if (value > 0 && weight > 0) { + const avgWeight = parseFloat((weight / value).toFixed(2)); + formik.setFieldValue(`body_weights.${idx}.avg_weight`, avgWeight); + } else { + formik.setFieldValue(`body_weights.${idx}.avg_weight`, ''); + } + + formik.setFieldValue(`body_weights.${idx}.total_weight`, totalWeight); + } + }; + + const handleWeightChangeWrapper = + (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0; + handleWeightChange(idx, value); + }; + + const handleAvgWeightChangeWrapper = + (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0; + handleAvgWeightChange(idx, value); + }; + + const handleQtyChangeWrapper = + (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0; + handleQtyChange(idx, value); + }; + + 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, + qty: '', + }, + ]; + formik.setFieldValue('stocks', newStocks); + }; + + const handleStockUsageQtyChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0; + formik.setFieldValue(`stocks.${idx}.qty`, 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 || []), + { + product_warehouse_id: 0, + qty: '', + }, + ]; + formik.setFieldValue('depletions', newDepletions); + }; + + const handleDepletionQtyChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0; + formik.setFieldValue(`depletions.${idx}.qty`, 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([]); + }; + + // Eggs Handlers + const addEgg = () => { + const newEggs = [ + ...((formik.values as RecordingLayingFormValues).eggs || []), + { + product_warehouse_id: 0, + qty: '', + }, + ]; + 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: '' }]); + } + } + }, [isLayingCategory, type]); + + useEffect(() => { + if (type !== 'add') { + setNewRecordingData(null); + } + }, [isLayingCategory, type]); + + const bodyWeightValues = useMemo(() => { + if (!formik.values.body_weights) return []; + return formik.values.body_weights.map((w) => ({ + weight: w.weight, + qty: w.qty, + })); + }, [formik.values.body_weights]); + + 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; + } + const qty = Number(weight.qty) || 0; + const weightValue = Number(weight.weight) || 0; + return { + ...weight, + avg_weight: + qty > 0 && weightValue > 0 + ? parseFloat((weightValue / qty).toFixed(2)) + : 0, + }; + } + ); + const hasChanges = updatedBodyWeights.some( + (updated, idx) => + idx !== editingAverageIndex && + !manuallyEditedRows.has(idx) && + updated.avg_weight !== + (formik.values.body_weights[idx]?.avg_weight || 0) + ); + if (hasChanges) { + formik.setFieldValue('body_weights', updatedBodyWeights, false); + } + } + }, [bodyWeightValues, editingAverageIndex, manuallyEditedRows]); + return ( <>
- +
+
+ + + {type === 'detail' && + !isRecordingApproved(initialValues) && + (!isLayingCategory || hasGradingData(initialValues)) && ( +
+ + + +
+ )} +
+ +

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

+
+ + {type === 'detail' && approvals && !approvalsLoading && ( + + )} + {/* Basic Info Card */} + {(type === 'add' || type === 'edit') && ( + +
+ <> + + + + + + +
+
+ )} + + {/* Combined Info Card for Detail View */} + {type === 'detail' && + initialValues && + (projectFlockKandangLookup || projectFlockKandangDetail) && ( + +
+ {initialValues.approval && ( +
+ + Status Approval + +
+ + {(() => { + const actionText = (() => { + switch (initialValues.approval.action) { + case 'APPROVED': + return 'Disetujui'; + case 'REJECTED': + return 'Ditolak'; + case 'CREATED': + return 'Dibuat'; + case 'UPDATED': + return 'Diperbarui'; + default: + return initialValues.approval.action; + } + })(); + + const stepName = initialValues.approval.step_name; + + if (stepName === actionText) { + return stepName; + } + + return `${stepName} - ${actionText}`; + })()} + +
+
+ )} +
+ Lokasi +

+ {projectFlockKandangLookup?.project_flock?.location + ?.name || + projectFlockKandangDetail?.project_flock?.location + ?.name || + '-'} +

+
+
+ Project Flock +

+ {projectFlockKandangLookup?.project_flock?.flock_name || + projectFlockKandangDetail?.project_flock?.flock_name || + '-'} +

+
+
+ Kandang +

+ {projectFlockKandangLookup?.kandang?.name || + projectFlockKandangDetail?.kandang?.name || + '-'} +

+
+
+ + Tanggal Recording + +

+ {formatDate( + initialValues.record_datetime || '', + 'DD MMMM YYYY' + )} +

+
+
+ Hari +

Hari ke-{initialValues.day}

+
+
+ Kategori +

+ + {initialValues.project_flock_category} + +

+
+
+ Periode +

+ + Periode{' '} + {projectFlockKandangLookup?.project_flock?.period || + projectFlockKandangDetail?.project_flock?.period || + '-'} + +

+
+
+
+ )} + + {/* Body Weights Table */} -
- { - locationChangeHandler(val); - setTimeout(() => { - formik.setFieldTouched('location', true); - formik.setFieldTouched('location_id', true); - }, 0); - }} - options={locationOptions} - onInputChange={setLocationSelectInputValue} - isLoading={isLoadingLocations} - isError={ - formik.touched.location_id && - Boolean(formik.errors.location_id) - } - errorMessage={formik.errors.location_id as string} - isDisabled={type === 'detail'} - isClearable - placeholder='Pilih lokasi terlebih dahulu' - /> - - { - const date = e.target.value ? new Date(e.target.value) : null; - formik.setFieldValue('recording_date', date); - }} - onBlur={formik.handleBlur} - isError={ - formik.touched.recording_date && - Boolean(formik.errors.recording_date) - } - errorMessage={formik.errors.recording_date as string} - readOnly={type === 'detail'} - /> - - { - flockChangeHandler(val); - setTimeout(() => { - formik.setFieldTouched('flock', true); - formik.setFieldTouched('flock_id', true); - }, 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' - } - /> - - { - coopChangeHandler(val); - setTimeout(() => { - formik.setFieldTouched('coop', true); - formik.setFieldTouched('coop_id', true); - }, 0); - }} - 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' - } - /> -
-
- - {/* Feed Data Table */} - {
- {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( )} - - {type !== 'detail' && } + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + )} - {formik.values.feed_data?.map((feed, idx) => ( - - {type !== 'detail' && ( + {formik.values.body_weights?.map((bw, idx) => ( + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( )} - - - {type !== 'detail' && ( - - )} - - ))} - -
0 + formik.values.body_weights?.length === + selectedBodyWeights.length && + formik.values.body_weights?.length > 0 } onChange={( e: React.ChangeEvent ) => { if (e.target.checked) { - setSelectedFeed( - formik.values.feed_data?.map((_, idx) => idx) ?? - [] + setSelectedBodyWeights( + formik.values.body_weights?.map( + (_, idx) => idx + ) ?? [] ); } else { - setSelectedFeed([]); + setSelectedBodyWeights([]); } }} classNames={{ @@ -817,43 +1881,56 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { - Nama Pakan + Berat Ayam (gram) * Total Stock pada saat ini - Jumlah Stock yang digunakan + Jumlah Ayam * Action + Rata-rata Berat Ayam (gram) + + * + + Action
) => { if (e.target.checked) { - setSelectedFeed([...selectedFeed, idx]); + setSelectedBodyWeights([ + ...selectedBodyWeights, + idx, + ]); } else { - setSelectedFeed( - selectedFeed.filter((i) => i !== idx) + setSelectedBodyWeights( + selectedBodyWeights.filter((i) => i !== idx) ); } }} @@ -864,281 +1941,75 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { /> - - 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' && ( -
- {selectedFeed.length > 0 && ( - - )} - -
- )} - - - {/* Body Weight Table */} - -
- - - - {type !== 'detail' && ( - - )} - - - - {type !== 'detail' && } - - - - {formik.values.body_weight?.map((weight, idx) => ( - - {type !== 'detail' && ( - - )} - + - - {type !== 'detail' && ( + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
-
- 0 - } - onChange={( - e: React.ChangeEvent - ) => { - if (e.target.checked) { - setSelectedWeight( - formik.values.body_weight?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedWeight([]); - } - }} - classNames={{ - wrapper: - 'flex justify-center items-center h-full', - checkbox: 'checkbox checkbox-sm', - }} - /> -
-
- Berat (Gram) - - * - - - Jumlah Ayam - - * - - - Rata-rata berat Ayam - - * - - Action
- - ) => { - if (e.target.checked) { - setSelectedWeight([...selectedWeight, idx]); - } else { - setSelectedWeight( - selectedWeight.filter((i) => i !== idx) - ); - } - }} - classNames={{ - wrapper: 'flex justify-center items-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - - + + { -
- -
-
-
+
- {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
- {selectedWeight.length > 0 && ( + {selectedBodyWeights.length > 0 && ( )}
)}
- {/* Vaccination Table */} + {/* Stocks Table */} { - {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( )} - - {type !== 'detail' && } + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + )} - {formik.values.vaccination?.map((vaccine, idx) => ( - - {type !== 'detail' && ( + {formik.values.stocks?.map((stock, idx) => ( + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - - {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
-
- 0 + 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedStocks( + formik.values.stocks?.map((_, idx) => idx) ?? [] + ); + } else { + setSelectedStocks([]); } - onChange={( - e: React.ChangeEvent - ) => { - if (e.target.checked) { - setSelectedVaccine( - formik.values.vaccination?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedVaccine([]); - } - }} - classNames={{ - wrapper: - 'flex justify-center items-center h-full', - checkbox: 'checkbox checkbox-sm', - }} - /> -
+ }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + />
- Name Vaksin + Persediaan * Total Stock pada saat ini - Jumlah Stock yang digunakan + Jumlah Pakai * ActionAction
) => { if (e.target.checked) { - setSelectedVaccine([...selectedVaccine, idx]); + setSelectedStocks([...selectedStocks, idx]); } else { - setSelectedVaccine( - selectedVaccine.filter((i) => i !== idx) + setSelectedStocks( + selectedStocks.filter((i) => i !== idx) ); } }} @@ -1353,131 +2191,94 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { - Number(opt.value) === Number(vaccine.vaccine_id) - ) ?? null + unifiedStockProducts.find( + (product) => + product.value === stock.product_warehouse_id + ) || null } - onChange={(val) => { - const productWarehouseId = - (val as OptionType)?.value ?? 0; - const stock = productWarehouseId - ? (ovkStockMap.get( - productWarehouseId as number - ) ?? '') - : ''; + onChange={(selectedOption) => { + const option = selectedOption as OptionType | null; formik.setFieldValue( - `vaccination.${idx}.vaccine`, - val + `stocks.${idx}.product_warehouse_id`, + option?.value || 0 ); - 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} + options={unifiedStockProducts} + placeholder='Pilih Produk' + isLoading={isLoadingStockProducts} isError={ isRepeaterInputError( - 'vaccination', - 'vaccine_id', + 'stocks', + 'product_warehouse_id', idx ).isError } errorMessage={ isRepeaterInputError( - 'vaccination', - 'vaccine_id', + 'stocks', + 'product_warehouse_id', idx ).errorMessage } + className={{ + wrapper: 'w-full min-w-48', + }} + isSearchable isDisabled={type === 'detail'} - isClearable - className={{ - wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', - }} - /> - - - +
+ + {(type as 'add' | 'edit' | 'detail') !== 'detail' && + getStockUsageAdornment(idx)} +
-
+
- {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
- {selectedVaccine.length > 0 && ( + {selectedStocks.length > 0 && ( )}
)} - {/* Mortality Table */} + {/* Depletions Table */} { - {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( )} - {type !== 'detail' && } + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + )} - {formik.values.mortality?.map((mortality, idx) => ( - - {type !== 'detail' && ( + {formik.values.depletions?.map((depletion, idx) => ( + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
-
- 0 + 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedDepletions( + formik.values.depletions?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedDepletions([]); } - onChange={( - e: React.ChangeEvent - ) => { - if (e.target.checked) { - setSelectedMortality( - formik.values.mortality?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedMortality([]); - } - }} - classNames={{ - wrapper: - 'flex justify-center items-center h-full', - checkbox: 'checkbox checkbox-sm', - }} - /> -
+ }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + />
- Kondisi/Alasan Mortalitas + Kondisi * @@ -1577,34 +2371,36 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { Jumlah * ActionAction
) => { if (e.target.checked) { - setSelectedMortality([ - ...selectedMortality, + setSelectedDepletions([ + ...selectedDepletions, idx, ]); } else { - setSelectedMortality( - selectedMortality.filter((i) => i !== idx) + setSelectedDepletions( + selectedDepletions.filter((i) => i !== idx) ); } }} @@ -1617,69 +2413,80 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} opt.value === mortality.condition - )} - onChange={(val) => { - formik.setFieldTouched( - `mortality.${idx}.condition`, - true - ); + value={ + depletionProducts.find( + (product) => + product.value === depletion.product_warehouse_id + ) || null + } + onChange={(selectedOption) => { + const option = selectedOption as OptionType | null; formik.setFieldValue( - `mortality.${idx}.condition`, - (val as OptionType)?.value + `depletions.${idx}.product_warehouse_id`, + option?.value || 0 ); }} + options={depletionProducts} + placeholder='Pilih Kondisi' + isLoading={isLoadingDepletionProducts} isError={ - isRepeaterInputError('mortality', 'condition', idx) - .isError + isRepeaterInputError( + 'depletions', + 'product_warehouse_id', + idx + ).isError } errorMessage={ - isRepeaterInputError('mortality', 'condition', idx) - .errorMessage + isRepeaterInputError( + 'depletions', + 'product_warehouse_id', + idx + ).errorMessage } - options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} - isClearable className={{ - wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', + wrapper: 'w-full min-w-48', }} + isSearchable + isClearable={type !== 'detail'} /> -
+
- {type !== 'detail' && ( + {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
- {selectedMortality.length > 0 && ( + {selectedDepletions.length > 0 && ( )}
)} - {/* Action buttons */} - - type={type} - formik={formik} - editUrl={ - initialValues - ? `/production/recording/detail/edit/?recordingId=${initialValues.id}` - : undefined - } - onDelete={deleteRecordingClickHandler} - /> + {/* Eggs Table - Only for LAYING Category */} + {isLayingCategory && ( + +
+ + + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + )} + + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + )} + + + + {(formik.values as RecordingLayingFormValues).eggs?.map( + (egg, idx) => ( + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + )} + + + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( + + )} + + ) + )} + +
+ 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedEggs( + ( + formik.values as RecordingLayingFormValues + ).eggs?.map((_, idx) => idx) ?? [] + ); + } else { + setSelectedEggs([]); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Kondisi Telur + + * + + + Jumlah + + * + + Action
+ + ) => { + if (e.target.checked) { + setSelectedEggs([...selectedEggs, idx]); + } else { + setSelectedEggs( + selectedEggs.filter((i) => i !== idx) + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + + product.value === egg.product_warehouse_id + ) || null + } + onChange={(selectedOption) => { + const option = + selectedOption as OptionType | null; + formik.setFieldValue( + `eggs.${idx}.product_warehouse_id`, + option?.value || 0 + ); + }} + options={eggProducts} + placeholder='Pilih Kondisi Telur' + isLoading={isLoadingEggProducts} + isError={ + isRepeaterInputError( + 'eggs', + 'product_warehouse_id', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'eggs', + 'product_warehouse_id', + idx + ).errorMessage + } + isDisabled={type === 'detail'} + className={{ + wrapper: 'w-full min-w-48', + }} + isSearchable + isClearable={type !== 'detail'} + /> + +
+ +
+
+
+ +
+
+
+ {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( +
+ {selectedEggs.length > 0 && ( + + )} + +
+ )} +
+ )} + {/* Action buttons */} +
+ {/* Left side - Detail & Edit actions */} +
+ {type === 'detail' && deleteRecordingClickHandler && ( + + )} + {type === 'detail' && initialValues && ( + + )} +
+ {/* Right side actions */} +
+ {type === 'detail' && isLayingCategory && ( + + + + )} + + {type === 'edit' && ( +
+ + +
+ )} + + {type === 'add' && ( +
+ + + {isLayingCategory && ( + + + + )} +
+ )} +
+
{recordingFormErrorMessage && (
{ - {type !== 'add' && ( - + {/* ===== MODALS ===== */} + {type === 'detail' && ( + <> + + + {/* Approve Confirmation Modal */} + {(type as 'add' | 'edit' | 'detail') === 'detail' && + !isRecordingApproved(initialValues) && + (!isLayingCategory || hasGradingData(initialValues)) && ( + setApprovalNotes(''), + }} + primaryButton={{ + text: 'Ya', + color: 'success', + isLoading: isApproveLoading, + onClick: approveHandler, + }} + placeholder='(Opsional) Tambahkan catatan untuk approval ini...' + rows={3} + /> + )} + + {/* Reject Confirmation Modal */} + {(type as 'add' | 'edit' | 'detail') === 'detail' && + !isRecordingApproved(initialValues) && + (!isLayingCategory || hasGradingData(initialValues)) && ( + setApprovalNotes(''), + }} + primaryButton={{ + text: 'Ya', + color: 'error', + isLoading: isRejectLoading, + onClick: rejectHandler, + }} + placeholder='(Opsional) Tambahkan catatan untuk reject ini...' + rows={3} + /> + )} + )} ); diff --git a/src/components/pages/production/recording/form/useRecordingFormHandlers.ts b/src/components/pages/production/recording/form/useRecordingFormHandlers.ts deleted file mode 100644 index 334b791d..00000000 --- a/src/components/pages/production/recording/form/useRecordingFormHandlers.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { toast } from 'react-hot-toast'; -import { useModal } from '@/components/Modal'; -import { RecordingApi } from '@/services/api/production'; -import { - CreateRecordingPayload, - UpdateRecordingPayload, -} from '@/types/api/production/recording'; -import { isResponseError } from '@/lib/api-helper'; - -export const useRecordingFormHandlers = (initialValuesId?: number) => { - const router = useRouter(); - const deleteModal = useModal(); - const [recordingFormErrorMessage, setRecordingFormErrorMessage] = - useState(''); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const createRecordingHandler = useCallback( - async (payload: CreateRecordingPayload) => { - const res = await RecordingApi.create(payload); - if (isResponseError(res)) { - setRecordingFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.push('/flock/recording'); - }, - [router] - ); - - const updateRecordingHandler = useCallback( - async (recordingId: number, payload: UpdateRecordingPayload) => { - const res = await RecordingApi.update(recordingId, payload); - if (res?.status === 'error') { - setRecordingFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.refresh(); - router.push('/flock/recording'); - }, - [router] - ); - - const deleteRecordingClickHandler = useCallback(() => { - deleteModal.openModal(); - }, [deleteModal]); - - const confirmationModalDeleteClickHandler = useCallback(async () => { - if (!initialValuesId) return; - - setIsDeleteLoading(true); - await RecordingApi.delete(initialValuesId); - deleteModal.closeModal(); - toast.success('Successfully delete Recording!'); - setIsDeleteLoading(false); - router.push('/flock/recording'); - }, [deleteModal, initialValuesId, router]); - - return { - deleteModal, - recordingFormErrorMessage, - isDeleteLoading, - createRecordingHandler, - updateRecordingHandler, - deleteRecordingClickHandler, - confirmationModalDeleteClickHandler, - }; -}; diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx new file mode 100644 index 00000000..9c3ba37a --- /dev/null +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -0,0 +1,1051 @@ +'use client'; + +import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useFormik } from 'formik'; +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 Card from '@/components/Card'; +import Badge from '@/components/Badge'; + +import { + CreateGradingPayload, + UpdateGradingPayload, + RecordingEgg, + GradingEgg, + Recording, +} from '@/types/api/production/recording'; +import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; +import { type BaseApiResponse } from '@/types/api/api-general'; + +import { + RecordingGradingFormSchema, + RecordingGradingFormValues, + UpdateRecordingGradingFormSchema, + getRecordingGradingFormInitialValues, +} from '../../form/RecordingForm.schema'; + +import { cn, formatDate } from '@/lib/helper'; +import toast from 'react-hot-toast'; +import { isResponseError } from '@/lib/api-helper'; + +import { + RecordingApi, + ProjectFlockKandangApi, +} from '@/services/api/production'; + +import { useModal } from '@/components/Modal'; +import useSWR from 'swr'; + +// INTERFACES & PROPS +interface GradingFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: RecordingEgg & { + grading_eggs?: GradingEgg[]; + gradings?: { grade: string; qty: number }[]; + }; +} + +const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => { + // HOOKS & ROUTER + const router = useRouter(); + const searchParams = useSearchParams(); + const recordingId = searchParams.get('recording_id'); + + // STATE MANAGEMENT + const [selectedGradingItems, setSelectedGradingItems] = useState( + [] + ); + const [gradingFormErrorMessage, setGradingFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const deleteModal = useModal(); + + // API DATA FETCHING + const recordingUrl = useMemo(() => { + const recordingIdToUse = recordingId; + if (!recordingIdToUse) return null; + return `${RecordingApi.basePath}/${recordingIdToUse}`; + }, [recordingId]); + + const { data: recordingData } = useSWR( + recordingUrl, + recordingUrl ? RecordingApi.getAllFetcher : null + ); + + // DATA PROCESSING + const recording = + recordingData?.status === 'success' + ? (recordingData.data as unknown as Recording) + : undefined; + + const projectFlockKandangUrl = useMemo(() => { + if (!recording?.project_flock_kandang_id) return null; + return `${ProjectFlockKandangApi.basePath}/${recording.project_flock_kandang_id}`; + }, [recording?.project_flock_kandang_id]); + + const { data: projectFlockKandangData } = useSWR( + projectFlockKandangUrl, + projectFlockKandangUrl ? ProjectFlockKandangApi.getAllFetcher : null + ); + + const projectFlockKandang = + projectFlockKandangData?.status === 'success' + ? (projectFlockKandangData.data as unknown as ProjectFlockKandang) + : undefined; + + const konsumsiBaikEggData = useMemo(() => { + if (!recording?.eggs) return null; + + const konsumsiBaikEgg = recording.eggs.find((egg: RecordingEgg) => + egg.product_warehouse?.product?.name + ?.toLowerCase() + .includes('konsumsi baik') + ); + + return konsumsiBaikEgg || null; + }, [recording]); + + const totalKonsumsiBaikEggs = konsumsiBaikEggData?.qty || 0; + const konsumsiBaikEggId = konsumsiBaikEggData?.id; + + const isDataLoading = + !recording || + (totalKonsumsiBaikEggs === 0 && + recording?.project_flock_category === 'LAYING'); + + // FORM HANDLERS + const createGradingHandler = useCallback( + async (payload: CreateGradingPayload) => { + const res = (await RecordingApi.createGrading(payload)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setGradingFormErrorMessage(res?.message || 'Failed to add Grading'); + return; + } + + toast.success(res?.message || 'Successfully added Grading!'); + router.push('/production/recording'); + }, + [router] + ); + + const updateGradingHandler = useCallback( + async (gradingId: number, payload: UpdateGradingPayload) => { + const res = (await RecordingApi.updateGrading(gradingId, payload)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setGradingFormErrorMessage(res?.message || 'Failed to update Grading'); + return; + } + toast.success(res?.message || 'Successfully updated Grading!'); + router.refresh(); + router.push('/production/recording'); + }, + [router] + ); + + const deleteRecordingClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!initialValues?.id) return; + + setIsDeleteLoading(true); + try { + const res = (await RecordingApi.deleteGrading(initialValues.id)) as + | BaseApiResponse + | undefined; + + if (!res || isResponseError(res)) { + setGradingFormErrorMessage(res?.message || 'Failed to delete Grading'); + return; + } + deleteModal.closeModal(); + toast.success(res?.message || 'Successfully delete Grading!'); + router.push('/production/recording'); + } catch (err) { + console.error(err); + setGradingFormErrorMessage('Failed to delete Grading'); + } finally { + setIsDeleteLoading(false); + } + }, [deleteModal, initialValues?.id, router]); + + // FORMIK SETUP + const formikInitialValues = useMemo(() => { + let recordingEggId: number | undefined = konsumsiBaikEggId; + + if (!recordingEggId && initialValues?.id) { + recordingEggId = initialValues.id; + } + + if (!recordingEggId) { + recordingEggId = parseInt(recordingId || '0') || 0; + } + + let gradingData: { + recording_egg_id: number; + grade: string; + qty: number; + }[] = []; + + if (initialValues?.grading_eggs && initialValues.grading_eggs.length > 0) { + gradingData = initialValues.grading_eggs.map((grading: GradingEgg) => ({ + recording_egg_id: recordingEggId, + grade: grading.grade, + qty: grading.qty, + })); + } else if (initialValues?.gradings && initialValues.gradings.length > 0) { + gradingData = initialValues.gradings.map( + (grading: { grade: string; qty: number }) => ({ + recording_egg_id: recordingEggId, + grade: grading.grade, + qty: grading.qty, + }) + ); + } + + return getRecordingGradingFormInitialValues({ + recording_egg_id: recordingEggId, + eggs_grading: gradingData, + }); + }, [initialValues, recordingId, konsumsiBaikEggId]); + + const formik = useFormik({ + initialValues: formikInitialValues, + enableReinitialize: true, + validationSchema: (() => { + return type === 'edit' + ? UpdateRecordingGradingFormSchema + : RecordingGradingFormSchema; + })(), + validateOnChange: true, + validateOnBlur: true, + onSubmit: async (values) => { + const gradingPayload = { + eggs_grading: (values.eggs_grading ?? []).map((grading) => ({ + recording_egg_id: grading.recording_egg_id, + grade: grading.grade, + qty: grading.qty || 0, + })), + }; + + switch (type) { + case 'add': + await createGradingHandler(gradingPayload as CreateGradingPayload); + break; + case 'edit': + await updateGradingHandler( + initialValues?.id as number, + gradingPayload as UpdateGradingPayload + ); + break; + } + }, + }); + + const currentGradingTotal = useMemo(() => { + return (formik.values.eggs_grading || []).reduce((total, grading) => { + return total + (Number(grading.qty) || 0); + }, 0); + }, [formik.values.eggs_grading]); + + const isGradingExceedsAvailable = currentGradingTotal > totalKonsumsiBaikEggs; + const isGradingIncomplete = + currentGradingTotal < totalKonsumsiBaikEggs && totalKonsumsiBaikEggs > 0; + const hasUserStartedGrading = currentGradingTotal > 0; + + // GRADING HANDLERS + const addGrading = () => { + let recordingEggId: number | undefined = konsumsiBaikEggId; + + if (!recordingEggId && initialValues?.id) { + recordingEggId = initialValues.id; + } + + if (!recordingEggId) { + recordingEggId = parseInt(recordingId || '0') || 0; + } + + const newGrading = [ + ...(formik.values.eggs_grading || []), + { + recording_egg_id: recordingEggId, + grade: '', + qty: '', + }, + ]; + 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([]); + }; + + // VALIDATION HELPERS + 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(() => { + if (isDataLoading) { + toast.dismiss('grading-exceeds'); + toast.dismiss('grading-incomplete'); + return; + } + + if (isGradingExceedsAvailable && currentGradingTotal > 0) { + toast.error( + `Total grading (${currentGradingTotal}) melebihi telur yang tersedia (${totalKonsumsiBaikEggs})!`, + { + id: 'grading-exceeds', + duration: 3000, + } + ); + toast.dismiss('grading-incomplete'); + } else if (isGradingIncomplete && hasUserStartedGrading) { + toast.error( + `Total grading (${currentGradingTotal}) tidak sama dengan total telur konsumsi baik yang tersedia (${totalKonsumsiBaikEggs})! Semua telur harus digrading.`, + { + id: 'grading-incomplete', + duration: 3000, + } + ); + toast.dismiss('grading-exceeds'); + } else { + toast.dismiss('grading-exceeds'); + toast.dismiss('grading-incomplete'); + } + }, [ + isDataLoading, + isGradingExceedsAvailable, + isGradingIncomplete, + hasUserStartedGrading, + currentGradingTotal, + totalKonsumsiBaikEggs, + ]); + + useEffect(() => { + if ( + konsumsiBaikEggId && + formik.values.eggs_grading && + formik.values.eggs_grading.length === 0 + ) { + formik.setFieldValue('eggs_grading', [ + { recording_egg_id: konsumsiBaikEggId, grade: '', qty: '' }, + ]); + } + }, [konsumsiBaikEggId, formik.values.eggs_grading.length]); + + return ( + <> +
+
+ +

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

+
+ +
+ {/* Basic Info Card */} + +
+ {/* Status Approval */} + {recording?.approval && ( +
+ Status Approval +
+ + {(() => { + const actionText = (() => { + switch (recording.approval.action) { + case 'APPROVED': + return 'Disetujui'; + case 'REJECTED': + return 'Ditolak'; + case 'CREATED': + return 'Dibuat'; + case 'UPDATED': + return 'Diperbarui'; + default: + return recording.approval.action; + } + })(); + + const stepName = recording.approval.step_name; + + if (stepName === actionText) { + return stepName; + } + + return `${stepName} - ${actionText}`; + })()} + +
+
+ )} + {/* Recording Info */} +
+ Lokasi +

+ {projectFlockKandang?.project_flock?.location?.name || '-'} +

+
+
+ Project Flock +

+ {projectFlockKandang?.project_flock?.flock_name || '-'} +

+
+
+ Kandang +

+ {projectFlockKandang?.kandang?.name || '-'} +

+
+
+ Tanggal Recording +

+ {recording + ? formatDate(recording.record_datetime, 'DD MMMM YYYY') + : '-'} +

+
+
+ Hari +

Hari ke-{recording?.day || '-'}

+
+
+ Kategori +

+ + {recording?.project_flock_category || '-'} + +

+
+
+ Periode +

+ + Periode {projectFlockKandang?.project_flock?.period || '-'} + +

+
+
+ +
+ {/* Additional Recording Info */} +
+
+
+ +
+ + Detail Recording + +
+
+
+

Area

+

+ {projectFlockKandang?.project_flock?.area?.name || '-'} +

+
+
+

Status Kandang

+

+ {projectFlockKandang?.kandang?.status || '-'} +

+
+
+
+ + {/* Total Telur Konsumsi Baik Info */} +
+
+
+

+ Total Telur Konsumsi Baik +

+
+

+ {isDataLoading ? ( + + ) : ( + totalKonsumsiBaikEggs + )}{' '} + + telur + +

+
+
+
+ +
+
+ + {/* Progress Bar */} +
+
+ Total yang digrading: + + {isDataLoading ? ( + + ) : ( + `${currentGradingTotal} / ${totalKonsumsiBaikEggs}` + )} + +
+
+
+
+ {!isDataLoading && isGradingExceedsAvailable && ( +
+ + Melebihi batas tersedia +
+ )} + {!isDataLoading && + isGradingIncomplete && + hasUserStartedGrading && ( +
+ + + Grading belum lengkap, semua telur harus digrading + +
+ )} + {isDataLoading && ( +
+ + Memuat data telur konsumsi baik... +
+ )} +
+
+
+ + + {/* 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' && ( +
+ + +
+ )} +
+ {gradingFormErrorMessage && ( +
+ + {gradingFormErrorMessage} +
+ )} + +
+ + {/* ===== MODALS ===== */} + {type !== 'add' && ( + <> + + + )} + + ); +}; + +export default GradingForm; diff --git a/src/config/approval-line.ts b/src/config/approval-line.ts index 8174057d..bedc1ad8 100644 --- a/src/config/approval-line.ts +++ b/src/config/approval-line.ts @@ -33,6 +33,51 @@ export const TRANSFER_TO_LAYING_APPROVAL_LINE: ApprovalLine = [ }, ] as const; +export const RECORDING_APPROVAL_LINE: ApprovalLine = [ + { + step_number: 1, + step_name: 'Grading-Telur', + }, + { + step_number: 2, + step_name: 'Pengajuan', + }, + { + step_number: 3, + step_name: 'Disetujui', + }, +] as const; + +export const GROWING_RECORDING_APPROVAL_LINE: ApprovalLine = [ + { + step_number: 1, + step_name: 'Grading-Telur', + }, + { + step_number: 2, + step_name: 'Pengajuan', + }, + { + step_number: 3, + step_name: 'Disetujui', + }, +] as const; + +export const LAYING_RECORDING_APPROVAL_LINE: ApprovalLine = [ + { + step_number: 1, + step_name: 'Grading-Telur', + }, + { + step_number: 2, + step_name: 'Pengajuan', + }, + { + step_number: 3, + step_name: 'Disetujui', + }, +] as const; + export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [ { step_number: 1, diff --git a/src/config/constant.ts b/src/config/constant.ts index e82a1e7b..847245d4 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -233,9 +233,42 @@ 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' }, +]; + +export const APPROVAL_WORKFLOWS = [ + { + key: 'PROJECT_FLOCKS', + steps: [ + { + step_number: 1, + step_name: 'Pengajuan', + }, + { + step_number: 2, + step_name: 'Aktif', + }, + ], + }, + { + key: 'RECORDINGS', + steps: [ + { + step_number: 1, + step_name: 'Grading-Telur', + }, + { + step_number: 2, + step_name: 'Pengajuan', + }, + { + step_number: 3, + step_name: 'Disetujui', + }, + ], + }, ]; export const ACCEPTED_FILE_TYPE = { diff --git a/src/services/api/approval.ts b/src/services/api/approval.ts new file mode 100644 index 00000000..1debace3 --- /dev/null +++ b/src/services/api/approval.ts @@ -0,0 +1,6 @@ +import { BaseApiService } from '@/services/api/base'; +import { BaseApproval } from '@/types/api/api-general'; + +export const ApprovalApi = new BaseApiService( + '/approvals' +); 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/services/api/production.ts b/src/services/api/production.ts index 79617a75..4266f6b7 100644 --- a/src/services/api/production.ts +++ b/src/services/api/production.ts @@ -1,8 +1,17 @@ -import { BaseApiService } from '@/services/api/base'; +import { BaseApiService } from './base'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { + CreateProjectFlockPayload, + ProjectFlock, + UpdateProjectFlockPayload, +} from '@/types/api/production/project-flock'; import { CreateRecordingPayload, Recording, UpdateRecordingPayload, + CreateGradingPayload, + UpdateGradingPayload, + NextDayRecording, } from '@/types/api/production/recording'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; @@ -11,8 +20,96 @@ export const ProjectFlockKandangApi = new BaseApiService< unknown, unknown >('/production/project-flock-kandangs'); -export const RecordingApi = new BaseApiService< +export const ProjectFlockApi = new BaseApiService< + ProjectFlock, + CreateProjectFlockPayload, + UpdateProjectFlockPayload +>('/production/project-flocks'); +export class RecordingService extends BaseApiService< Recording, CreateRecordingPayload, UpdateRecordingPayload ->('/flock/recordings'); +> { + constructor(basePath: string = '') { + super(basePath); + } + + async approve( + idOrIds: number | number[], + notes?: string + ): Promise | undefined> { + const approvable_ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + return await this.customRequest>('approvals', { + method: 'POST', + payload: { + action: 'APPROVED', + approvable_ids, + notes, + }, + }); + } + + async reject( + idOrIds: number | number[], + notes: string = '' + ): Promise | undefined> { + const approvable_ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + return await this.customRequest>('approvals', { + method: 'POST', + payload: { + action: 'REJECTED', + approvable_ids, + notes, + }, + }); + } + + async createGrading( + payload: CreateGradingPayload + ): Promise | undefined> { + return await this.customRequest>('gradings', { + method: 'POST', + payload, + }); + } + + async updateGrading( + gradingId: number, + payload: UpdateGradingPayload + ): Promise | undefined> { + return await this.customRequest>( + `gradings/${gradingId}`, + { + method: 'PUT', + payload, + } + ); + } + + async deleteGrading( + gradingId: number + ): Promise | undefined> { + return await this.customRequest>( + `gradings/${gradingId}`, + { + method: 'DELETE', + } + ); + } + + async nextDayRecording( + projectFlockId: number + ): Promise | undefined> { + return await this.customRequest>( + `next-day`, + { + method: 'GET', + params: { + project_flock_kandang_id: projectFlockId, + }, + } + ); + } +} + +export const RecordingApi = new RecordingService('/production/recordings'); diff --git a/src/services/api/production/project-flock-kandang.ts b/src/services/api/production/project-flock-kandang.ts new file mode 100644 index 00000000..b7729325 --- /dev/null +++ b/src/services/api/production/project-flock-kandang.ts @@ -0,0 +1,11 @@ +import { BaseApiService } from '@/services/api/base'; +import { + BaseProjectFlockKandang, + ProjectFlockKandang, +} from '@/types/api/production/project-flock-kandang'; + +export const ProjectFlockKandangApi = new BaseApiService< + BaseProjectFlockKandang, + ProjectFlockKandang, + unknown +>('project-flock-kandang'); 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; diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index 9e6e7c3c..54ae86f0 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -7,8 +7,8 @@ import { BaseApproval, BaseMetadata } from '@/types/api/api-general'; export type BaseProjectFlock = { id: number; - name: string; - flock_name: string; + name?: string; + flock_name?: string; status: string; flock?: Flock; flock_i?: number; @@ -52,6 +52,16 @@ export type ProjectFlockApprovalPayload = { 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; +}; + export type ProjectFlockAvailableQuantity = { project_flock_id: number; flock_name: string; diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index 6fac0bc8..e7b28f47 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -1,61 +1,147 @@ -import { BaseMetadata } from '@/types/api/api-general'; -import { Location } from '@/types/api/master-data/location'; -import { Kandang } from '@/types/api/master-data/kandang'; -import { Flock } from '@/types/api/master-data/flock'; +import { BaseApproval, BaseMetadata, User } from '@/types/api/api-general'; +import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; + +export type ProductionMetrics = { + total_depletion_qty: number; + cum_depletion_rate: number; + daily_gain: number; + avg_daily_gain: number; + cum_intake: number; + fcr_value: number; + total_chick_qty: number; + daily_depletion_rate?: number; + cum_depletion?: number; +}; export type BaseRecording = { id: number; - flock: Flock; - recording_date: string; - location: Location; - coop: Kandang; - feed_data: { - feed_name: string; - feed_qty: number; - feed_stock: number; - }[]; - body_weight: { - chicken_weight: number; - chicken_count: number; - average_chicken_weight: number; - }[]; - vaccination: { - vaccine_name: string; - total_stock: number; - used_stock: number; - }[]; - mortality: { - condition: string; - count: number; + project_flock_kandang_id: number; + record_datetime: string; + day: number; + created_by: User; +} & ProductionMetrics; + +export type RecordingBW = { + id: number; + recording_id: number; + avg_weight: number; + qty: number; + total_weight: number; +}; + +export type RecordingDepletion = { + id: number; + recording_id: number; + product_warehouse_id: number; + qty: number; + product_warehouse: ProductWarehouse; +}; + +export type RecordingStock = { + id: number; + recording_id: number; + product_warehouse_id: number; + usage_amount?: number; + usage_qty: number; + qty: number; + pending_qty: number; + product_warehouse: ProductWarehouse; +}; + +export type RecordingEgg = { + id: number; + recording_id: number; + product_warehouse_id: number; + qty: number; + created_by: User; + product_warehouse: ProductWarehouse; + gradings?: { + grade: string; + qty: number; }[]; }; -export type Recording = BaseMetadata & BaseRecording; +export type GradingEgg = { + id: number; + recording_egg_id: number; + qty: number; + grade: string; + created_by: User; +}; -export type CreateRecordingPayload = { - flock_id: number; - recording_date: string; - location_id: number; - coop_id: number; - feed_data: { - feed_id: string; - feed_qty: number; - feed_stock: number; +export type Recording = BaseMetadata & + BaseRecording & { + project_flock_category?: 'GROWING' | 'LAYING'; + approval?: BaseApproval; + egg_grading_status?: string | null; + egg_grading_pending_qty?: number | null; + egg_grading_completed_qty?: number | null; + body_weights?: RecordingBW[]; + depletions?: RecordingDepletion[]; + stocks?: RecordingStock[]; + eggs?: RecordingEgg[]; + recording_bws?: RecordingBW[]; + recording_depletions?: RecordingDepletion[]; + recording_stocks?: RecordingStock[]; + recording_eggs?: RecordingEgg[]; + grading_eggs?: GradingEgg[]; + }; + +export type NextDayRecording = { + project_flock_kandang_id: number; + next_day: number; +}; + +export type CreateGrowingRecordingPayload = { + project_flock_kandang_id: number; + body_weights: { + avg_weight: number; + qty: number; }[]; - body_weight: { - chicken_weight: number; - chicken_count: number; - average_chicken_weight: number; + stocks?: { + product_warehouse_id: number; + qty: number; }[]; - vaccination: { - vaccine_id: string; - total_stock: number; - used_stock: number; - }[]; - mortality: { - condition: string; - count: number; + depletions?: { + product_warehouse_id: number; + qty: number; }[]; }; +export type CreateGradingPayload = { + eggs_grading: { + recording_egg_id: number; + grade: string; + qty: number; + }[]; +}; + +export type UpdateGradingPayload = CreateGradingPayload; + +export type CreateGradingRecordingPayload = { + eggs_grading: { + recording_egg_id: number; + grade: string; + qty: number; + }[]; +}; + +export type CreateEggPayload = { + product_warehouse_id: number; + qty: number; +}; + +export type CreateLayingRecordingPayload = CreateGrowingRecordingPayload & { + eggs?: CreateEggPayload[]; +}; + +export type CreateRecordingPayload = + | CreateGrowingRecordingPayload + | CreateLayingRecordingPayload + | CreateGradingRecordingPayload; + +export type UpdateGrowingRecordingPayload = CreateGrowingRecordingPayload; +export type UpdateLayingRecordingPayload = CreateLayingRecordingPayload; +export type UpdateGradingRecordingPayload = CreateGradingRecordingPayload; + export type UpdateRecordingPayload = CreateRecordingPayload;