Merge branch 'hotfix/adjustment-recording-fifo-stock' into 'development'

[HOTFIX/FE] Adjustment FIFO Stock V2 (Stock Related Usage)

See merge request mbugroup/lti-web-client!351
This commit is contained in:
Rivaldi A N S
2026-03-17 04:38:42 +00:00
10 changed files with 1171 additions and 383 deletions
@@ -8,7 +8,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR, { mutate } from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table'; import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -26,6 +26,10 @@ import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { WarehouseApi, ProductApi } from '@/services/api/master-data'; import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import toast from 'react-hot-toast';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant'; import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
@@ -38,6 +42,62 @@ import {
AdjustmentFilterType, AdjustmentFilterType,
} from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter'; } from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import { CellContext } from '@tanstack/react-table';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<InventoryAdjustment, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `adjustment#${props.row.original.id}`;
const popoverAnchorName = `--anchor-adjustment#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const InventoryAdjustmentTable = () => { const InventoryAdjustmentTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore(); const { searchValue, setSearchValue, setTableState } = useUiStore();
@@ -182,12 +242,39 @@ const InventoryAdjustmentTable = () => {
formik.validateForm(); formik.validateForm();
}; };
const { data: inventoryAdjustments, isLoading } = useSWR( const {
data: inventoryAdjustments,
isLoading,
mutate: refreshAdjustments,
} = useSWR(
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, `${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher InventoryAdjustmentApi.getAllFetcher
); );
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
const response = await InventoryAdjustmentApi.delete(
selectedAdjustment?.id as number
);
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Adjustment!');
refreshAdjustments();
} else {
toast.error(response?.message || 'Failed to delete Adjustment');
}
};
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedAdjustment, setSelectedAdjustment] = useState<
InventoryAdjustment | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
useEffect(() => { useEffect(() => {
updateFilter('search', searchValue); updateFilter('search', searchValue);
@@ -302,8 +389,39 @@ const InventoryAdjustmentTable = () => {
header: 'Oleh', header: 'Oleh',
accessorFn: (row) => row.created_user?.name ?? '-', accessorFn: (row) => row.created_user?.name ?? '-',
}, },
{
id: 'actions',
header: 'Aksi',
cell: (props: CellContext<InventoryAdjustment, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedAdjustment(props.row.original);
singleDeleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
], ],
[tableFilterState.pageSize, tableFilterState.page] [
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedAdjustment,
]
); );
const updateSortingFilter = useCallback( const updateSortingFilter = useCallback(
@@ -532,6 +650,21 @@ const InventoryAdjustmentTable = () => {
</div> </div>
</form> </form>
</Modal> </Modal>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Adjustment ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</> </>
); );
}; };
@@ -8,7 +8,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR, { mutate } from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -21,6 +21,8 @@ import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import Button from '@/components/Button'; import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput'; import SelectInput, { useSelect } from '@/components/input/SelectInput';
@@ -41,9 +43,11 @@ import {
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
props, props,
deleteClickHandler,
}: { }: {
popoverPosition: 'bottom' | 'top'; popoverPosition: 'bottom' | 'top';
props: CellContext<Movement, unknown>; props: CellContext<Movement, unknown>;
deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `movement#${props.row.original.id}`; const popoverId = `movement#${props.row.original.id}`;
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`; const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
@@ -83,6 +87,20 @@ const RowOptionsMenu = ({
Detail Detail
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission permissions='lti.inventory.transfer.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div> </div>
</PopoverContent> </PopoverContent>
</div> </div>
@@ -206,12 +224,37 @@ const MovementTable = () => {
}; };
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
const { data: movements, isLoading } = useSWR( const {
data: movements,
isLoading,
mutate: refreshMovements,
} = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`, `${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher MovementApi.getAllFetcher
); );
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
const response = await MovementApi.delete(selectedMovement?.id as number);
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Movement!');
refreshMovements();
} else {
toast.error(response?.message || 'Failed to delete Movement');
}
};
useEffect(() => { useEffect(() => {
updateFilter('search', searchValue); updateFilter('search', searchValue);
}, [searchValue, updateFilter]); }, [searchValue, updateFilter]);
@@ -275,16 +318,27 @@ const MovementTable = () => {
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedMovement(props.row.original);
singleDeleteModal.openModal();
};
return ( return (
<RowOptionsMenu <RowOptionsMenu
props={props} props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'} popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/> />
); );
}, },
}, },
], ],
[tableFilterState.pageSize, tableFilterState.page] [
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedMovement,
]
); );
return ( return (
@@ -455,6 +509,21 @@ const MovementTable = () => {
</div> </div>
</form> </form>
</Modal> </Modal>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</> </>
); );
}; };
@@ -82,6 +82,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: number; warehouse_id: number;
warehouse_name: string; warehouse_name: string;
quantity: number; quantity: number;
transfer_available_qty?: number;
} }
// ===== USE SELECT HOOKS ===== // ===== USE SELECT HOOKS =====
@@ -379,6 +380,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: formik.values.source_warehouse_id warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString() ? formik.values.source_warehouse_id.toString()
: '', : '',
transfer_context: 'inventory_transfer',
stock_mode: 'exclude_chickin',
} }
); );
@@ -391,6 +394,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: pw.warehouse.id, warehouse_id: pw.warehouse.id,
warehouse_name: pw.warehouse.name, warehouse_name: pw.warehouse.name,
quantity: pw.quantity, quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
})) }))
: []; : [];
}, [productWarehouses]); }, [productWarehouses]);
@@ -834,6 +838,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, [formik.values.products, formik.values.deliveries]); }, [formik.values.products, formik.values.deliveries]);
const getAvailableStock = useCallback( const getAvailableStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return (
productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
},
[productWarehouseOptions, type]
);
const getTotalStock = useCallback(
(productId: number) => { (productId: number) => {
if (type === 'detail') return 0; if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
@@ -844,6 +864,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[productWarehouseOptions, type] [productWarehouseOptions, type]
); );
const hasAvailableQty = useCallback(
(productId: number) => {
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.transfer_available_qty !== undefined;
},
[productWarehouseOptions]
);
const getProductQtyBottomLabel = useCallback( const getProductQtyBottomLabel = useCallback(
(productIdx: number) => { (productIdx: number) => {
if (type === 'detail') return undefined; if (type === 'detail') return undefined;
@@ -851,16 +881,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return undefined; if (!product || !product.product_id) return undefined;
const availableStock = getAvailableStock(product.product_id); const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0; const requestedQty = Number(product.product_qty) || 0;
const remainingStock = availableStock - requestedQty; const remainingStock = availableStock - requestedQty;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > 0) { if (requestedQty > 0) {
if (isAyamProduct) {
return `Sisa: ${formatNumber(remainingStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Sisa: ${formatNumber(remainingStock)}`; return `Sisa: ${formatNumber(remainingStock)}`;
} }
if (isAyamProduct) {
return `Tersedia: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Tersedia: ${formatNumber(availableStock)}`; return `Tersedia: ${formatNumber(availableStock)}`;
}, },
[formik.values.products, getAvailableStock, type] [
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
); );
const getDeliveryProductQtyBottomLabel = useCallback( const getDeliveryProductQtyBottomLabel = useCallback(
@@ -922,15 +967,26 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return null; if (!product || !product.product_id) return null;
const availableStock = getAvailableStock(product.product_id); const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0; const requestedQty = Number(product.product_qty) || 0;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > availableStock) { if (requestedQty > availableStock) {
if (isAyamProduct) {
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)}, terpakai untuk chickin: ${formatNumber(totalStock - availableStock)})`;
}
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`; return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`;
} }
return null; return null;
}, },
[formik.values.products, getAvailableStock, type] [
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
); );
const validateDeliveryQty = useCallback( const validateDeliveryQty = useCallback(
@@ -21,6 +21,7 @@ import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; import PopoverContent from '@/components/popover/PopoverContent';
import Tooltip from '@/components/Tooltip';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { AreaApi } from '@/services/api/master-data'; import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
@@ -36,6 +37,7 @@ import {
import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton'; import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording'; import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production'; import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -105,30 +107,75 @@ const RowOptionsMenu = ({
}; };
const isRecordingEditable = (recording: Recording) => { const isRecordingEditable = (recording: Recording) => {
if ( const isGrowingCategory =
recording.executed_at && recording.project_flock?.project_flock_category === 'GROWING';
recording.project_flock?.project_flock_category === 'GROWING' const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
) { if (isGrowingLockedByLaying) {
return false;
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
const restriction = getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
if (restriction.isLocked) {
return false; return false;
} }
return true; return true;
}; };
const getRecordingRestrictionInfo = (recording: Recording) => {
const isGrowingCategory =
recording.project_flock?.project_flock_category === 'GROWING';
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
if (isGrowingLockedByLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing tidak dapat diubah karena sudah masuk fase laying dan dipakai pada recording laying',
};
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
return getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
};
const isApproved = isRecordingApproved(props.row.original); const isApproved = isRecordingApproved(props.row.original);
const isRejected = isRecordingRejected(props.row.original); const isRejected = isRecordingRejected(props.row.original);
const isEditable = isRecordingEditable(props.row.original); const isEditable = isRecordingEditable(props.row.original);
const restrictionInfo = getRecordingRestrictionInfo(props.row.original);
return ( return (
<div className='relative'> <div className='relative'>
<Tooltip
content={restrictionInfo.isLocked ? restrictionInfo.lockReason : ''}
position='top'
>
<PopoverButton <PopoverButton
tabIndex={0} tabIndex={0}
variant='ghost' variant='ghost'
color='none' color='none'
popoverTarget={popoverId} popoverTarget={popoverId}
anchorName={popoverAnchorName} anchorName={popoverAnchorName}
className={restrictionInfo.isLocked ? 'text-error' : ''}
> >
<Icon icon='material-symbols:more-vert' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton> </PopoverButton>
</Tooltip>
<PopoverContent <PopoverContent
id={popoverId} id={popoverId}
@@ -560,12 +607,17 @@ const RecordingTable = () => {
const singleDeleteHandler = async () => { const singleDeleteHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await RecordingApi.delete(selectedRecording?.id as number); const response = await RecordingApi.delete(selectedRecording?.id as number);
refreshRecordings();
singleDeleteModal.closeModal(); singleDeleteModal.closeModal();
toast.success('Successfully delete Recording!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Recording!');
refreshRecordings();
} else {
toast.error(response?.message || 'Failed to delete Recording');
}
}; };
const approveHandler = async (notes: string) => { const approveHandler = async (notes: string) => {
@@ -761,11 +813,30 @@ const RecordingTable = () => {
{ {
header: 'Kategori', header: 'Kategori',
cell: (props) => { cell: (props) => {
const isTransition = props.row.original.is_transition;
const category = const category =
props.row.original.project_flock?.project_flock_category; props.row.original.project_flock?.project_flock_category ||
if (!category) return '-'; 'GROWING';
const color = category === 'LAYING' ? 'info' : 'warning'; const color = category === 'LAYING' ? 'info' : 'warning';
return <StatusBadge color={color} text={formatTitleCase(category)} />;
const isGrowingLocked =
category === 'GROWING' && props.row.original.is_laying;
return (
<div className='flex flex-col gap-1'>
<StatusBadge color={color} text={formatTitleCase(category)} />
{isTransition && (
<span className='text-xs text-warning font-medium'>
(Transisi)
</span>
)}
{isGrowingLocked && (
<span className='text-xs text-error font-medium'>
(Penguncian)
</span>
)}
</div>
);
}, },
}, },
{ {
@@ -70,7 +70,7 @@ import {
} from '@/components/pages/production/recording/form/RecordingForm.schema'; } from '@/components/pages/production/recording/form/RecordingForm.schema';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber, cn } from '@/lib/helper';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import ApprovalSteps, { import ApprovalSteps, {
useApprovalSteps, useApprovalSteps,
@@ -79,7 +79,9 @@ import {
GROWING_RECORDING_APPROVAL_LINE, GROWING_RECORDING_APPROVAL_LINE,
LAYING_RECORDING_APPROVAL_LINE, LAYING_RECORDING_APPROVAL_LINE,
} from '@/config/approval-line'; } from '@/config/approval-line';
import { PROJECT_FLOCK_STATUS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { getRecordingRestriction } from '../recording-utils';
interface RecordingFormProps { interface RecordingFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -242,6 +244,23 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const [isProductionStandardModalOpen, setIsProductionStandardModalOpen] = const [isProductionStandardModalOpen, setIsProductionStandardModalOpen] =
useState(false); useState(false);
const calculateWeek = useCallback(
(day: number): number => {
if (
productionStandards?.details &&
productionStandards.details.length > 0
) {
const firstWeek = productionStandards.details[0].week;
const weekOffset = Math.ceil(day / 7) - 1;
return firstWeek + weekOffset;
}
return Math.ceil(day / 7);
},
[productionStandards]
);
useEffect(() => { useEffect(() => {
const checkProductionStandardModalOpen = () => { const checkProductionStandardModalOpen = () => {
const isOpen = productionStandardModal.ref.current?.open || false; const isOpen = productionStandardModal.ref.current?.open || false;
@@ -272,73 +291,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return recording?.approval?.action === 'REJECTED'; return recording?.approval?.action === 'REJECTED';
}, []); }, []);
const isRecordingEditable = useCallback((recording?: Recording) => {
if (
recording?.executed_at &&
recording?.project_flock?.project_flock_category === 'GROWING'
) {
return false;
}
return true;
}, []);
// ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => {
const depletions = values.depletions
?.filter((d) => d.product_warehouse_id && d.qty)
.map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id!,
qty: Number(depletion.qty) || 0,
}));
return {
project_flock_kandang_id: values.project_flock_kandang_id,
record_date: values.record_date,
stocks: (values.stocks ?? []).map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
qty: Number(stock.qty) || 0,
})),
...(depletions && depletions.length > 0 && { depletions }),
};
},
[]
);
const createLayingPayload = useCallback(
(values: RecordingLayingFormValues) => {
const depletions = values.depletions
?.filter((d) => d.product_warehouse_id && d.qty)
.map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id!,
qty: Number(depletion.qty) || 0,
}));
const eggs = values.eggs
?.filter((e) => e.product_warehouse_id && e.qty && e.weight)
.map((egg) => ({
product_warehouse_id: egg.product_warehouse_id!,
qty: Number(egg.qty) || 0,
weight:
typeof egg.weight === 'number'
? egg.weight
: parseFloat(String(egg.weight)) || 0,
}));
return {
project_flock_kandang_id: values.project_flock_kandang_id,
record_date: values.record_date,
stocks: values.stocks.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
qty: Number(stock.qty) || 0,
})),
...(depletions && depletions.length > 0 && { depletions }),
...(eggs && eggs.length > 0 && { eggs }),
};
},
[]
);
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const createRecordingHandler = useCallback( const createRecordingHandler = useCallback(
async ( async (
@@ -380,11 +332,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (!initialValues?.id) return; if (!initialValues?.id) return;
setIsDeleteLoading(true); setIsDeleteLoading(true);
await RecordingApi.delete(initialValues.id); const response = await RecordingApi.delete(initialValues.id);
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Recording!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Recording!');
router.push('/production/recording'); router.push('/production/recording');
} else {
toast.error(response?.message || 'Failed to delete Recording');
}
}, [deleteModal, initialValues?.id, router]); }, [deleteModal, initialValues?.id, router]);
// ===== API DATA FETCHING ===== // ===== API DATA FETCHING =====
@@ -403,16 +361,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
loadMore: loadMoreProjectFlocks, loadMore: loadMoreProjectFlocks,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', { } = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
location_id: selectedProjectFlockLocationId, location_id: selectedProjectFlockLocationId,
status: PROJECT_FLOCK_STATUS.AKTIF,
}); });
const projectFlockKandangLookupUrl = useMemo(() => { const projectFlockKandangLookupUrl = useMemo(() => {
if (!selectedProjectFlock || !selectedKandang) return null; if (!selectedProjectFlock || !selectedKandang || !selectedRecordDate)
return null;
const params = new URLSearchParams({ const params = new URLSearchParams({
project_flock_id: selectedProjectFlock.value.toString(), project_flock_id: selectedProjectFlock.value.toString(),
kandang_id: selectedKandang.value.toString(), kandang_id: selectedKandang.value.toString(),
record_date: selectedRecordDate,
}); });
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
}, [selectedProjectFlock, selectedKandang]); }, [selectedProjectFlock, selectedKandang, selectedRecordDate]);
const { data: projectFlockKandangLookupData } = useSWR( const { data: projectFlockKandangLookupData } = useSWR(
projectFlockKandangLookupUrl, projectFlockKandangLookupUrl,
@@ -444,13 +405,24 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
() => ProductionStandardApi.getSingle(productionStandardId!) () => ProductionStandardApi.getSingle(productionStandardId!)
); );
const { data: productionStandardForAdd } = useSWR(
type === 'add' && productionStandardId
? `production-standard-add-${productionStandardId}`
: null,
() => ProductionStandardApi.getSingle(productionStandardId!)
);
useEffect(() => { useEffect(() => {
if (productionStandard?.status === 'success') { if (productionStandard?.status === 'success') {
setProductionStandards( setProductionStandards(
productionStandard.data as ProductionStandard | null productionStandard.data as ProductionStandard | null
); );
} else if (productionStandardForAdd?.status === 'success') {
setProductionStandards(
productionStandardForAdd.data as ProductionStandard | null
);
} }
}, [productionStandard]); }, [productionStandard, productionStandardForAdd]);
const projectFlockKandangDetailUrl = useMemo(() => { const projectFlockKandangDetailUrl = useMemo(() => {
if ( if (
@@ -476,6 +448,159 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
? projectFlockKandangDetailData.data ? projectFlockKandangDetailData.data
: undefined; : undefined;
const selectedProjectFlockKandangId = useMemo(() => {
if (type === 'add') {
return projectFlockKandangLookup?.project_flock_kandang_id ?? null;
}
return (
projectFlockKandangDetail?.id ??
initialValues?.project_flock?.project_flock_kandang_id ??
null
);
}, [
type,
projectFlockKandangLookup,
projectFlockKandangDetail,
initialValues,
]);
// ===== TRANSITION RESTRICTION LOGIC =====
const isTransitionPeriod = useMemo(() => {
return (
initialValues?.is_transition ??
projectFlockKandangLookup?.is_transition ??
false
);
}, [initialValues, projectFlockKandangLookup]);
const recordingRestriction = useMemo(() => {
let isLaying: boolean;
if (initialValues?.is_laying !== undefined) {
isLaying = initialValues.is_laying;
} else if (projectFlockKandangLookup?.is_laying !== undefined) {
isLaying = projectFlockKandangLookup.is_laying;
} else {
isLaying =
projectFlockKandangDetail?.project_flock?.category === 'LAYING' ||
false;
}
const isTransition =
initialValues?.is_transition ??
projectFlockKandangLookup?.is_transition ??
false;
const currentIsLaying =
type === 'edit'
? projectFlockKandangDetail?.project_flock?.category === 'LAYING'
: projectFlockKandangLookup?.project_flock?.category === 'LAYING';
return getRecordingRestriction(isLaying, isTransition, currentIsLaying);
}, [
initialValues,
projectFlockKandangLookup,
projectFlockKandangDetail,
type,
]);
// ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => {
const depletions = recordingRestriction.canEditDepletion
? values.depletions
?.filter((d) => d.product_warehouse_id && d.qty)
.map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id!,
qty: Number(depletion.qty) || 0,
}))
: [];
const stocks = recordingRestriction.canEditStock
? (values.stocks ?? [])
.filter((s) => s.product_warehouse_id && s.qty)
.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
qty: Number(stock.qty) || 0,
}))
: [];
return {
project_flock_kandang_id: values.project_flock_kandang_id,
record_date: values.record_date,
...(stocks.length > 0 && { stocks }),
...(depletions.length > 0 && { depletions }),
};
},
[recordingRestriction.canEditStock, recordingRestriction.canEditDepletion]
);
const createLayingPayload = useCallback(
(values: RecordingLayingFormValues) => {
const depletions = values.depletions
?.filter((d) => d.product_warehouse_id && d.qty)
.map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id!,
qty: Number(depletion.qty) || 0,
}));
const eggs = values.eggs
?.filter((e) => e.product_warehouse_id && e.qty && e.weight)
.map((egg) => ({
product_warehouse_id: egg.product_warehouse_id!,
qty: Number(egg.qty) || 0,
weight:
typeof egg.weight === 'number'
? egg.weight
: parseFloat(String(egg.weight)) || 0,
}));
const stocks = recordingRestriction.canEditStock
? values.stocks
.filter((s) => s.product_warehouse_id && s.qty)
.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
qty: Number(stock.qty) || 0,
}))
: [];
return {
project_flock_kandang_id: values.project_flock_kandang_id,
record_date: values.record_date,
...(stocks.length > 0 && { stocks }),
...(depletions && depletions.length > 0 && { depletions }),
...(eggs && eggs.length > 0 && { eggs }),
};
},
[recordingRestriction.canEditStock]
);
const isRecordingEditable = useCallback((recording?: Recording) => {
if (!recording) return true;
const isGrowingCategory =
recording.project_flock?.project_flock_category === 'GROWING';
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
if (isGrowingLockedByLaying) {
return false;
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
const restriction = getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
if (restriction.isLocked) {
return false;
}
return true;
}, []);
const { const {
options: stockProductOptions, options: stockProductOptions,
rawData: stockProducts, rawData: stockProducts,
@@ -581,15 +706,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return approvedProjectFlockKandangsData.data; return approvedProjectFlockKandangsData.data;
}, [approvedProjectFlockKandangsData]); }, [approvedProjectFlockKandangsData]);
const isLayingCategory = const isLayingCategory = useMemo(() => {
initialValues?.project_flock?.project_flock_category === 'LAYING' || // Priority 1: initialValues category (for edit/detail mode)
projectFlockKandangLookup?.project_flock?.category === 'LAYING' || if (initialValues?.project_flock?.project_flock_category !== undefined) {
projectFlockKandangDetail?.project_flock?.category === 'LAYING'; return initialValues.project_flock.project_flock_category === 'LAYING';
}
const isGrowingCategory = // Priority 2: projectFlockKandangLookup category (for add mode)
initialValues?.project_flock?.project_flock_category === 'GROWING' || if (projectFlockKandangLookup?.project_flock?.category !== undefined) {
projectFlockKandangLookup?.project_flock?.category === 'GROWING' || return projectFlockKandangLookup.project_flock.category === 'LAYING';
projectFlockKandangDetail?.project_flock?.category === 'GROWING'; }
// Priority 3: projectFlockKandangDetail (fallback for edit/detail mode)
return (
projectFlockKandangDetail?.project_flock?.category === 'LAYING' || false
);
}, [
initialValues?.project_flock?.project_flock_category,
projectFlockKandangLookup,
projectFlockKandangDetail,
]);
const isGrowingCategory = !isLayingCategory;
const recordingApprovalLines = useMemo(() => { const recordingApprovalLines = useMemo(() => {
if (isLayingCategory) { if (isLayingCategory) {
@@ -637,8 +775,36 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return options; return options;
}, [locationOptions, projectFlockKandangDetail, type]); }, [locationOptions, projectFlockKandangDetail, type]);
const isProjectFlockActive = useCallback((projectFlock: ProjectFlock) => {
const approvalStepName = projectFlock.approval?.step_name
?.trim()
.toLowerCase();
if (approvalStepName) {
return approvalStepName === PROJECT_FLOCK_STATUS.AKTIF.toLowerCase();
}
return (
projectFlock.status?.trim().toLowerCase() ===
PROJECT_FLOCK_STATUS.AKTIF.toLowerCase()
);
}, []);
const activeProjectFlockIDs = useMemo(() => {
if (!isResponseSuccess(projectFlocksRawData)) return new Set<number>();
const data = projectFlocksRawData.data as ProjectFlock[];
return new Set(
data
.filter((projectFlock) => isProjectFlockActive(projectFlock))
.map((projectFlock) => projectFlock.id)
);
}, [projectFlocksRawData, isProjectFlockActive]);
const enhancedProjectFlockOptions = useMemo(() => { const enhancedProjectFlockOptions = useMemo(() => {
const options = [...projectFlockOptions]; const options = projectFlockOptions.filter((option) => {
if (type !== 'add') return true;
return activeProjectFlockIDs.has(Number(option.value));
});
if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) { if (projectFlockKandangDetail && (type === 'edit' || type === 'detail')) {
const currentProjectFlock = projectFlockKandangDetail.project_flock; const currentProjectFlock = projectFlockKandangDetail.project_flock;
@@ -654,7 +820,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
} }
return options; return options;
}, [projectFlockOptions, projectFlockKandangDetail, type]); }, [
projectFlockOptions,
projectFlockKandangDetail,
type,
activeProjectFlockIDs,
]);
const kandangOptions = useMemo(() => { const kandangOptions = useMemo(() => {
let options: OptionType[] = []; let options: OptionType[] = [];
@@ -762,8 +933,41 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
projectFlockKandangDetail, projectFlockKandangDetail,
]); ]);
const isProductWarehouseBelongsToSelectedProjectFlockKandang = useCallback(
(productWarehouse: ProductWarehouse) => {
if (!selectedProjectFlockKandangId) return false;
return (
productWarehouse.project_flock_kandang?.id ===
selectedProjectFlockKandangId
);
},
[selectedProjectFlockKandangId]
);
const scopedStockProductIds = useMemo(() => {
if (!isResponseSuccess(stockProducts) || !selectedProjectFlockKandangId) {
return new Set<number>();
}
const data = stockProducts.data as unknown as ProductWarehouse[];
return new Set(
data
.filter(isProductWarehouseBelongsToSelectedProjectFlockKandang)
.map((product) => product.id)
);
}, [
stockProducts,
selectedProjectFlockKandangId,
isProductWarehouseBelongsToSelectedProjectFlockKandang,
]);
const unifiedStockProducts = useMemo(() => { const unifiedStockProducts = useMemo(() => {
const options = [...stockProductOptions]; const options = selectedProjectFlockKandangId
? stockProductOptions.filter((option) =>
scopedStockProductIds.has(Number(option.value))
)
: [];
if ( if (
initialValues && initialValues &&
@@ -787,14 +991,25 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
} }
return options; return options;
}, [stockProductOptions, initialValues, type]); }, [
stockProductOptions,
initialValues,
type,
selectedProjectFlockKandangId,
scopedStockProductIds,
]);
const depletionProducts = useMemo(() => { const depletionProducts = useMemo(() => {
const options: OptionType[] = []; const options: OptionType[] = [];
if (isResponseSuccess(depletionProductsData) && selectedKandang) { if (
isResponseSuccess(depletionProductsData) &&
selectedProjectFlockKandangId
) {
const data = depletionProductsData.data as unknown as ProductWarehouse[]; const data = depletionProductsData.data as unknown as ProductWarehouse[];
data.forEach((product) => { data
.filter(isProductWarehouseBelongsToSelectedProjectFlockKandang)
.forEach((product) => {
options.push({ options.push({
value: product.id, value: product.id,
label: product.product.name, label: product.product.name,
@@ -822,14 +1037,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
} }
return options; return options;
}, [depletionProductsData, initialValues, type, selectedKandang]); }, [
depletionProductsData,
initialValues,
type,
selectedProjectFlockKandangId,
isProductWarehouseBelongsToSelectedProjectFlockKandang,
]);
const eggProducts = useMemo(() => { const eggProducts = useMemo(() => {
const options: OptionType[] = []; const options: OptionType[] = [];
if (isResponseSuccess(eggProductsData) && selectedKandang) { if (isResponseSuccess(eggProductsData) && selectedProjectFlockKandangId) {
const data = eggProductsData.data as unknown as ProductWarehouse[]; const data = eggProductsData.data as unknown as ProductWarehouse[];
data.forEach((product) => { data
.filter(isProductWarehouseBelongsToSelectedProjectFlockKandang)
.forEach((product) => {
options.push({ options.push({
value: product.id, value: product.id,
label: product.product.name, label: product.product.name,
@@ -854,7 +1077,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
} }
return options; return options;
}, [eggProductsData, initialValues, type, selectedKandang]); }, [
eggProductsData,
initialValues,
type,
selectedProjectFlockKandangId,
isProductWarehouseBelongsToSelectedProjectFlockKandang,
]);
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formikInitialValues = useMemo(() => { const formikInitialValues = useMemo(() => {
@@ -924,6 +1153,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}; };
} }
if (!recordingRestriction.canEditStock) {
baseValues.stocks = [];
}
if (!recordingRestriction.canEditDepletion) {
baseValues.depletions = [];
}
return baseValues; return baseValues;
}, [ }, [
initialValues, initialValues,
@@ -934,6 +1171,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
selectedLocation, selectedLocation,
selectedProjectFlock, selectedProjectFlock,
selectedKandang, selectedKandang,
recordingRestriction.canEditStock,
recordingRestriction.canEditDepletion,
]); ]);
const formik = useFormik< const formik = useFormik<
@@ -954,7 +1193,36 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
? UpdateRecordingGrowingFormSchema ? UpdateRecordingGrowingFormSchema
: RecordingGrowingFormSchema; : RecordingGrowingFormSchema;
} }
return schema.clone().concat(
if (!recordingRestriction.canEditStock) {
schema = schema.shape({
stocks: Yup.array()
.of(
Yup.object({
product_warehouse_id: Yup.number().optional(),
qty: Yup.number().optional(),
})
)
.optional()
.default([]) as unknown as Yup.Schema<unknown>,
});
}
if (!recordingRestriction.canEditDepletion) {
schema = schema.shape({
depletions: Yup.array()
.of(
Yup.object({
product_warehouse_id: Yup.number().optional(),
qty: Yup.number().optional(),
})
)
.optional()
.default([]) as unknown as Yup.Schema<unknown>,
});
}
return schema.concat(
Yup.object().shape({ Yup.object().shape({
project_flock_kandang_id: Yup.number().test( project_flock_kandang_id: Yup.number().test(
'not-already-recorded', 'not-already-recorded',
@@ -1299,6 +1567,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedLocation(location); setSelectedLocation(location);
setSelectedProjectFlock(null); setSelectedProjectFlock(null);
setSelectedKandang(null); setSelectedKandang(null);
setProductionStandards(null);
setNextDayRecording(null);
if (duplicateErrorShown) { if (duplicateErrorShown) {
toast.dismiss(); toast.dismiss();
setDuplicateErrorShown(false); setDuplicateErrorShown(false);
@@ -1323,6 +1593,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedProjectFlock(projectFlock); setSelectedProjectFlock(projectFlock);
setSelectedKandang(null); setSelectedKandang(null);
setProductionStandards(null);
setNextDayRecording(null);
if (duplicateErrorShown) { if (duplicateErrorShown) {
toast.dismiss(); toast.dismiss();
setDuplicateErrorShown(false); setDuplicateErrorShown(false);
@@ -1343,6 +1615,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldValue('kandang_id', kandangId); formik.setFieldValue('kandang_id', kandangId);
setSelectedKandang(kandang); setSelectedKandang(kandang);
setProductionStandards(null);
setNextDayRecording(null);
if (duplicateErrorShown) { if (duplicateErrorShown) {
toast.dismiss(); toast.dismiss();
setDuplicateErrorShown(false); setDuplicateErrorShown(false);
@@ -1497,7 +1771,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, [ }, [
projectFlockKandangDetail, projectFlockKandangDetail,
type, type,
enhancedProjectFlockOptions,
formik.values.project_flock_kandang_id, formik.values.project_flock_kandang_id,
setFieldValue, setFieldValue,
]); ]);
@@ -1879,10 +2152,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<p className='font-semibold'> <p className='font-semibold'>
{type === 'add' {type === 'add'
? nextDayRecording ? nextDayRecording
? `Hari ke-${nextDayRecording.next_day} (Minggu ke-${Math.ceil(nextDayRecording.next_day / 7)})` ? `Hari ke-${nextDayRecording.next_day} (Minggu ke-${calculateWeek(nextDayRecording.next_day)})`
: '-' : '-'
: initialValues?.day : initialValues?.day
? `Hari ke-${initialValues.day} (Minggu ke-${Math.ceil(initialValues.day / 7)})` ? `Hari ke-${initialValues.day} (Minggu ke-${calculateWeek(initialValues.day)})`
: '-'} : '-'}
</p> </p>
</div> </div>
@@ -1956,18 +2229,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<div> <div>
<span className='text-sm text-gray-600'>Kategori</span> <span className='text-sm text-gray-600'>Kategori</span>
<p className='font-semibold'> <p className='font-semibold'>
<Badge {(() => {
variant='soft' const category =
color={ initialValues.project_flock?.project_flock_category ||
initialValues.project_flock 'GROWING';
?.project_flock_category === 'LAYING' const color =
? 'info' category === 'LAYING' ? 'info' : 'warning';
: 'warning' return (
} <Badge variant='soft' color={color} size='sm'>
size='sm' {category}
>
{initialValues.project_flock?.project_flock_category}
</Badge> </Badge>
);
})()}
</p> </p>
</div> </div>
<div> <div>
@@ -2103,9 +2376,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{type === 'detail' && initialValues && ( {type === 'detail' && initialValues && (
<div <div
className={`grid gap-6 mb-6 grid-cols-1 ${ className={`grid gap-6 mb-6 grid-cols-1 ${
initialValues.project_flock?.project_flock_category === 'LAYING' initialValues.is_laying ? 'xl:grid-cols-3' : 'xl:grid-cols-2'
? 'xl:grid-cols-3'
: 'xl:grid-cols-2'
}`} }`}
> >
{/* FCR Section */} {/* FCR Section */}
@@ -2196,8 +2467,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{/* Egg Production Section - Only for LAYING category */} {/* Egg Production Section - Only for LAYING category */}
{type === 'detail' && {type === 'detail' &&
initialValues && initialValues &&
initialValues.project_flock?.project_flock_category === initialValues.is_laying && (
'LAYING' && (
<div className='border border-gray-200 rounded-lg bg-white'> <div className='border border-gray-200 rounded-lg bg-white'>
<div className='px-4 py-3 border-b border-gray-200'> <div className='px-4 py-3 border-b border-gray-200'>
<span className='card-title font-bold text-xl'> <span className='card-title font-bold text-xl'>
@@ -2292,7 +2562,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div> </div>
)} )}
{/* Stocks Table */} {/* Stocks Table - Only show if can edit stock or has data */}
{(recordingRestriction.canEditStock ||
(type === 'detail' && formik.values.stocks?.length > 0)) && (
<Card <Card
title='Stok Persediaan' title='Stok Persediaan'
className={{ className={{
@@ -2318,12 +2590,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
) => { ) => {
if (e.target.checked) { if (e.target.checked) {
setSelectedStocks( setSelectedStocks(
formik.values.stocks?.map((_, idx) => idx) ?? [] formik.values.stocks?.map((_, idx) => idx) ??
[]
); );
} else { } else {
setSelectedStocks([]); setSelectedStocks([]);
} }
}} }}
disabled={!recordingRestriction.canEditStock}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -2373,6 +2647,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
); );
} }
}} }}
disabled={!recordingRestriction.canEditStock}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -2391,7 +2666,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
) || null ) || null
} }
onChange={(selectedOption) => { onChange={(selectedOption) => {
const option = selectedOption as OptionType | null; const option =
selectedOption as OptionType | null;
formik.setFieldValue( formik.setFieldValue(
`stocks.${idx}.product_warehouse_id`, `stocks.${idx}.product_warehouse_id`,
option?.value || 0 option?.value || 0
@@ -2425,7 +2701,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
isSearchable isSearchable
isDisabled={ isDisabled={
type === 'detail' || type === 'detail' ||
!formik.values.project_flock_kandang_id !formik.values.project_flock_kandang_id ||
!recordingRestriction.canEditStock
} }
isClearable={type !== 'detail'} isClearable={type !== 'detail'}
inputPrefix={ inputPrefix={
@@ -2472,7 +2749,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
) )
: null : null
} }
disabled={type === 'detail'} disabled={
type === 'detail' ||
!recordingRestriction.canEditStock
}
/> />
{getStockUsageAdornment(idx)} {getStockUsageAdornment(idx)}
</div> </div>
@@ -2484,6 +2764,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
type='button' type='button'
color='error' color='error'
onClick={() => removeStock(idx)} onClick={() => removeStock(idx)}
disabled={!recordingRestriction.canEditStock}
> >
<Icon <Icon
icon='mdi:trash-can' icon='mdi:trash-can'
@@ -2501,7 +2782,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div> </div>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'> <div className='flex justify-center items-center mt-4 gap-4'>
{selectedStocks.length > 0 && ( {selectedStocks.length > 0 &&
recordingRestriction.canEditStock && (
<Button <Button
type='button' type='button'
color='error' color='error'
@@ -2513,18 +2795,60 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
Hapus Terpilih ({selectedStocks.length}) Hapus Terpilih ({selectedStocks.length})
</Button> </Button>
)} )}
<Tooltip
content={
!recordingRestriction.canEditStock
? 'Stock tidak dapat ditambahkan pada masa transisi Laying'
: ''
}
position='top'
>
<Button <Button
type='button' type='button'
color='success' color='success'
onClick={addStock} onClick={addStock}
className='w-fit' className='w-fit'
disabled={
!formik.values.project_flock_kandang_id ||
!recordingRestriction.canEditStock
}
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Stok Tambah Stok
</Button> </Button>
</Tooltip>
</div> </div>
)} )}
</Card> </Card>
)}
{/* Transition Warning Banner -- MOVED UP -- */}
{isTransitionPeriod && (
<div className='alert alert-warning mb-4'>
<Icon
icon='material-symbols:warning-outline'
width={24}
height={24}
/>
<span>
{isLayingCategory
? 'Masa Transisi Laying: Hanya Deplesi yang dapat diisi. Stock (Pakan/OVK) tidak dapat diinput.'
: 'Masa Transisi Growing: Hanya Stock (Pakan/OVK) yang dapat diisi. Deplesi tidak dapat diinput.'}
</span>
</div>
)}
{/* Locked Recording Warning */}
{recordingRestriction.isLocked && (
<div className='alert alert-error mb-4'>
<Icon
icon='material-symbols:lock-outline'
width={24}
height={24}
/>
<span>{recordingRestriction.lockReason}</span>
</div>
)}
{/* Depletions Table */} {/* Depletions Table */}
{((type as 'add' | 'edit' | 'detail') !== 'detail' || {((type as 'add' | 'edit' | 'detail') !== 'detail' ||
@@ -2532,7 +2856,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<Card <Card
title='Deplesi' title='Deplesi'
className={{ className={{
wrapper: 'w-full mb-4 shadow', wrapper: cn('w-full mb-4 shadow', {
'opacity-60':
!recordingRestriction.canEditDepletion &&
(type as 'add' | 'edit' | 'detail') !== 'detail',
}),
title: 'mb-4', title: 'mb-4',
}} }}
> >
@@ -2562,6 +2890,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedDepletions([]); setSelectedDepletions([]);
} }
}} }}
disabled={!recordingRestriction.canEditDepletion}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -2598,6 +2927,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
); );
} }
}} }}
disabled={!recordingRestriction.canEditDepletion}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -2623,7 +2953,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
); );
}} }}
options={getAvailableDepletionProductOptions(idx)} options={getAvailableDepletionProductOptions(idx)}
placeholder='Pilih Kondisi' placeholder={
!formik.values.project_flock_kandang_id
? 'Pilih kandang terlebih dahulu'
: 'Pilih Kondisi'
}
isLoading={isLoadingDepletionProducts} isLoading={isLoadingDepletionProducts}
onMenuScrollToBottom={loadMoreDepletionProducts} onMenuScrollToBottom={loadMoreDepletionProducts}
isError={ isError={
@@ -2640,7 +2974,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
idx idx
).errorMessage ).errorMessage
} }
isDisabled={type === 'detail'} isDisabled={
type === 'detail' ||
!formik.values.project_flock_kandang_id ||
!recordingRestriction.canEditDepletion
}
className={{ className={{
wrapper: 'w-full min-w-48', wrapper: 'w-full min-w-48',
}} }}
@@ -2679,7 +3017,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
) )
: null : null
} }
disabled={type === 'detail'} disabled={
type === 'detail' ||
!recordingRestriction.canEditDepletion
}
/> />
</td> </td>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
@@ -2689,6 +3030,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
type='button' type='button'
color='error' color='error'
onClick={() => removeDepletion(idx)} onClick={() => removeDepletion(idx)}
disabled={
!recordingRestriction.canEditDepletion
}
> >
<Icon <Icon
icon='mdi:trash-can' icon='mdi:trash-can'
@@ -2706,7 +3050,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div> </div>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'> <div className='flex justify-center items-center mt-4 gap-4'>
{selectedDepletions.length > 0 && ( {selectedDepletions.length > 0 &&
recordingRestriction.canEditDepletion && (
<Button <Button
type='button' type='button'
color='error' color='error'
@@ -2718,15 +3063,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
Hapus Terpilih ({selectedDepletions.length}) Hapus Terpilih ({selectedDepletions.length})
</Button> </Button>
)} )}
<Tooltip
content={
!recordingRestriction.canEditDepletion
? 'Deplesi tidak dapat ditambahkan pada masa transisi Growing'
: ''
}
position='top'
>
<Button <Button
type='button' type='button'
color='success' color='success'
onClick={addDepletion} onClick={addDepletion}
className='w-fit' className='w-fit'
disabled={
!formik.values.project_flock_kandang_id ||
!recordingRestriction.canEditDepletion
}
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Depletion Tambah Depletion
</Button> </Button>
</Tooltip>
</div> </div>
)} )}
</Card> </Card>
@@ -2847,7 +3205,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
); );
}} }}
options={getAvailableEggProductOptions(idx)} options={getAvailableEggProductOptions(idx)}
placeholder='Pilih Kondisi Telur' placeholder={
!formik.values.project_flock_kandang_id
? 'Pilih kandang terlebih dahulu'
: 'Pilih Kondisi Telur'
}
isLoading={isLoadingEggProducts} isLoading={isLoadingEggProducts}
onMenuScrollToBottom={loadMoreEggProducts} onMenuScrollToBottom={loadMoreEggProducts}
isError={ isError={
@@ -2864,7 +3226,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
idx idx
).errorMessage ).errorMessage
} }
isDisabled={type === 'detail'} isDisabled={
type === 'detail' ||
!formik.values.project_flock_kandang_id
}
className={{ className={{
wrapper: 'w-full min-w-48', wrapper: 'w-full min-w-48',
}} }}
@@ -2969,6 +3334,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
color='success' color='success'
onClick={addEgg} onClick={addEgg}
className='w-fit' className='w-fit'
disabled={!formik.values.project_flock_kandang_id}
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Telur Tambah Telur
@@ -0,0 +1,73 @@
export type RecordingRestriction = {
canEditStock: boolean;
canEditDepletion: boolean;
canEditEgg: boolean;
isLocked: boolean;
lockReason?: string;
};
export const getRecordingRestriction = (
isLaying: boolean,
isTransition: boolean,
currentIsLaying?: boolean
): RecordingRestriction => {
if (isTransition && !isLaying) {
const isLayingKandangInTransition = currentIsLaying === true;
if (isLayingKandangInTransition) {
return {
canEditStock: false,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
} else {
return {
canEditStock: true,
canEditDepletion: false,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
}
if (!isLaying && !isTransition && currentIsLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
};
}
if (!isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
if (isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
}
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason: 'Kondisi transisi tidak valid',
};
};
+6
View File
@@ -555,6 +555,12 @@ export const APPROVAL_WORKFLOWS = {
], ],
}; };
export const PROJECT_FLOCK_STATUS = {
PENGAJUAN: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[0].step_name,
AKTIF: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[1].step_name,
SELESAI: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[2].step_name,
} as const;
export const ACCEPTED_FILE_TYPE = { export const ACCEPTED_FILE_TYPE = {
PDF: { PDF: {
'application/pdf': ['.pdf'], 'application/pdf': ['.pdf'],
+11
View File
@@ -9,8 +9,19 @@ export type BaseProductWarehouse = {
warehouse_id: number; warehouse_id: number;
uom: Uom; uom: Uom;
quantity: number; quantity: number;
transfer_available_qty?: number;
product: Product; product: Product;
warehouse: Warehouse; warehouse: Warehouse;
project_flock_kandang?: {
id: number;
project_flock_id: number;
kandang_id: number;
period: number;
project_flock?: {
id: number;
flock_name: string;
};
};
week?: number | null; week?: number | null;
}; };
+2
View File
@@ -74,6 +74,8 @@ export type ProjectFlockKandangLookup = {
available_quantity?: number; available_quantity?: number;
population: number; population: number;
chick_in_date: string; chick_in_date: string;
is_transition: boolean;
is_laying: boolean;
}; };
export type ProjectFlockAvailableQuantity = { export type ProjectFlockAvailableQuantity = {
+2 -1
View File
@@ -49,7 +49,8 @@ export type BaseRecording = {
project_flock: ProjectFlock; project_flock: ProjectFlock;
record_datetime: string; record_datetime: string;
day: number; day: number;
executed_at: string; is_transition: boolean;
is_laying: boolean;
} & ProductionMetrics; } & ProductionMetrics;
export type RecordingDepletion = { export type RecordingDepletion = {