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) { if (approvalGroup.approvals) {
switch (approvalGroup?.approvals[0]?.action) { switch (approvalGroup?.approvals[0]?.action) {
case 'CREATED': case 'CREATED':
case 'UPDATED':
case 'APPROVED': case 'APPROVED':
approvalStatus = 'APPROVED'; approvalStatus = 'APPROVED';
break; break;
@@ -256,7 +257,7 @@ const useApprovalSteps = ({
moduleName: string; moduleName: string;
moduleId: string; moduleId: string;
params?: { params?: {
page: number; page?: number;
limit: number; limit: number;
search?: string; search?: string;
group_step_number?: boolean; group_step_number?: boolean;
@@ -23,7 +23,6 @@ import {
} from '@/services/api/production'; } from '@/services/api/production';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
import { ApprovalApi } from '@/services/api/approval';
import { import {
CreateGrowingRecordingPayload, CreateGrowingRecordingPayload,
@@ -32,11 +31,7 @@ import {
UpdateLayingRecordingPayload, UpdateLayingRecordingPayload,
Recording, Recording,
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
import { import { type BaseApiResponse } from '@/types/api/api-general';
type BaseApiResponse,
BaseApproval,
BaseGroupedApproval,
} from '@/types/api/api-general';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
@@ -55,8 +50,13 @@ import {
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import ApprovalSteps from '@/components/pages/ApprovalSteps'; import ApprovalSteps, {
import { RECORDING_APPROVAL_LINE } from '@/config/approval-line'; useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import {
GROWING_RECORDING_APPROVAL_LINE,
LAYING_RECORDING_APPROVAL_LINE,
} from '@/config/approval-line';
interface RecordingFormProps { interface RecordingFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -434,213 +434,41 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return approvedProjectFlockKandangsData.data; return approvedProjectFlockKandangsData.data;
}, [approvedProjectFlockKandangsData]); }, [approvedProjectFlockKandangsData]);
// ===== APPROVAL DATA FETCHING ===== const isLayingCategory =
const approvalHistoryUrl = useMemo(() => { initialValues?.project_flock_category === 'LAYING' ||
if (!initialValues?.id || type !== 'detail') return null; projectFlockKandangLookup?.project_flock?.category === 'LAYING' ||
const params = new URLSearchParams({ projectFlockKandangDetail?.project_flock?.category === 'LAYING';
module_name: 'RECORDINGS',
module_id: initialValues.id.toString(),
group_step_number: 'true',
limit: '100',
});
return `${ApprovalApi.basePath}?${params.toString()}`;
}, [initialValues?.id, type]);
const { data: approvalHistoryData } = useSWR( const isGrowingCategory =
approvalHistoryUrl, initialValues?.project_flock_category === 'GROWING' ||
approvalHistoryUrl ? ApprovalApi.getAllFetcher : null projectFlockKandangLookup?.project_flock?.category === 'GROWING' ||
); projectFlockKandangDetail?.project_flock?.category === 'GROWING';
// Helper functions for approval data processing const recordingApprovalLines = useMemo(() => {
const groupApprovalsByStep = useCallback( if (isLayingCategory) {
(approvals: BaseApproval[]): BaseGroupedApproval[] => { return LAYING_RECORDING_APPROVAL_LINE;
const groups: Record<number, BaseGroupedApproval> = {}; }
for (const approval of approvals) { if (isGrowingCategory) {
if (!groups[approval.step_number]) { return GROWING_RECORDING_APPROVAL_LINE;
groups[approval.step_number] = { }
step_number: approval.step_number, return GROWING_RECORDING_APPROVAL_LINE;
step_name: approval.step_name, }, [isLayingCategory, isGrowingCategory]);
approvals: [],
}; // ===== APPROVAL DATA FETCHING USING HOOK =====
} const {
groups[approval.step_number].approvals.push(approval); approvals,
} isLoading: approvalsLoading,
return Object.values(groups); 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 ===== // ===== DATA PROCESSING =====
const locationOptions = useMemo(() => { const locationOptions = useMemo(() => {
@@ -889,11 +717,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return options; return options;
}, [eggProductsData, initialValues, type, selectedKandang]); }, [eggProductsData, initialValues, type, selectedKandang]);
const isLayingCategory =
initialValues?.project_flock_category === 'LAYING' ||
projectFlockKandangLookup?.project_flock?.category === 'LAYING' ||
projectFlockKandangDetail?.project_flock?.category === 'LAYING';
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formikInitialValues = useMemo(() => { const formikInitialValues = useMemo(() => {
let baseValues; let baseValues;
@@ -1276,6 +1099,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isResponseSuccess(approveResponse)) { if (isResponseSuccess(approveResponse)) {
toast.success('Recording berhasil disetujui!'); toast.success('Recording berhasil disetujui!');
approveModal.closeModal(); approveModal.closeModal();
await refreshApprovals();
router.push('/production/recording'); router.push('/production/recording');
} else { } else {
toast.error( toast.error(
@@ -1298,6 +1122,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isResponseSuccess(rejectResponse)) { if (isResponseSuccess(rejectResponse)) {
toast.success('Recording berhasil ditolak!'); toast.success('Recording berhasil ditolak!');
rejectModal.closeModal(); rejectModal.closeModal();
await refreshApprovals();
router.push('/production/recording'); router.push('/production/recording');
} else { } else {
toast.error( toast.error(
@@ -1586,7 +1411,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
} }
}, [bodyWeightValues, editingAverageIndex, manuallyEditedRows]); }, [bodyWeightValues, editingAverageIndex, manuallyEditedRows]);
// ===== RENDER =====
return ( return (
<> <>
<section className='w-full'> <section className='w-full'>
@@ -1645,37 +1469,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</h1> </h1>
</header> </header>
{/* Project Flock Info Card */} {type === 'detail' && approvals && !approvalsLoading && (
{(projectFlockKandangLookup || projectFlockKandangDetail) && ( <ApprovalSteps approvals={approvals} />
<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>
)} )}
<form <form
@@ -11,7 +11,6 @@ import SelectInput, { OptionType } from '@/components/input/SelectInput';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Card from '@/components/Card'; import Card from '@/components/Card';
import StepItem from '@/components/steps/StepItem';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { import {
@@ -22,11 +21,7 @@ import {
Recording, Recording,
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { import { type BaseApiResponse } from '@/types/api/api-general';
type BaseApiResponse,
BaseApproval,
BaseGroupedApproval,
} from '@/types/api/api-general';
import { import {
RecordingGradingFormSchema, RecordingGradingFormSchema,
@@ -37,24 +32,15 @@ import {
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { import {
RecordingApi, RecordingApi,
ProjectFlockKandangApi, ProjectFlockKandangApi,
} from '@/services/api/production'; } from '@/services/api/production';
import { ApprovalApi } from '@/services/api/approval';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import useSWR from 'swr'; 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 // INTERFACES & PROPS
interface GradingFormProps { interface GradingFormProps {
@@ -75,7 +61,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
const [selectedGradingItems, setSelectedGradingItems] = useState<number[]>( const [selectedGradingItems, setSelectedGradingItems] = useState<number[]>(
[] []
); );
const [formSteps, setFormSteps] = useState<FormStepStatus[] | null>(null);
const [gradingFormErrorMessage, setGradingFormErrorMessage] = useState(''); const [gradingFormErrorMessage, setGradingFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const deleteModal = useModal(); const deleteModal = useModal();
@@ -113,213 +98,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
? (projectFlockKandangData.data as unknown as ProjectFlockKandang) ? (projectFlockKandangData.data as unknown as ProjectFlockKandang)
: undefined; : 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(() => { const konsumsiBaikEggData = useMemo(() => {
if (!recording?.eggs) return null; if (!recording?.eggs) return null;
@@ -568,22 +346,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
}; };
// EFFECTS // EFFECTS
useEffect(() => {
const steps: FormStepStatus[] = [
{
name: 'Recording',
isCompleted: true,
isCurrent: false,
},
{
name: 'Grading',
isCompleted: type !== 'add',
isCurrent: type === 'add',
},
];
setFormSteps(steps);
}, [type]);
useEffect(() => { useEffect(() => {
if (isGradingExceedsAvailable && currentGradingTotal > 0) { if (isGradingExceedsAvailable && currentGradingTotal > 0) {
toast.error( toast.error(
@@ -629,37 +391,6 @@ const GradingForm = ({ type = 'add', initialValues }: GradingFormProps) => {
</h1> </h1>
</header> </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 <form
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
className='w-full mt-8 flex flex-col gap-6' 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', step_name: 'Disetujui',
}, },
] as const; ] 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;