feat(FE-170,174,175): implement approval lines for growing and laying recording categories

This commit is contained in:
rstubryan
2025-11-18 10:37:10 +07:00
parent 38cab1464c
commit 9164b985b1
4 changed files with 78 additions and 521 deletions
+2 -1
View File
@@ -158,6 +158,7 @@ export const formatGroupedApprovalsToApprovalSteps = (
if (approvalGroup.approvals) {
switch (approvalGroup?.approvals[0]?.action) {
case 'CREATED':
case 'UPDATED':
case 'APPROVED':
approvalStatus = 'APPROVED';
break;
@@ -256,7 +257,7 @@ const useApprovalSteps = ({
moduleName: string;
moduleId: string;
params?: {
page: number;
page?: number;
limit: number;
search?: string;
group_step_number?: boolean;
@@ -23,7 +23,6 @@ import {
} from '@/services/api/production';
import { LocationApi } from '@/services/api/master-data';
import { ProductWarehouseApi } from '@/services/api/inventory';
import { ApprovalApi } from '@/services/api/approval';
import {
CreateGrowingRecordingPayload,
@@ -32,11 +31,7 @@ import {
UpdateLayingRecordingPayload,
Recording,
} from '@/types/api/production/recording';
import {
type BaseApiResponse,
BaseApproval,
BaseGroupedApproval,
} from '@/types/api/api-general';
import { type BaseApiResponse } from '@/types/api/api-general';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Kandang } from '@/types/api/master-data/kandang';
@@ -55,8 +50,13 @@ import {
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate } from '@/lib/helper';
import toast from 'react-hot-toast';
import ApprovalSteps from '@/components/pages/ApprovalSteps';
import { RECORDING_APPROVAL_LINE } from '@/config/approval-line';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import {
GROWING_RECORDING_APPROVAL_LINE,
LAYING_RECORDING_APPROVAL_LINE,
} from '@/config/approval-line';
interface RecordingFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -434,213 +434,41 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return approvedProjectFlockKandangsData.data;
}, [approvedProjectFlockKandangsData]);
// ===== APPROVAL DATA FETCHING =====
const approvalHistoryUrl = useMemo(() => {
if (!initialValues?.id || type !== 'detail') return null;
const params = new URLSearchParams({
module_name: 'RECORDINGS',
module_id: initialValues.id.toString(),
group_step_number: 'true',
limit: '100',
});
return `${ApprovalApi.basePath}?${params.toString()}`;
}, [initialValues?.id, type]);
const isLayingCategory =
initialValues?.project_flock_category === 'LAYING' ||
projectFlockKandangLookup?.project_flock?.category === 'LAYING' ||
projectFlockKandangDetail?.project_flock?.category === 'LAYING';
const { data: approvalHistoryData } = useSWR(
approvalHistoryUrl,
approvalHistoryUrl ? ApprovalApi.getAllFetcher : null
);
const isGrowingCategory =
initialValues?.project_flock_category === 'GROWING' ||
projectFlockKandangLookup?.project_flock?.category === 'GROWING' ||
projectFlockKandangDetail?.project_flock?.category === 'GROWING';
// Helper functions for approval data processing
const groupApprovalsByStep = useCallback(
(approvals: BaseApproval[]): BaseGroupedApproval[] => {
const groups: Record<number, BaseGroupedApproval> = {};
for (const approval of approvals) {
if (!groups[approval.step_number]) {
groups[approval.step_number] = {
step_number: approval.step_number,
step_name: approval.step_name,
approvals: [],
};
}
groups[approval.step_number].approvals.push(approval);
}
return Object.values(groups);
const recordingApprovalLines = useMemo(() => {
if (isLayingCategory) {
return LAYING_RECORDING_APPROVAL_LINE;
}
if (isGrowingCategory) {
return GROWING_RECORDING_APPROVAL_LINE;
}
return GROWING_RECORDING_APPROVAL_LINE;
}, [isLayingCategory, isGrowingCategory]);
// ===== APPROVAL DATA FETCHING USING HOOK =====
const {
approvals,
isLoading: approvalsLoading,
refresh: refreshApprovals,
} = useApprovalSteps({
latestApproval: initialValues?.approval,
approvalLines: recordingApprovalLines,
moduleName: 'RECORDINGS',
moduleId: initialValues?.id?.toString() ?? '',
params: {
limit: 100,
group_step_number: true,
},
[]
);
const isGroupedApprovalData = useCallback(
(
data: BaseApproval[] | BaseGroupedApproval[]
): data is BaseGroupedApproval[] => {
if (!data || data.length === 0) {
return true;
}
const firstElement = data[0];
return (
typeof firstElement === 'object' &&
firstElement !== null &&
'approvals' in firstElement &&
Array.isArray(firstElement.approvals)
);
},
[]
);
// Process approval data
const { groupedApprovals, latestApproval } = useMemo(() => {
const latest = initialValues?.approval;
const rawData = isResponseSuccess(approvalHistoryData)
? approvalHistoryData.data
: undefined;
let processedGroupedApprovals: BaseGroupedApproval[] = [];
if (rawData) {
if (isGroupedApprovalData(rawData)) {
processedGroupedApprovals = rawData as BaseGroupedApproval[];
} else {
processedGroupedApprovals = groupApprovalsByStep(
rawData as BaseApproval[]
);
}
}
if (
latest &&
!processedGroupedApprovals.find(
(g) => g.step_number === latest.step_number
)
) {
processedGroupedApprovals.push({
step_number: latest.step_number,
step_name: latest.step_name,
approvals: [latest],
});
processedGroupedApprovals.sort((a, b) => a.step_number - b.step_number);
}
return {
groupedApprovals: processedGroupedApprovals,
latestApproval: latest,
};
}, [
approvalHistoryData,
initialValues?.approval,
isGroupedApprovalData,
groupApprovalsByStep,
]);
// Format approval steps for display
const approvalStepsData = useMemo(() => {
if (type !== 'detail') {
return [];
}
try {
return RECORDING_APPROVAL_LINE.map(
(
approvalLineItem
): {
name: string;
status: 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE';
logs: Array<{
action_by?: string;
date?: string;
notes?: string | null;
}>;
} => {
const approvalGroup = groupedApprovals.find(
(approvalGroupItem) =>
approvalGroupItem.step_number === approvalLineItem.step_number
);
if (!latestApproval) {
return {
name: approvalLineItem.step_name,
status: 'IDLE',
logs: approvalGroup
? approvalGroup.approvals.map((approval) => ({
action_by: approval.action_by.name,
date: approval.action_at,
notes: approval.notes,
}))
: [],
};
}
const currentStepNumber = approvalLineItem.step_number;
const latestStepNumber = latestApproval.step_number;
if (!approvalGroup) {
if (currentStepNumber === latestStepNumber + 1) {
return {
name: approvalLineItem.step_name,
status: 'WAITING',
logs: [],
};
}
return {
name: approvalLineItem.step_name,
status: 'IDLE',
logs: [],
};
}
let approvalStatus: 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE' =
'IDLE';
if (currentStepNumber <= latestStepNumber) {
if (approvalGroup.approvals && approvalGroup.approvals.length > 0) {
const latestApprovalInGroup = approvalGroup.approvals.sort(
(a, b) =>
new Date(b.action_at).getTime() -
new Date(a.action_at).getTime()
)[0];
switch (latestApprovalInGroup.action) {
case 'CREATED':
case 'APPROVED':
approvalStatus = 'APPROVED';
break;
case 'REJECTED':
approvalStatus = 'REJECTED';
break;
case 'UPDATED':
approvalStatus = 'APPROVED';
break;
default:
approvalStatus = 'APPROVED';
break;
}
}
} else if (currentStepNumber === latestStepNumber + 1) {
approvalStatus = 'WAITING';
} else {
approvalStatus = 'IDLE';
}
const approvalLogs = approvalGroup.approvals.map((approval) => ({
action_by: approval.action_by.name,
date: approval.action_at,
notes: approval.notes,
}));
return {
name: approvalGroup.step_name,
status: approvalStatus,
logs: approvalLogs,
};
}
);
} catch (error) {
console.warn('Gagal memformat approval steps:', error);
return [];
}
}, [groupedApprovals, latestApproval, type]);
});
// ===== DATA PROCESSING =====
const locationOptions = useMemo(() => {
@@ -889,11 +717,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return options;
}, [eggProductsData, initialValues, type, selectedKandang]);
const isLayingCategory =
initialValues?.project_flock_category === 'LAYING' ||
projectFlockKandangLookup?.project_flock?.category === 'LAYING' ||
projectFlockKandangDetail?.project_flock?.category === 'LAYING';
// ===== FORMIK SETUP =====
const formikInitialValues = useMemo(() => {
let baseValues;
@@ -1276,6 +1099,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isResponseSuccess(approveResponse)) {
toast.success('Recording berhasil disetujui!');
approveModal.closeModal();
await refreshApprovals();
router.push('/production/recording');
} else {
toast.error(
@@ -1298,6 +1122,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isResponseSuccess(rejectResponse)) {
toast.success('Recording berhasil ditolak!');
rejectModal.closeModal();
await refreshApprovals();
router.push('/production/recording');
} else {
toast.error(
@@ -1586,7 +1411,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}
}, [bodyWeightValues, editingAverageIndex, manuallyEditedRows]);
// ===== RENDER =====
return (
<>
<section className='w-full'>
@@ -1645,37 +1469,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</h1>
</header>
{/* Project Flock Info Card */}
{(projectFlockKandangLookup || projectFlockKandangDetail) && (
<div className='flex items-center gap-2 mb-4'>
{/* Approval Steps for all categories */}
{type === 'detail' && approvalStepsData.length > 0 && (
<div className='flex-1 mt-4'>
<ApprovalSteps approvals={approvalStepsData} />
</div>
)}
{/* Default approval steps for add/edit modes */}
{type !== 'detail' && (
<div className='flex-1 mt-4'>
<ApprovalSteps
approvals={[
{
name: RECORDING_APPROVAL_LINE[0].step_name,
status: 'APPROVED',
},
{
name: RECORDING_APPROVAL_LINE[1].step_name,
status: 'WAITING',
},
{
name: RECORDING_APPROVAL_LINE[2].step_name,
status: 'IDLE',
},
]}
/>
</div>
)}
</div>
{type === 'detail' && approvals && !approvalsLoading && (
<ApprovalSteps approvals={approvals} />
)}
<form
@@ -11,7 +11,6 @@ import SelectInput, { OptionType } from '@/components/input/SelectInput';
import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Card from '@/components/Card';
import StepItem from '@/components/steps/StepItem';
import Badge from '@/components/Badge';
import {
@@ -22,11 +21,7 @@ import {
Recording,
} from '@/types/api/production/recording';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import {
type BaseApiResponse,
BaseApproval,
BaseGroupedApproval,
} from '@/types/api/api-general';
import { type BaseApiResponse } from '@/types/api/api-general';
import {
RecordingGradingFormSchema,
@@ -37,24 +32,15 @@ import {
import { cn, formatDate } from '@/lib/helper';
import toast from 'react-hot-toast';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { isResponseError } from '@/lib/api-helper';
import {
RecordingApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import { ApprovalApi } from '@/services/api/approval';
import { useModal } from '@/components/Modal';
import useSWR from 'swr';
import ApprovalSteps from '@/components/pages/ApprovalSteps';
import { RECORDING_APPROVAL_LINE } from '@/config/approval-line';
type FormStepStatus = {
name: string;
isCompleted: boolean;
isCurrent: boolean;
};
// INTERFACES & PROPS
interface GradingFormProps {
@@ -75,7 +61,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
const [selectedGradingItems, setSelectedGradingItems] = useState<number[]>(
[]
);
const [formSteps, setFormSteps] = useState<FormStepStatus[] | null>(null);
const [gradingFormErrorMessage, setGradingFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const deleteModal = useModal();
@@ -113,213 +98,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
? (projectFlockKandangData.data as unknown as ProjectFlockKandang)
: undefined;
// ===== APPROVAL DATA FETCHING =====
const approvalHistoryUrl = useMemo(() => {
if (!recording?.id || type !== 'detail') return null;
const params = new URLSearchParams({
module_name: 'RECORDINGS',
module_id: recording.id.toString(),
group_step_number: 'true',
});
return `${ApprovalApi.basePath}?${params.toString()}`;
}, [recording?.id, type]);
const { data: approvalHistoryData } = useSWR(
approvalHistoryUrl,
approvalHistoryUrl ? ApprovalApi.getAllFetcher : null
);
// Helper functions for approval data processing
const groupApprovalsByStep = useCallback(
(approvals: BaseApproval[]): BaseGroupedApproval[] => {
const groups: Record<number, BaseGroupedApproval> = {};
for (const approval of approvals) {
if (!groups[approval.step_number]) {
groups[approval.step_number] = {
step_number: approval.step_number,
step_name: approval.step_name,
approvals: [],
};
}
groups[approval.step_number].approvals.push(approval);
}
return Object.values(groups);
},
[]
);
const isGroupedApprovalData = useCallback(
(
data: BaseApproval[] | BaseGroupedApproval[]
): data is BaseGroupedApproval[] => {
if (!data || data.length === 0) {
return true;
}
const firstElement = data[0];
return (
typeof firstElement === 'object' &&
firstElement !== null &&
'approvals' in firstElement &&
Array.isArray(firstElement.approvals)
);
},
[]
);
// Process approval data
const { groupedApprovals, latestApproval } = useMemo(() => {
const latest = recording?.approval;
const rawData = isResponseSuccess(approvalHistoryData)
? approvalHistoryData.data
: undefined;
let processedGroupedApprovals: BaseGroupedApproval[] = [];
if (rawData) {
if (isGroupedApprovalData(rawData)) {
processedGroupedApprovals = rawData as BaseGroupedApproval[];
} else {
processedGroupedApprovals = groupApprovalsByStep(
rawData as BaseApproval[]
);
}
}
if (
latest &&
!processedGroupedApprovals.find(
(g) => g.step_number === latest.step_number
)
) {
processedGroupedApprovals.push({
step_number: latest.step_number,
step_name: latest.step_name,
approvals: [latest],
});
processedGroupedApprovals.sort((a, b) => a.step_number - b.step_number);
}
return {
groupedApprovals: processedGroupedApprovals,
latestApproval: latest,
};
}, [
approvalHistoryData,
recording?.approval,
isGroupedApprovalData,
groupApprovalsByStep,
]);
// Format approval steps for display
const approvalStepsData = useMemo(() => {
if (!latestApproval || type !== 'detail') {
return [];
}
try {
return RECORDING_APPROVAL_LINE.map(
(
approvalLineItem
): {
name: string;
status: 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE';
logs: Array<{
action_by?: string;
date?: string;
notes?: string | null;
}>;
} => {
const approvalGroup = groupedApprovals.find(
(approvalGroupItem) =>
approvalGroupItem.step_number === approvalLineItem.step_number
);
if (!latestApproval) {
return {
name: approvalLineItem.step_name,
status: 'IDLE',
logs: approvalGroup
? approvalGroup.approvals.map((approval) => ({
action_by: approval.action_by.name,
date: approval.action_at,
notes: approval.notes,
}))
: [],
};
}
const currentStepNumber = approvalLineItem.step_number;
const latestStepNumber = latestApproval.step_number;
if (!approvalGroup) {
if (currentStepNumber === latestStepNumber + 1) {
return {
name: approvalLineItem.step_name,
status: 'WAITING',
logs: [],
};
}
return {
name: approvalLineItem.step_name,
status: 'IDLE',
logs: [],
};
}
let approvalStatus: 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE' =
'IDLE';
if (currentStepNumber <= latestStepNumber) {
if (approvalGroup.approvals && approvalGroup.approvals.length > 0) {
const latestApprovalInGroup = approvalGroup.approvals.sort(
(a, b) =>
new Date(b.action_at).getTime() -
new Date(a.action_at).getTime()
)[0];
switch (latestApprovalInGroup.action) {
case 'CREATED':
case 'APPROVED':
approvalStatus = 'APPROVED';
break;
case 'REJECTED':
approvalStatus = 'REJECTED';
break;
case 'UPDATED':
approvalStatus = 'APPROVED';
break;
default:
approvalStatus = 'APPROVED';
break;
}
}
} else if (currentStepNumber === latestStepNumber + 1) {
approvalStatus = 'WAITING';
} else {
approvalStatus = 'IDLE';
}
const approvalLogs = approvalGroup.approvals.map((approval) => ({
action_by: approval.action_by.name,
date: approval.action_at,
notes: approval.notes,
}));
return {
name: approvalGroup.step_name,
status: approvalStatus,
logs: approvalLogs,
};
}
);
} catch (error) {
console.warn('Gagal memformat approval steps:', error);
return [];
}
}, [groupedApprovals, latestApproval, type]);
const konsumsiBaikEggData = useMemo(() => {
if (!recording?.eggs) return null;
@@ -568,22 +346,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
};
// EFFECTS
useEffect(() => {
const steps: FormStepStatus[] = [
{
name: 'Recording',
isCompleted: true,
isCurrent: false,
},
{
name: 'Grading',
isCompleted: type !== 'add',
isCurrent: type === 'add',
},
];
setFormSteps(steps);
}, [type]);
useEffect(() => {
if (isGradingExceedsAvailable && currentGradingTotal > 0) {
toast.error(
@@ -629,37 +391,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
</h1>
</header>
{/* Project Flock Info Card */}
<div className='flex items-center gap-2 mb-4'>
{/* Approval Steps */}
{type === 'detail' && approvalStepsData.length > 0 && (
<div className='flex-1 mt-4'>
<ApprovalSteps approvals={approvalStepsData} />
</div>
)}
{/* Default approval steps for add/edit modes */}
{type !== 'detail' && (
<div className='flex-1 mt-4'>
<ApprovalSteps
approvals={[
{
name: RECORDING_APPROVAL_LINE[0].step_name,
status: 'APPROVED',
},
{
name: RECORDING_APPROVAL_LINE[1].step_name,
status: 'APPROVED',
},
{
name: RECORDING_APPROVAL_LINE[2].step_name,
status: 'WAITING',
},
]}
/>
</div>
)}
</div>
<form
onSubmit={formik.handleSubmit}
className='w-full mt-8 flex flex-col gap-6'
+30
View File
@@ -47,3 +47,33 @@ export const RECORDING_APPROVAL_LINE: ApprovalLine = [
step_name: 'Disetujui',
},
] as const;
export const GROWING_RECORDING_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan',
},
{
step_number: 3,
step_name: 'Disetujui',
},
] as const;
export const LAYING_RECORDING_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan',
},
{
step_number: 3,
step_name: 'Disetujui',
},
] as const;