Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into dev/randy

This commit is contained in:
randy-ar
2026-01-07 10:52:12 +07:00
24 changed files with 529 additions and 303 deletions
@@ -872,7 +872,7 @@ const RecordingTable = () => {
'mb-20':
isResponseSuccess(recordings) && recordings?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full overflow-visible!',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
@@ -1,91 +1,93 @@
import React from 'react';
import React, { useMemo } from 'react';
import Card from '@/components/Card';
import UniformityBarChart from '@/components/pages/production/uniformity/chart/UniformityBarChart';
import UniformityGaugeChart from '@/components/pages/production/uniformity/chart/UniformityGaugeChart';
import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton';
import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton';
import {
UniformityDetailItem,
Uniformity,
} from '@/types/api/production/uniformity';
interface BarChartData {
name: string;
uv: number;
interface UniformityChartProps {
uniformityData?: Uniformity | null;
uniformityDetails?: UniformityDetailItem[];
}
interface GaugeChartData {
value: number;
label: string;
kandang?: string;
week?: string;
currentValue?: number;
totalValue?: number;
}
const UniformityChart = () => {
// TODO: Replace with actual API call
const barChartData: BarChartData[] = [
{
name: '48-52',
uv: 80,
},
{
name: '52-56',
uv: 120,
},
{
name: '56-60',
uv: 160,
},
{
name: '60-64',
uv: 200,
},
{
name: '64-68',
uv: 160,
},
{
name: '68-72',
uv: 120,
},
{
name: '72-76',
uv: 80,
},
{
name: '76-80',
uv: 120,
},
{
name: '84-88',
uv: 160,
},
{
name: '88-92',
uv: 200,
},
{
name: '92-96',
uv: 160,
},
const UniformityChart = ({
uniformityData,
uniformityDetails,
}: UniformityChartProps) => {
const defaultUniformityDetails: UniformityDetailItem[] = [
{ id: 1, weight: 61, range: 'Ideal' },
{ id: 2, weight: 62, range: 'Ideal' },
{ id: 3, weight: 63, range: 'Ideal' },
{ id: 4, weight: 64, range: 'Ideal' },
{ id: 5, weight: 65, range: 'Ideal' },
{ id: 6, weight: 66, range: 'Ideal' },
{ id: 7, weight: 67, range: 'Ideal' },
];
// TODO: Replace with actual API call
// const gaugeChartData: GaugeChartData = {
// value: 0,
// label: '',
// kandang: 'Kandang Cirangga',
// week: 'Week 2',
// currentValue: 512,
// totalValue: 1024,
// };
const detailsToUse = uniformityDetails || defaultUniformityDetails;
const gaugeChartData: GaugeChartData = {
value: 52,
label: 'Uniformity',
kandang: 'Kandang Cirangga',
week: 'Week 2',
currentValue: 512,
totalValue: 1024,
};
const barChartData = useMemo(() => {
if (!uniformityData) {
return [];
}
if (!detailsToUse || detailsToUse.length === 0) {
return [];
}
const weights = detailsToUse.map((d) => d.weight);
const minWeight = Math.floor(Math.min(...weights) / 5) * 5;
const maxWeight = Math.ceil(Math.max(...weights) / 5) * 5;
const rangeSize = maxWeight - minWeight < 11 ? 4 : 5;
const ranges: string[] = [];
for (let start = minWeight; start <= maxWeight; start += rangeSize) {
const end = start + rangeSize;
ranges.push(`${start}-${end}`);
}
const totalIdealCount = detailsToUse.filter(
(d) => d.range === 'Ideal'
).length;
return ranges.map((range) => {
const [minStr, maxStr] = range.split('-').map(Number);
const min = minStr;
const max = maxStr;
const birdsInRange = detailsToUse.filter(
(d) => d.weight >= min && d.weight < max
).length;
const hasIdeal = detailsToUse.some(
(d) => d.range === 'Ideal' && d.weight >= min && d.weight < max
);
return {
name: range,
uv: birdsInRange,
isIdeal: hasIdeal,
idealCount: hasIdeal ? totalIdealCount : undefined,
};
});
}, [uniformityData, detailsToUse]);
const gaugeChartData = useMemo(() => {
if (!uniformityData) return undefined;
return {
value: uniformityData.uniformity,
label: 'Uniformity',
week: `Week ${uniformityData.week}`,
currentValue: uniformityData.uniform_qty,
totalValue: uniformityData.chick_qty_of_weight,
};
}, [uniformityData]);
return (
<section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-4'>
@@ -98,14 +100,14 @@ const UniformityChart = () => {
}}
>
<div className='w-full h-full flex items-center justify-center'>
{barChartData.length === 0 ? (
{!uniformityData || barChartData.length === 0 ? (
<UniformityBarChartSkeleton />
) : (
<UniformityBarChart data={barChartData} />
)}
</div>
</Card>
{gaugeChartData.value === 0 ? (
{!uniformityData || !gaugeChartData ? (
<Card
variant='bordered'
title='Weekly Performance ⓘ'
@@ -128,7 +130,6 @@ const UniformityChart = () => {
<UniformityGaugeChart
value={gaugeChartData.value}
label={gaugeChartData.label}
kandang={gaugeChartData.kandang}
week={gaugeChartData.week}
currentValue={gaugeChartData.currentValue}
totalValue={gaugeChartData.totalValue}
@@ -41,9 +41,7 @@ export default function UniformityPageWrapper({
return (
<>
<div className='w-full p-4'>
<UniformityTable
refresh={() => !isOpen && router.push('/production/uniformity')}
/>
<UniformityTable />
</div>
<Drawer
@@ -10,7 +10,11 @@ import Button from '@/components/Button';
import UniformityChart from '@/components/pages/production/uniformity/UniformityChart';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { UniformityApi } from '@/services/api/uniformity';
import { type Uniformity } from '@/types/api/production/uniformity';
import {
DetailOptionType,
type Uniformity,
type UniformityDetail,
} from '@/types/api/production/uniformity';
import { isResponseSuccess } from '@/lib/api-helper';
import { type BaseApiResponse } from '@/types/api/api-general';
import Table from '@/components/Table';
@@ -45,62 +49,59 @@ import Dropdown from '@/components/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
const isUniformityLocked = (uniformity: Uniformity): boolean => {
// Uniformity data is never locked - checkbox is always enabled
return false;
};
const canApproveRejectUniformity = (uniformity: Uniformity): boolean => {
return uniformity.status === 'CREATED' || uniformity.status === 'Pengajuan';
};
interface UniformityPreviewData {
id: string;
label: string;
value: string;
}
const UniformityConfirmationPreview = ({
uniformity,
uniformityDetail,
}: {
uniformity?: Uniformity;
uniformityDetail?: UniformityDetail;
}) => {
const data: UniformityPreviewData[] = [
const data: DetailOptionType[] = [
{
id: 'tanggal',
label: 'Tanggal',
value: uniformity
? formatDate(uniformity.applied_at, 'DD MMM YYYY')
: '-',
: uniformityDetail
? formatDate(uniformityDetail.info_umum.tanggal, 'DD MMM YYYY')
: '-',
},
{
id: 'lokasi-farm',
label: 'Lokasi Farm',
value: uniformity?.location_name || '-',
value:
uniformity?.location_name ||
uniformityDetail?.info_umum?.lokasi_farm ||
'-',
},
{
id: 'project-flock',
label: 'Project Flock',
value: uniformity?.flock_name || '-',
value:
uniformity?.flock_name ||
uniformityDetail?.info_umum?.project_flock ||
'-',
},
{
id: 'kandang',
label: 'Kandang',
value: uniformity?.kandang_name || '-',
value:
uniformity?.kandang_name || uniformityDetail?.info_umum?.kandang || '-',
},
{
id: 'file-uniformity',
label: 'File Uniformity',
value: '-', // File name tidak tersedia di GET ALL response
value:
uniformity?.file_name || uniformityDetail?.info_umum?.file_name || '-',
},
{
id: 'status',
label: 'Status',
value: uniformity?.status || '-',
value: uniformity?.status || (uniformityDetail ? 'CREATED' : '-'),
},
];
const columns: ColumnDef<UniformityPreviewData>[] = [
const columns: ColumnDef<DetailOptionType>[] = [
{
accessorKey: 'label',
header: 'Label',
@@ -148,7 +149,52 @@ const UniformityConfirmationPreview = ({
);
};
const UniformityTable = ({ refresh }: { refresh?: () => void }) => {
const UniformityChartWrapper = ({
uniformitySwrKey,
}: {
uniformitySwrKey: string;
}) => {
const { data: uniformities } = useSWR(
uniformitySwrKey,
UniformityApi.getAllFetcher
);
const uniformityData = useMemo(() => {
if (isResponseSuccess(uniformities) && uniformities?.data?.length > 0) {
return uniformities.data[0];
}
return null;
}, [uniformities]);
const shouldFetchDetails = !!uniformityData;
const uniformityDetailSwrKey = useMemo(() => {
if (!uniformityData) return null;
return `${UniformityApi.basePath}/${uniformityData.id}?with_details=true`;
}, [uniformityData]);
const { data: uniformityDetailResponse } = useSWR(
uniformityDetailSwrKey,
shouldFetchDetails ? UniformityApi.getAllFetcher : null
);
const uniformityDetails = useMemo(() => {
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
const detailData =
uniformityDetailResponse.data as unknown as UniformityDetail;
return detailData.uniformity_details;
}
return undefined;
}, [shouldFetchDetails, uniformityDetailResponse]);
return (
<UniformityChart
uniformityData={uniformityData}
uniformityDetails={uniformityDetails}
/>
);
};
const UniformityTable = () => {
const router = useRouter();
const searchParams = useSearchParams();
const isSuccess = useUniformityStore((s) => s.isSuccess);
@@ -355,6 +401,12 @@ const UniformityTable = ({ refresh }: { refresh?: () => void }) => {
mutate: refreshUniformities,
} = useSWR(uniformitySwrKey, UniformityApi.getAllFetcher);
useEffect(() => {
if (isSuccess) {
refreshUniformities();
}
}, [isSuccess, refreshUniformities]);
// ===== FILTER HANDLERS =====
const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
@@ -408,9 +460,15 @@ const UniformityTable = ({ refresh }: { refresh?: () => void }) => {
const canApproveReject = useMemo(() => {
return (
selectedUniformities.length > 0 &&
selectedUniformities.every(
(u) => u.status === 'CREATED' || u.status === 'Pengajuan'
)
selectedUniformities.every((u) => {
const approvalAction = u.latest_approval?.action;
return (
approvalAction === 'CREATED' ||
approvalAction === 'Pengajuan' ||
(!approvalAction &&
(u.status === 'CREATED' || u.status === 'Pengajuan'))
);
})
);
}, [selectedUniformities]);
@@ -765,7 +823,9 @@ const UniformityTable = ({ refresh }: { refresh?: () => void }) => {
accessorKey: 'status',
header: 'Status',
cell: (props) => {
const status = props.row.original.status;
const uniformity = props.row.original;
const status =
uniformity.latest_approval?.action ?? uniformity.status;
return (
<div className='w-full'>
<Badge
@@ -836,7 +896,7 @@ const UniformityTable = ({ refresh }: { refresh?: () => void }) => {
<div className='my-4 divider'></div>
<section>
<UniformityChart />
<UniformityChartWrapper uniformitySwrKey={uniformitySwrKey} />
</section>
<Card
@@ -898,34 +958,7 @@ const UniformityTable = ({ refresh }: { refresh?: () => void }) => {
<div className='flex flex-col gap-4 mt-4'>
{createdUniformity ? (
<UniformityConfirmationPreview
uniformity={{
id: createdUniformity.id,
location_name: createdUniformity.info_umum.lokasi_farm,
flock_name: createdUniformity.info_umum.project_flock,
kandang_name: createdUniformity.info_umum.kandang,
applied_at: createdUniformity.info_umum.tanggal,
week: 0,
status: 'Pengajuan',
uniformity: createdUniformity.result.uniformity,
cv: createdUniformity.result.cv,
chick_qty_of_weight:
createdUniformity.sampling.chick_qty_of_weight,
uniform_qty: createdUniformity.result.uniform_qty,
mean_up: createdUniformity.sampling.mean_up,
mean_down: createdUniformity.sampling.mean_down,
standard_mean_weight: null,
standard_uniformity: null,
created_at: '',
created_by: 0,
project_flock_kandang_id: 0,
created_user: {
id: 0,
id_user: 0,
email: '',
name: '',
},
updated_at: '',
}}
uniformityDetail={createdUniformity}
/>
) : selectedRowIds.length === 1 ? (
<UniformityConfirmationPreview
@@ -3,6 +3,7 @@ import {
Bar,
BarChart,
CartesianGrid,
Cell,
Rectangle,
ResponsiveContainer,
Tooltip,
@@ -25,6 +26,8 @@ interface CustomTooltipProps {
interface BarChartData {
name: string;
uv: number;
isIdeal?: boolean;
idealCount?: number;
}
interface UniformityBarChartProps {
@@ -33,7 +36,25 @@ interface UniformityBarChartProps {
function CustomTooltip({ payload, label, active }: CustomTooltipProps) {
if (active && payload && payload.length && label !== undefined) {
const data = payload[0] as unknown as { payload: BarChartData };
const chartData = data.payload as BarChartData;
const labelStr = String(label);
if (chartData.isIdeal && chartData.idealCount !== undefined) {
return (
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
<div className='flex items-center gap-2 mt-2 justify-between'>
<div className='flex items-center gap-2'>
<div className='w-5 h-5 bg-[#0069E0] rounded-md'></div>
{chartData.idealCount} of Birds
</div>
<span>{labelStr}</span>
</div>
</div>
);
}
return (
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
@@ -105,9 +126,6 @@ const UniformityBarChart: React.FC<UniformityBarChartProps> = ({ data }) => {
<Bar
name='Birds'
dataKey='uv'
fill='#FFFFFF'
stroke='#DDD'
strokeWidth={2}
radius={[25, 25, 0, 0]}
activeBar={
<Rectangle
@@ -117,7 +135,16 @@ const UniformityBarChart: React.FC<UniformityBarChartProps> = ({ data }) => {
radius={[25, 25, 0, 0]}
/>
}
/>
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.isIdeal ? 'url(#activeBarGradient)' : '#FFFFFF'}
stroke={entry.isIdeal ? '#18181B' : '#DDD'}
strokeWidth={entry.isIdeal ? 0 : 2}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
@@ -1,25 +1,29 @@
import React from 'react';
import React, { useState } from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts';
import Card from '@/components/Card';
import { Icon } from '@iconify/react';
import { formatNumber } from '@/lib/helper';
import { Icon } from '@iconify/react';
interface UniformityGaugeChartProps {
value: number;
label: string;
kandang?: string;
week?: string;
currentValue?: number;
totalValue?: number;
onWeekChange?: (direction: 'prev' | 'next') => void;
hasPrevWeek?: boolean;
hasNextWeek?: boolean;
}
const UniformityGaugeChart: React.FC<UniformityGaugeChartProps> = ({
value,
label,
kandang,
week,
currentValue,
totalValue,
onWeekChange,
hasPrevWeek = false,
hasNextWeek = false,
}) => {
const numberOfSegments = 50;
const filledSegments = Math.round((value / 100) * numberOfSegments);
@@ -34,7 +38,7 @@ const UniformityGaugeChart: React.FC<UniformityGaugeChartProps> = ({
const inactiveColor = '#f0f0f0';
return (
<div className='flex flex-col w-full'>
<div className='flex flex-col w-full items-center'>
<div className='h-64 w-full relative flex justify-center'>
<div className='relative w-full h-full flex flex-col items-center justify-end'>
<ResponsiveContainer width='100%' height='100%'>
@@ -73,34 +77,49 @@ const UniformityGaugeChart: React.FC<UniformityGaugeChartProps> = ({
</div>
</div>
</div>
<Card
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<section className='flex items-center gap-4'>
<div className='w-12 h-12 bg-base-200 rounded-lg flex items-center justify-center border border-gray-200 shrink-0'>
<Icon icon='heroicons:calendar-date-range' width={24} height={24} />
</div>
<div className='grid grid-cols-1 min-w-0'>
<div className='flex items-center space-x-2 text-[#18181B80] text-sm mb-1'>
<span className='font-medium truncate'>{kandang}</span>
<span className='shrink-0'></span>
<span className='text-[#0069E0] font-semibold truncate'>
{week}
</span>
<div className='flex items-center justify-center gap-2 w-full'>
<button
onClick={() => onWeekChange?.('prev')}
disabled={!hasPrevWeek}
className='p-2 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shadow-sm'
aria-label='Previous week'
>
<Icon icon='heroicons:chevron-left' width={20} height={20} />
</button>
<Card
variant='bordered'
className={{
wrapper: 'max-w-xs',
}}
>
<section className='flex items-center justify-center gap-4'>
<div className='grid grid-cols-1 min-w-0 text-center'>
<div className='flex items-center justify-center space-x-2 text-[#18181B80] text-sm mb-1'>
<span className='text-[#0069E0] font-semibold truncate'>
{week}
</span>
</div>
<div className='text-xl font-bold text-[#18181B80]'>
<span className='text-[#0069E0] break-all'>
{formatNumber(currentValue ?? 0)}
</span>
<span className='mx-1 text-gray-400 text-base'>From</span>
<span className='break-all'>
{formatNumber(totalValue ?? 0)}
</span>
</div>
</div>
<div className='text-xl font-bold text-[#18181B80]'>
<span className='text-[#0069E0] break-all'>
{formatNumber(currentValue ?? 0)}
</span>
<span className='mx-1 text-gray-400 text-base'>From</span>
<span className='break-all'>{formatNumber(totalValue ?? 0)}</span>
</div>
</div>
</section>
</Card>
</section>
</Card>
<button
onClick={() => onWeekChange?.('next')}
disabled={!hasNextWeek}
className='p-2 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shadow-sm'
aria-label='Next week'
>
<Icon icon='heroicons:chevron-right' width={20} height={20} />
</button>
</div>
</div>
);
};
@@ -119,11 +119,13 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
const statusValue = latest_approval?.action ?? '-';
const valueMap: Record<string, string> = {
tanggal: formatDate(info_umum.tanggal, 'DD MMMM YYYY'),
'lokasi-farm': info_umum.lokasi_farm,
'project-flock': info_umum.project_flock,
kandang: info_umum.kandang,
'document-name': info_umum.file_name,
tanggal: info_umum?.tanggal
? formatDate(info_umum.tanggal, 'DD MMMM YYYY')
: '-',
'lokasi-farm': info_umum?.lokasi_farm ?? '-',
'project-flock': info_umum?.project_flock ?? '-',
kandang: info_umum?.kandang ?? '-',
'document-name': info_umum?.file_name ?? '-',
'approval-status': statusValue,
};
@@ -229,49 +229,52 @@ const UniformityDetailsPreview = ({
{/* Form Section */}
<div className='divider mt-3.5'></div>
<section className='w-full px-6'>
{uniformity_details && uniformity_details.length > 0 ? (
{info_umum || sampling || result ? (
<div className='flex flex-col gap-4'>
{/* Sampling and Range */}
<div className=''>
<p className='text-sm font-medium mb-5'>Sampling and Range</p>
<Table<DetailOptionType>
data={samplingTableData}
columns={columnsSampling}
pageSize={4}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
{/* Result */}
<div className=''>
<p className='text-sm font-medium mb-5'>Result</p>
<Table<DetailOptionType>
data={resultTableData}
columns={resultColumns}
pageSize={4}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
{sampling && (
<div className=''>
<p className='text-sm font-medium mb-5'>Sampling and Range</p>
<Table<DetailOptionType>
data={samplingTableData}
columns={columnsSampling}
pageSize={4}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
)}
{/* Body Weight Details Button */}
<div className='mt-4'>
<Button
type='button'
onClick={fetchWeightData}
disabled={isLoading}
className='w-full'
>
{isLoading ? 'Loading...' : 'Show Body Weight Details'}
</Button>
</div>
{/*{!uniformity_details || uniformity_details.length === 0 ? (
<></>
) : null}*/}
{/* Result */}
{result && (
<div className=''>
<p className='text-sm font-medium mb-5'>Result</p>
<Table<DetailOptionType>
data={resultTableData}
columns={resultColumns}
pageSize={4}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
)}
{!uniformity_details || uniformity_details.length === 0 ? (
<div className='mt-4'>
<Button
type='button'
onClick={fetchWeightData}
disabled={isLoading}
className='w-full'
>
{isLoading ? 'Loading...' : 'Show Body Weight Details'}
</Button>
</div>
) : null}
{/* Body Weight Details */}
{uniformity_details && uniformity_details.length > 0 && (
@@ -97,12 +97,16 @@ const UniformityForm = ({
setInputValue: setLocationSelectInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
page: '1',
limit: '100',
});
// ===== FETCH PROJECT FLOCKS DATA =====
const projectFlocksUrl = useMemo(() => {
const params = new URLSearchParams({
search: projectFlockSearchValue || '',
page: '1',
limit: '100',
});
if (selectedLocation) {
@@ -141,6 +145,7 @@ const UniformityForm = ({
const approvedProjectFlockKandangsUrl = useMemo(() => {
const params = new URLSearchParams({
step_name: 'Disetujui',
page: '1',
limit: '100',
});
return `${ProjectFlockKandangApi.basePath}?${params.toString()}`;
@@ -28,7 +28,7 @@ const UniformityGaugeChartSkeleton: React.FC<
const inactiveColor = '#f0f0f0';
return (
<div className='flex flex-col w-full'>
<div className='flex flex-col w-full items-center'>
<div className='h-64 w-full relative flex justify-center min-h-[256px]'>
<div className='relative w-full h-full flex flex-col items-center justify-end min-w-0'>
<ResponsiveContainer width='100%' height={256}>