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 PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import Tooltip from '@/components/Tooltip';
import { useFormik } from 'formik';
import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
@@ -36,6 +37,7 @@ import {
import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton';
import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -105,30 +107,57 @@ const RowOptionsMenu = ({
};
const isRecordingEditable = (recording: Recording) => {
if (
recording.executed_at &&
recording.project_flock?.project_flock_category === 'GROWING'
) {
const category = recording.project_flock?.project_flock_category;
const isTransition = recording.is_transition;
const restriction = getRecordingRestriction(
category || 'GROWING',
isTransition
);
if (restriction.isLocked) {
return false;
}
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 isRejected = isRecordingRejected(props.row.original);
const isEditable = isRecordingEditable(props.row.original);
const restrictionInfo = getRecordingRestrictionInfo(props.row.original);
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
<Tooltip
content={restrictionInfo.isLocked ? restrictionInfo.lockReason : ''}
position='top'
>
<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
id={popoverId}
@@ -763,9 +792,19 @@ const RecordingTable = () => {
cell: (props) => {
const category =
props.row.original.project_flock?.project_flock_category;
const isTransition = props.row.original.is_transition;
if (!category) return '-';
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';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper';
import { formatDate, formatNumber, cn } from '@/lib/helper';
import toast from 'react-hot-toast';
import ApprovalSteps, {
useApprovalSteps,
@@ -80,6 +80,7 @@ import {
LAYING_RECORDING_APPROVAL_LINE,
} from '@/config/approval-line';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { getRecordingRestriction } from '../recording-utils';
interface RecordingFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -272,16 +273,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return recording?.approval?.action === 'REJECTED';
}, []);
const isRecordingEditable = useCallback((recording?: Recording) => {
if (
recording?.executed_at &&
recording?.project_flock?.project_flock_category === 'GROWING'
) {
return false;
}
return true;
}, []);
// ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => {
@@ -476,6 +467,60 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
? projectFlockKandangDetailData.data
: 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 {
options: stockProductOptions,
rawData: stockProducts,
@@ -2324,6 +2369,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedStocks([]);
}
}}
disabled={!recordingRestriction.canEditStock}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -2373,6 +2419,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
);
}
}}
disabled={!recordingRestriction.canEditStock}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -2425,7 +2472,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
isSearchable
isDisabled={
type === 'detail' ||
!formik.values.project_flock_kandang_id
!formik.values.project_flock_kandang_id ||
!recordingRestriction.canEditStock
}
isClearable={type !== 'detail'}
inputPrefix={
@@ -2472,7 +2520,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)
: null
}
disabled={type === 'detail'}
disabled={
type === 'detail' ||
!recordingRestriction.canEditStock
}
/>
{getStockUsageAdornment(idx)}
</div>
@@ -2484,6 +2535,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
type='button'
color='error'
onClick={() => removeStock(idx)}
disabled={!recordingRestriction.canEditStock}
>
<Icon
icon='mdi:trash-can'
@@ -2501,38 +2553,81 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<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
type='button'
color='error'
onClick={removeSelectedStocks}
disabled={selectedStocks.length === 0}
color='success'
onClick={addStock}
className='w-fit'
disabled={!recordingRestriction.canEditStock}
>
<Icon icon='mdi:trash-can' width={24} height={24} />
Hapus Terpilih ({selectedStocks.length})
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Stok
</Button>
)}
<Button
type='button'
color='success'
onClick={addStock}
className='w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Stok
</Button>
</Tooltip>
</div>
)}
</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 */}
{((type as 'add' | 'edit' | 'detail') !== 'detail' ||
(formik.values.depletions?.length ?? 0) > 0) && (
<Card
title='Deplesi'
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',
}}
>
@@ -2562,6 +2657,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedDepletions([]);
}
}}
disabled={!recordingRestriction.canEditDepletion}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -2598,6 +2694,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
);
}
}}
disabled={!recordingRestriction.canEditDepletion}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -2640,7 +2737,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
idx
).errorMessage
}
isDisabled={type === 'detail'}
isDisabled={
type === 'detail' ||
!recordingRestriction.canEditDepletion
}
className={{
wrapper: 'w-full min-w-48',
}}
@@ -2679,7 +2779,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)
: null
}
disabled={type === 'detail'}
disabled={
type === 'detail' ||
!recordingRestriction.canEditDepletion
}
/>
</td>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
@@ -2689,6 +2792,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
type='button'
color='error'
onClick={() => removeDepletion(idx)}
disabled={
!recordingRestriction.canEditDepletion
}
>
<Icon
icon='mdi:trash-can'
@@ -2706,27 +2812,38 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<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
type='button'
color='error'
onClick={removeSelectedDepletions}
disabled={selectedDepletions.length === 0}
color='success'
onClick={addDepletion}
className='w-fit'
disabled={!recordingRestriction.canEditDepletion}
>
<Icon icon='mdi:trash-can' width={24} height={24} />
Hapus Terpilih ({selectedDepletions.length})
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Depletion
</Button>
)}
<Button
type='button'
color='success'
onClick={addDepletion}
className='w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Depletion
</Button>
</Tooltip>
</div>
)}
</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;
population: number;
chick_in_date: string;
is_transition: boolean;
};
export type ProjectFlockAvailableQuantity = {
+1 -1
View File
@@ -49,7 +49,7 @@ export type BaseRecording = {
project_flock: ProjectFlock;
record_datetime: string;
day: number;
executed_at: string;
is_transition: boolean;
} & ProductionMetrics;
export type RecordingDepletion = {