feat(FE-170,175): add approval steps and status display in GradingForm and RecordingForm

This commit is contained in:
rstubryan
2025-11-12 23:41:22 +07:00
parent 47262adaf1
commit 478ca186d3
@@ -23,8 +23,9 @@ import {
} from '@/types/api/production/recording';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import {
type FormStepStatus,
type BaseApiResponse,
BaseApproval,
BaseGroupedApproval,
} from '@/types/api/api-general';
import {
@@ -36,15 +37,24 @@ import {
import { cn, formatDate } from '@/lib/helper';
import toast from 'react-hot-toast';
import { isResponseError } from '@/lib/api-helper';
import { isResponseSuccess, 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 {
@@ -103,6 +113,231 @@ 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':
if (currentStepNumber === latestStepNumber) {
switch (latestApproval.action) {
case 'CREATED':
case 'APPROVED':
approvalStatus = 'APPROVED';
break;
case 'REJECTED':
approvalStatus = 'REJECTED';
break;
default:
approvalStatus = 'WAITING';
break;
}
} else {
approvalStatus = 'APPROVED';
}
break;
default:
approvalStatus =
currentStepNumber === latestStepNumber
? 'WAITING'
: '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;
@@ -360,12 +595,12 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
},
{
name: 'Grading',
isCompleted: false,
isCurrent: true,
isCompleted: type !== 'add',
isCurrent: type === 'add',
},
];
setFormSteps(steps);
}, []);
}, [type]);
useEffect(() => {
if (isGradingExceedsAvailable && currentGradingTotal > 0) {
@@ -414,57 +649,31 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
{/* Project Flock Info Card */}
<div className='flex items-center gap-2 mb-4'>
{/* Form Steps */}
{formSteps && (
{/* Approval Steps */}
{type === 'detail' && approvalStepsData.length > 0 && (
<div className='flex-1 mt-4'>
<div className='w-full'>
<ul className='steps w-full'>
{formSteps.map((step, idx) => (
<StepItem
key={idx}
color={
step.isCompleted
? 'success'
: step.isCurrent
? 'primary'
: undefined
}
className={
step.isCurrent && idx > 0 ? 'step-primary' : undefined
}
icon={
step.isCompleted ? (
<Icon
icon='material-symbols:check-rounded'
width={24}
height={24}
/>
) : (
idx + 1
)
}
>
{step.name}
</StepItem>
))}
</ul>
</div>
{/* Action buttons for multi-form navigation */}
{type === 'detail' && (
<div className='mt-4 flex gap-2'>
<Button
type='button'
color='primary'
onClick={() => {
toast.success('Semua form telah selesai!');
router.push('/production/recording');
}}
>
Kembali ke Recording
</Button>
</div>
)}
<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>
@@ -482,11 +691,55 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
}}
>
<div className='grid grid-cols-2 md:grid-cols-4 gap-4 mb-6'>
{/* Status Approval */}
{recording?.approval && (
<div>
<span className='text-sm text-gray-600'>Status Approval</span>
<div className='mt-1'>
<Badge
variant='soft'
color={
recording.approval.action === 'APPROVED'
? 'success'
: recording.approval.action === 'REJECTED'
? 'error'
: recording.approval.action === 'UPDATED'
? 'warning'
: 'info'
}
className={{
badge: 'whitespace-nowrap font-semibold text-xs px-2',
}}
>
{(() => {
const actionText = (() => {
switch (recording.approval.action) {
case 'APPROVED':
return 'Disetujui';
case 'REJECTED':
return 'Ditolak';
case 'CREATED':
return 'Dibuat';
case 'UPDATED':
return 'Diperbarui';
default:
return recording.approval.action;
}
})();
const stepName = recording.approval.step_name;
if (stepName === actionText) {
return stepName;
}
return `${stepName} - ${actionText}`;
})()}
</Badge>
</div>
</div>
)}
{/* Recording Info */}
<div>
<span className='text-sm text-gray-600'>Recording ID</span>
<p className='font-semibold'>#{recording?.id || '-'}</p>
</div>
<div>
<span className='text-sm text-gray-600'>Lokasi</span>
<p className='font-semibold'>