refactor(FE): Add transition restrictions for recording operations

This commit is contained in:
rstubryan
2026-03-09 09:04:14 +07:00
parent 3042b54577
commit cdf0442a2b
5 changed files with 279 additions and 62 deletions
@@ -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,57 @@ const RowOptionsMenu = ({
}; };
const isRecordingEditable = (recording: Recording) => { const isRecordingEditable = (recording: Recording) => {
if ( const category = recording.project_flock?.project_flock_category;
recording.executed_at && const isTransition = recording.is_transition;
recording.project_flock?.project_flock_category === 'GROWING'
) { const restriction = getRecordingRestriction(
category || 'GROWING',
isTransition
);
if (restriction.isLocked) {
return false; return false;
} }
return true; return true;
}; };
const getRecordingRestrictionInfo = (recording: Recording) => {
const category = recording.project_flock?.project_flock_category;
const isTransition = recording.is_transition;
return getRecordingRestriction(category || 'GROWING', isTransition);
};
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'>
<PopoverButton <Tooltip
tabIndex={0} content={restrictionInfo.isLocked ? restrictionInfo.lockReason : ''}
variant='ghost' position='top'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
> >
<Icon icon='material-symbols:more-vert' width={16} height={16} /> <PopoverButton
</PopoverButton> tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
className={restrictionInfo.isLocked ? 'text-error' : ''}
>
<Icon
icon={
restrictionInfo.isLocked
? 'material-symbols:lock-outline'
: 'material-symbols:more-vert'
}
width={16}
height={16}
/>
</PopoverButton>
</Tooltip>
<PopoverContent <PopoverContent
id={popoverId} id={popoverId}
@@ -763,9 +792,19 @@ const RecordingTable = () => {
cell: (props) => { cell: (props) => {
const category = const category =
props.row.original.project_flock?.project_flock_category; props.row.original.project_flock?.project_flock_category;
const isTransition = props.row.original.is_transition;
if (!category) return '-'; if (!category) return '-';
const color = category === 'LAYING' ? 'info' : 'warning'; const color = category === 'LAYING' ? 'info' : 'warning';
return <StatusBadge color={color} text={formatTitleCase(category)} />; 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>
)}
</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,
@@ -80,6 +80,7 @@ import {
LAYING_RECORDING_APPROVAL_LINE, LAYING_RECORDING_APPROVAL_LINE,
} from '@/config/approval-line'; } from '@/config/approval-line';
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';
@@ -272,16 +273,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 ===== // ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback( const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => { (values: RecordingGrowingFormValues) => {
@@ -476,6 +467,60 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
? projectFlockKandangDetailData.data ? projectFlockKandangDetailData.data
: undefined; : undefined;
// ===== TRANSITION RESTRICTION LOGIC =====
const isTransitionPeriod = useMemo(() => {
return initialValues?.is_transition ?? false;
}, [initialValues]);
const recordingRestriction = useMemo(() => {
const category =
initialValues?.project_flock?.project_flock_category ||
projectFlockKandangLookup?.project_flock?.category ||
projectFlockKandangDetail?.project_flock?.category ||
'GROWING';
const isTransition = initialValues?.is_transition ?? false;
const currentFlockCategory = projectFlockKandangDetail?.project_flock
?.category as 'GROWING' | 'LAYING' | undefined;
return getRecordingRestriction(
category as 'GROWING' | 'LAYING',
isTransition,
type === 'edit' ? currentFlockCategory : undefined
);
}, [
initialValues,
projectFlockKandangLookup,
projectFlockKandangDetail,
type,
]);
const isRecordingEditable = useCallback(
(recording?: Recording) => {
if (!recording) return true;
const category = recording.project_flock?.project_flock_category;
const isTransition = recording.is_transition;
const currentFlockCategory = projectFlockKandangDetail?.project_flock
?.category as 'GROWING' | 'LAYING' | undefined;
const restriction = getRecordingRestriction(
category || 'GROWING',
isTransition,
currentFlockCategory
);
if (restriction.isLocked) {
return false;
}
return true;
},
[projectFlockKandangDetail]
);
const { const {
options: stockProductOptions, options: stockProductOptions,
rawData: stockProducts, rawData: stockProducts,
@@ -2324,6 +2369,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
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 +2419,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',
@@ -2425,7 +2472,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 +2520,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 +2535,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,38 +2553,81 @@ 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
type='button'
color='error'
onClick={removeSelectedStocks}
disabled={selectedStocks.length === 0}
className='w-fit'
>
<Icon icon='mdi:trash-can' width={24} height={24} />
Hapus Terpilih ({selectedStocks.length})
</Button>
)}
<Tooltip
content={
!recordingRestriction.canEditStock
? 'Stock tidak dapat ditambahkan pada masa transisi Laying'
: ''
}
position='top'
>
<Button <Button
type='button' type='button'
color='error' color='success'
onClick={removeSelectedStocks} onClick={addStock}
disabled={selectedStocks.length === 0}
className='w-fit' className='w-fit'
disabled={!recordingRestriction.canEditStock}
> >
<Icon icon='mdi:trash-can' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Hapus Terpilih ({selectedStocks.length}) Tambah Stok
</Button> </Button>
)} </Tooltip>
<Button
type='button'
color='success'
onClick={addStock}
className='w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Stok
</Button>
</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' ||
(formik.values.depletions?.length ?? 0) > 0) && ( (formik.values.depletions?.length ?? 0) > 0) && (
<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 +2657,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 +2694,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',
@@ -2640,7 +2737,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
idx idx
).errorMessage ).errorMessage
} }
isDisabled={type === 'detail'} isDisabled={
type === 'detail' ||
!recordingRestriction.canEditDepletion
}
className={{ className={{
wrapper: 'w-full min-w-48', wrapper: 'w-full min-w-48',
}} }}
@@ -2679,7 +2779,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 +2792,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,27 +2812,38 @@ 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
type='button'
color='error'
onClick={removeSelectedDepletions}
disabled={selectedDepletions.length === 0}
className='w-fit'
>
<Icon icon='mdi:trash-can' width={24} height={24} />
Hapus Terpilih ({selectedDepletions.length})
</Button>
)}
<Tooltip
content={
!recordingRestriction.canEditDepletion
? 'Deplesi tidak dapat ditambahkan pada masa transisi Growing'
: ''
}
position='top'
>
<Button <Button
type='button' type='button'
color='error' color='success'
onClick={removeSelectedDepletions} onClick={addDepletion}
disabled={selectedDepletions.length === 0}
className='w-fit' className='w-fit'
disabled={!recordingRestriction.canEditDepletion}
> >
<Icon icon='mdi:trash-can' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Hapus Terpilih ({selectedDepletions.length}) Tambah Depletion
</Button> </Button>
)} </Tooltip>
<Button
type='button'
color='success'
onClick={addDepletion}
className='w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Depletion
</Button>
</div> </div>
)} )}
</Card> </Card>
@@ -0,0 +1,60 @@
export type RecordingRestriction = {
canEditStock: boolean;
canEditDepletion: boolean;
canEditEgg: boolean;
isLocked: boolean;
lockReason?: string;
};
export const getRecordingRestriction = (
category: 'GROWING' | 'LAYING',
isTransition: boolean,
currentCategory?: 'GROWING' | 'LAYING'
): RecordingRestriction => {
if (currentCategory === 'LAYING' && category === 'GROWING') {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
};
}
if (category === 'GROWING') {
if (isTransition) {
return {
canEditStock: true,
canEditDepletion: false,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
if (isTransition) {
return {
canEditStock: false,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
};
+1
View File
@@ -74,6 +74,7 @@ export type ProjectFlockKandangLookup = {
available_quantity?: number; available_quantity?: number;
population: number; population: number;
chick_in_date: string; chick_in_date: string;
is_transition: boolean;
}; };
export type ProjectFlockAvailableQuantity = { export type ProjectFlockAvailableQuantity = {
+1 -1
View File
@@ -49,7 +49,7 @@ 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;
} & ProductionMetrics; } & ProductionMetrics;
export type RecordingDepletion = { export type RecordingDepletion = {