refactor(FE-316,317,438): Move Uniformity feature under production

namespace
This commit is contained in:
rstubryan
2025-12-31 11:17:26 +07:00
parent f3f552bd16
commit a1e8f582ba
28 changed files with 71 additions and 76 deletions
@@ -0,0 +1,142 @@
import React 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';
interface BarChartData {
name: string;
uv: number;
}
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,
},
];
// TODO: Replace with actual API call
// const gaugeChartData: GaugeChartData = {
// value: 0,
// label: '',
// kandang: 'Kandang Cirangga',
// week: 'Week 2',
// currentValue: 512,
// totalValue: 1024,
// };
const gaugeChartData: GaugeChartData = {
value: 52,
label: 'Uniformity',
kandang: 'Kandang Cirangga',
week: 'Week 2',
currentValue: 512,
totalValue: 1024,
};
return (
<section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-4'>
<Card
title='Performance Overview ⓘ'
variant='bordered'
className={{
wrapper: 'xl:col-span-1 2xl:col-span-3 w-full',
body: 'h-96',
}}
>
<div className='w-full h-full flex items-center justify-center'>
{barChartData.length === 0 ? (
<UniformityBarChartSkeleton />
) : (
<UniformityBarChart data={barChartData} />
)}
</div>
</Card>
{gaugeChartData.value === 0 ? (
<Card
variant='bordered'
title='Weekly Performance ⓘ'
className={{
wrapper: 'xl:col-span-1 2xl:col-span-1 w-full',
body: 'h-110',
}}
>
<UniformityGaugeChartSkeleton />
</Card>
) : (
<Card
variant='bordered'
title='Weekly Performance ⓘ'
className={{
wrapper: 'xl:col-span-1 2xl:col-span-1 w-full',
body: 'p-4',
}}
>
<UniformityGaugeChart
value={gaugeChartData.value}
label={gaugeChartData.label}
kandang={gaugeChartData.kandang}
week={gaugeChartData.week}
currentValue={gaugeChartData.currentValue}
totalValue={gaugeChartData.totalValue}
/>
</Card>
)}
</section>
);
};
export default UniformityChart;
@@ -0,0 +1,67 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import Drawer from '@/components/Drawer';
import React, { ReactNode } from 'react';
import UniformityTable from '@/components/pages/production/uniformity/UniformityTable';
import { useUiStore } from '@/stores/ui/ui.store';
export default function UniformityPageWrapper({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const toggleValidate = useUiStore((s) => s.toggleValidate);
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
const expandedDrawerOpen = useUiStore((s) => s.expandedDrawerOpen);
const expandedDrawerContent = useUiStore((s) => s.expandedDrawerContent);
const isAdd = pathname.includes('/add');
const isEdit = pathname.includes('/detail/edit');
const isDetail = pathname.includes('/detail');
const isOpen = isAdd || isEdit || isDetail;
const handleBackdropClick = () => {
const unsub = useUiStore.getState().subscribeIsValid((isValid) => {
if (isValid) {
router.push('/production/uniformity');
unsub?.();
setExpandedDrawerOpen(false);
} else {
unsub?.();
}
});
toggleValidate();
};
return (
<>
<div className='w-full p-4'>
<UniformityTable
refresh={() => !isOpen && router.push('/production/uniformity')}
/>
</div>
<Drawer
open={isOpen}
setOpen={(v) => {
if (!v) {
router.push('/production/uniformity');
setExpandedDrawerOpen(false);
}
}}
closeOnBackdropClick={isDetail ? true : false}
onBackdropClick={handleBackdropClick}
variant='right'
zIndex='99999'
sidebarContent={isOpen ? <div className=''>{children}</div> : null}
expandedContent={expandedDrawerOpen ? expandedDrawerContent : null}
expandedWidth='w-[500px]'
/>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
import React from 'react';
import {
Bar,
BarChart,
CartesianGrid,
Rectangle,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface Payload {
value?: number;
name?: string;
dataKey?: string | number;
}
interface CustomTooltipProps {
active?: boolean;
payload?: readonly Payload[];
label?: string | number;
}
interface BarChartData {
name: string;
uv: number;
}
interface UniformityBarChartProps {
data: BarChartData[];
}
function CustomTooltip({ payload, label, active }: CustomTooltipProps) {
if (active && payload && payload.length && label !== undefined) {
const labelStr = String(label);
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>
{payload[0].value} of Birds
</div>
<span>{labelStr}</span>
</div>
</div>
);
}
return null;
}
const UniformityBarChart: React.FC<UniformityBarChartProps> = ({ data }) => {
const margin = {
top: 20,
right: 30,
left: 20,
bottom: 5,
};
return (
<ResponsiveContainer
width='100%'
height='100%'
className='min-h-[300px] xl:min-h-[350px]'
>
<BarChart data={data} margin={margin} barGap={20}>
<defs>
<linearGradient id='activeBarGradient' x1='0' y1='0' x2='0' y2='1'>
<stop offset='0%' stopColor='#0069E0' stopOpacity={0.01} />
<stop offset='40%' stopColor='#0069E0' stopOpacity={1} />
<stop offset='100%' stopColor='#0069E0' stopOpacity={1} />
</linearGradient>
</defs>
<XAxis
dataKey='name'
axisLine={false}
tickLine={false}
label={{
value: 'Body Weight Range',
position: 'insideBottom',
offset: -5,
style: { textAnchor: 'middle', fontSize: 14, fill: '#18181B33' },
}}
/>
<YAxis
axisLine={false}
tickLine={false}
label={{
value: 'Number of Birds',
angle: -90,
position: 'insideLeft',
style: { textAnchor: 'middle', fontSize: 14, fill: '#18181B33' },
}}
/>
<Tooltip
cursor={false}
content={CustomTooltip}
wrapperStyle={{
width: '200px',
}}
/>
<CartesianGrid vertical={false} />
<Bar
name='Birds'
dataKey='uv'
fill='#FFFFFF'
stroke='#DDD'
strokeWidth={2}
radius={[25, 25, 0, 0]}
activeBar={
<Rectangle
fill='url(#activeBarGradient)'
stroke='#18181B'
strokeWidth={0}
radius={[25, 25, 0, 0]}
/>
}
/>
</BarChart>
</ResponsiveContainer>
);
};
export default UniformityBarChart;
@@ -0,0 +1,108 @@
import React from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts';
import Card from '@/components/Card';
import { Icon } from '@iconify/react';
import { formatNumber } from '@/lib/helper';
interface UniformityGaugeChartProps {
value: number;
label: string;
kandang?: string;
week?: string;
currentValue?: number;
totalValue?: number;
}
const UniformityGaugeChart: React.FC<UniformityGaugeChartProps> = ({
value,
label,
kandang,
week,
currentValue,
totalValue,
}) => {
const numberOfSegments = 50;
const filledSegments = Math.round((value / 100) * numberOfSegments);
const data = Array.from({ length: numberOfSegments }, (_, index) => ({
name: index,
value: 1,
filled: index < filledSegments,
}));
const activeColor = '#1890ff';
const inactiveColor = '#f0f0f0';
return (
<div className='flex flex-col w-full'>
<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%'>
<PieChart>
<Pie
data={data}
cx='50%'
cy='70%'
startAngle={180}
endAngle={0}
innerRadius='75%'
outerRadius='100%'
paddingAngle={2}
dataKey='value'
stroke='none'
isAnimationActive={false}
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.filled ? activeColor : inactiveColor}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className='absolute inset-x-0 bottom-8 flex flex-col items-center justify-center'>
<span className='2xl:text-3xl text-2xl font-bold text-gray-800 mb-4'>
{value}%
</span>
<div className='mt-2 px-4 py-1 bg-base-100 rounded-full shadow-sm border border-gray-200'>
<span className='text-sm font-medium text-gray-700 mb-32'>
{label}
</span>
</div>
</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>
<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>
</div>
);
};
export default UniformityGaugeChart;
@@ -0,0 +1,93 @@
import Badge from '../../../../Badge';
import Card from '@/components/Card';
import { Icon } from '@iconify/react';
import { formatNumber } from '@/lib/helper';
const UniformityStat = () => {
const statisticsData = [
{
title: 'Total Population',
value: 1908978,
icon: 'heroicons-outline:inbox-stack',
change: '15.5%',
changeType: 'increase',
},
{
title: 'Total Uniformity',
value: 954489,
icon: 'heroicons-outline:scale',
change: '50%',
changeType: 'decrease',
},
{
title: 'Total Depletion',
value: 954489,
icon: 'heroicons-outline:inbox-stack',
change: '15.5%',
changeType: 'increase',
},
{
title: 'Total Production',
value: 2534,
icon: 'heroicons-outline:inbox-stack',
change: '15.5%',
changeType: 'increase',
},
];
return (
<section className='grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4'>
{statisticsData.map((stat, index) => (
<Card
key={index}
variant='bordered'
size='sm'
className={{
wrapper: 'w-full',
footer: 'bg-[#F8F8F8]',
}}
footer={
<>
<section className='flex items-center justify-between'>
<span className='font-normal text-gray-500'>
From last month
</span>
<Badge
color={stat.changeType === 'increase' ? 'success' : 'error'}
variant='soft'
className={{ badge: 'rounded-2xl' }}
>
<Icon
icon={
stat.changeType === 'increase'
? 'heroicons-outline:arrow-trending-up'
: 'heroicons-outline:arrow-trending-down'
}
width={16}
height={16}
className='inline-block'
/>
{stat.change}
</Badge>
</section>
</>
}
>
<div className='flex gap-2 items-center'>
<div className='p-2 border rounded-xl border-gray-300 shrink-0'>
<Icon icon={stat.icon} width={32} height={32} />
</div>
<div className='grid grid-cols-1 min-w-0'>
<span className='truncate'>{stat.title}</span>
<span className='text-xl font-semibold break-all'>
{formatNumber(stat.value)}
</span>
</div>
</div>
</Card>
))}
</section>
);
};
export default UniformityStat;
@@ -0,0 +1,236 @@
'use client';
import { useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import Button from '@/components/Button';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import Table from '@/components/Table';
import Badge from '@/components/Badge';
import Tooltip from '@/components/Tooltip';
import RequirePermission from '@/components/helper/RequirePermission';
import { UniformityDetail as UniformityDetailType } from '@/types/api/production/uniformity';
import { formatDate } from '@/lib/helper';
import { useUiStore } from '@/stores/ui/ui.store';
import UniformityDetailsPreview from '@/components/pages/production/uniformity/detail/UniformityDetailsPreview';
import {
getStatusColor,
getStatusIndicatorColor,
getStatusText,
} from '@/components/pages/production/uniformity/uniformity-utils';
import { DetailOptionType } from '@/types/api/production/uniformity';
interface UniformityDetailProps {
initialValues: UniformityDetailType;
}
const UniformityDetail: React.FC<UniformityDetailProps> = ({
initialValues,
}) => {
const router = useRouter();
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
const setExpandedDrawerContent = useUiStore(
(s) => s.setExpandedDrawerContent
);
const handleApprove = () => {
router.push(`/production/uniformity?action=approve&id=${initialValues.id}`);
};
const handleReject = () => {
router.push(`/production/uniformity?action=reject&id=${initialValues.id}`);
};
const handleViewUniformityDetails = () => {
setExpandedDrawerContent(
<UniformityDetailsPreview
info_umum={initialValues.info_umum}
uniformity_details={initialValues.uniformity_details}
sampling={initialValues.sampling}
result={initialValues.result}
uniformityId={initialValues.id}
/>
);
setTimeout(() => {
setExpandedDrawerOpen(true);
}, 0);
};
useEffect(() => {
return () => {
setExpandedDrawerOpen(false);
setExpandedDrawerContent(null);
};
}, []);
const infoUmumTableData: DetailOptionType[] = useMemo(() => {
if (!initialValues) return [];
return [
{
id: 'tanggal',
value: 'tanggal',
label: 'Tanggal',
},
{
id: 'lokasi-farm',
value: 'lokasi-farm',
label: 'Lokasi Farm',
},
{
id: 'project-flock',
value: 'project-flock',
label: 'Project Flock',
},
{
id: 'kandang',
value: 'kandang',
label: 'Kandang',
},
{
id: 'document-name',
value: 'document-name',
label: 'File Uniformity',
},
{
id: 'approval-status',
value: 'approval-status',
label: 'Status',
},
];
}, [initialValues]);
const columnsInfoUmum: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => {
const id = props.row.original.id;
const { info_umum, latest_approval } = initialValues!;
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,
'approval-status': statusValue,
};
if (id === 'approval-status') {
const status = latest_approval?.action;
if (status) {
return (
<div className='w-full'>
<Badge
statusIndicator={true}
variant='soft'
className={{
badge: `rounded-xl w-full justify-start border border-gray-200 text-black ${getStatusColor(status)}`,
status: getStatusIndicatorColor(status),
}}
>
{getStatusText(status)}
</Badge>
</div>
);
}
return <span>-</span>;
}
if (id === 'document-name') {
return (
<div className='flex items-center gap-2'>
<span>{valueMap[id]}</span>
<Tooltip content='Lihat Detail'>
<button
className='p-1 hover:bg-gray-100 rounded cursor-pointer'
onClick={handleViewUniformityDetails}
>
<Icon icon='mdi:eye-outline' width={18} height={18} />
</button>
</Tooltip>
</div>
);
}
return <span>{valueMap[id] || '-'}</span>;
},
},
],
[initialValues]
);
return (
<section className='w-full h-full bg-white border-l border-gray-200'>
{/* Header */}
<DrawerHeader
leftIconHref='/production/uniformity'
subtitle={`Details`}
subtitleClassName='text-sm text-neutral'
showDivider
/>
{/* Form Section */}
<div className='divider mt-3.5'></div>
<section className='w-full px-6'>
{initialValues ? (
<div className='flex flex-col gap-4'>
{/* Info Umum */}
<div className=''>
<p className='text-sm font-medium mb-5'>Informasi Umum</p>
<Table<DetailOptionType>
data={infoUmumTableData}
columns={columnsInfoUmum}
pageSize={6}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
{/* Approve/Reject Buttons */}
{initialValues.result &&
initialValues.latest_approval?.step_name === 'CREATED' ? (
<>
<div className='divider my-3.5' />
<RequirePermission permissions='lti.production.uniformity.approve'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 [&_button]:rounded-lg'>
<Button variant='outline' onClick={handleReject}>
Reject
</Button>
<Button onClick={handleApprove}>Approve</Button>
</div>
</RequirePermission>
</>
) : null}
</div>
</div>
) : (
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
<Icon
icon='mdi:file-document-outline'
width={64}
height={64}
className='mb-4'
/>
<p className='text-lg'>No data available</p>
<p className='text-sm'>Uniformity detail not found</p>
</div>
)}
</section>
</section>
);
};
export default UniformityDetail;
@@ -0,0 +1,305 @@
'use client';
import React, { useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store';
import {
UniformityDetailItem,
UniformitySampling,
UniformityResult,
UniformityInfoUmum,
} from '@/types/api/production/uniformity';
import Table from '@/components/Table';
import Badge from '@/components/Badge';
import { formatNumber } from '@/lib/helper';
import { DetailOptionType } from '@/types/api/production/uniformity';
import {
getWeightStatusColor,
getWeightStatusIndicatorColor,
getWeightStatusText,
} from '@/components/pages/production/uniformity/uniformity-utils';
import { BodyWeightData } from '@/types/api/production/uniformity';
import Button from '@/components/Button';
import { UniformityApi } from '@/services/api/uniformity';
import useSWR from 'swr';
import { isResponseSuccess } from '@/lib/api-helper';
interface UniformityDetailsPreviewProps {
info_umum: UniformityInfoUmum;
sampling: UniformitySampling;
result: UniformityResult;
uniformity_details?: UniformityDetailItem[];
uniformityId: number;
}
const UniformityDetailsPreview = ({
info_umum,
uniformity_details: initialUniformityDetails,
sampling,
result,
uniformityId,
}: UniformityDetailsPreviewProps) => {
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
const [shouldFetchDetails, setShouldFetchDetails] = useState(false);
const { data: uniformityDetailResponse, isLoading } = useSWR(
shouldFetchDetails
? `uniformity-detail-${uniformityId}-with-details`
: null,
() => UniformityApi.getUniformityDetail(uniformityId, true)
);
const uniformity_details = useMemo(() => {
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
return uniformityDetailResponse.data.uniformity_details;
}
return initialUniformityDetails;
}, [shouldFetchDetails, uniformityDetailResponse, initialUniformityDetails]);
const handleClose = () => {
setExpandedDrawerOpen(false);
};
const fetchWeightData = () => {
setShouldFetchDetails(true);
};
const samplingTableData: DetailOptionType[] = useMemo(() => {
if (!sampling) return [];
return [
{
id: 'sampling-size',
label: 'Sampling size',
value: `${formatNumber(sampling.chick_qty_of_weight)} of Birds`,
},
{
id: 'mean-weight',
label: 'Mean Weight',
value: `${sampling.mean_weight} g`,
},
{
id: 'min-limit',
label: 'Min Limit (-10%)',
value: `${sampling.mean_down} g`,
},
{
id: 'max-limit',
label: 'Max Limit (+10%)',
value: `${sampling.mean_up} g`,
},
];
}, [sampling]);
const columnsSampling: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => <span>{props.row.original.value}</span>,
},
],
[]
);
const resultTableData: DetailOptionType[] = useMemo(() => {
if (!result) return [];
return [
{
id: 'ideal-birds',
label: 'Ideal Birds',
value: `${formatNumber(result.uniform_qty)} of Birds`,
},
{
id: 'outside-range',
label: 'Outside Range',
value: `${formatNumber(result.outside_qty)} of Birds`,
},
{
id: 'uniformity',
label: 'Uniformity',
value: `${result.uniformity} %`,
},
];
}, [result]);
const resultColumns: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => <span>{props.row.original.value}</span>,
},
],
[]
);
const tableData = useMemo(() => {
if (!uniformity_details) return [];
return uniformity_details.map(
(detail: UniformityDetailItem, index: number) => ({
id: `body-weight-${index + 1}`,
number: index + 1,
weight: detail.weight,
status: detail.range.toLowerCase() as 'ideal' | 'outside',
})
);
}, [uniformity_details]);
const columnsUniformity: ColumnDef<BodyWeightData>[] = useMemo(
() => [
{
accessorKey: 'number',
header: 'No',
cell: (props) => props.row.original.number,
},
{
accessorKey: 'weight',
header: 'Weight (g)',
cell: (props) => <span>{props.row.original.weight}</span>,
},
{
accessorKey: 'status',
header: 'Status',
cell: (props) => {
const status = props.row.original.status;
return status ? (
<div className='w-full'>
<Badge
statusIndicator={true}
variant='soft'
className={{
badge: `rounded-xl w-full justify-start border border-gray-200 text-black ${getWeightStatusColor(status)}`,
status: getWeightStatusIndicatorColor(status),
}}
>
{getWeightStatusText(status)}
</Badge>
</div>
) : (
<Badge
statusIndicator={true}
variant='soft'
className={{
badge:
'rounded-xl w-full justify-start border border-gray-200 text-black bg-info/10',
status: 'bg-info',
}}
>
Unknown
</Badge>
);
},
},
],
[]
);
return (
<section className='w-full h-full bg-white border-l border-gray-200'>
{/* Header */}
<DrawerHeader
leftIcon=''
subtitle={info_umum?.file_name ?? 'Uniformity Details'}
subtitleClassName='text-sm text-neutral line-clamp-1'
showDivider={false}
>
<button
className='p-0 text-error hover:bg-transparent hover:text-error/50 cursor-pointer'
onClick={handleClose}
>
<Icon icon='mdi:close' width={24} height={24} />
</button>
</DrawerHeader>
{/* Form Section */}
<div className='divider mt-3.5'></div>
<section className='w-full px-6'>
{uniformity_details && uniformity_details.length > 0 ? (
<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>
{/* 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}*/}
{/* Body Weight Details */}
{uniformity_details && uniformity_details.length > 0 && (
<div className='mt-4'>
<Table<BodyWeightData>
data={tableData}
columns={columnsUniformity}
pageSize={15}
className={{ containerClassName: 'mb-5' }}
/>
</div>
)}
</div>
) : (
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
<Icon
icon='mdi:file-document-outline'
width={64}
height={64}
className='mb-4'
/>
<p className='text-lg'>No data available</p>
<p className='text-sm'>Uniformity details not found</p>
</div>
)}
</section>
</section>
);
};
export default UniformityDetailsPreview;
@@ -0,0 +1,86 @@
'use client';
import * as XLSX from 'xlsx';
import type { Uniformity } from '@/types/api/production/uniformity';
import { formatDate, formatNumber } from '@/lib/helper';
interface UniformityExportExcelParams {
data: Uniformity[];
params: {
location_name?: string;
project_flock_name?: string;
kandang_name?: string;
start_date?: string;
end_date?: string;
};
}
const getStatusText = (status: string) => {
switch (status) {
case 'APPROVED':
return 'Disetujui';
case 'REJECTED':
return 'Ditolak';
case 'CREATED':
return 'Pengajuan';
default:
return status;
}
};
export const generateUniformityExcel = (
data: UniformityExportExcelParams['data'],
params: UniformityExportExcelParams['params']
): void => {
if (!data || data.length === 0) {
return;
}
const excelData: { [key: string]: string | number }[] = data.map(
(item: Uniformity, index: number) => ({
No: index + 1,
Lokasi: item.location_name || '',
'Project Flock': item.flock_name || '',
Kandang: item.kandang_name || '',
Tanggal: formatDate(item.applied_at, 'DD MMM YYYY'),
Minggu: item.week || 0,
Status: getStatusText(item.status),
'Uniformity (%)': formatNumber(item.uniformity),
'CV (%)': formatNumber(item.cv),
'Chick Qty': formatNumber(item.chick_qty_of_weight),
'Uniform Qty': formatNumber(item.uniform_qty),
'Mean Up': formatNumber(item.mean_up),
'Mean Down': formatNumber(item.mean_down),
})
);
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 6 }, // No
{ wch: 25 }, // Lokasi
{ wch: 20 }, // Project Flock
{ wch: 15 }, // Kandang
{ wch: 15 }, // Tanggal
{ wch: 10 }, // Minggu
{ wch: 12 }, // Status
{ wch: 15 }, // Uniformity (%)
{ wch: 10 }, // CV (%)
{ wch: 12 }, // Chick Qty
{ wch: 12 }, // Uniform Qty
{ wch: 12 }, // Mean Up
{ wch: 12 }, // Mean Down
];
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Uniformity');
const period =
params.start_date && params.end_date
? `${params.start_date}-${params.end_date}`
: formatDate(new Date(), 'YYYY-MM-DD');
const filename = `laporan-uniformity-${period}.xlsx`;
XLSX.writeFile(workbook, filename);
};
@@ -0,0 +1,339 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { formatDate, formatNumber } from '@/lib/helper';
import type { Uniformity } from '@/types/api/production/uniformity';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'left',
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
badge: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
padding: 2,
borderRadius: 2,
fontSize: 7,
fontWeight: 'bold',
alignSelf: 'center',
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface UniformityExportPDFParams {
data: Uniformity[];
params: {
location_name?: string;
project_flock_name?: string;
kandang_name?: string;
start_date?: string;
end_date?: string;
};
}
const getParameterText = (params: UniformityExportPDFParams['params']) => {
const paramsText = [];
if (params.location_name && params.location_name !== 'Semua Lokasi') {
paramsText.push(`Lokasi: ${params.location_name}`);
}
if (
params.project_flock_name &&
params.project_flock_name !== 'Semua Project Flock'
) {
paramsText.push(`Project Flock: ${params.project_flock_name}`);
}
if (params.kandang_name && params.kandang_name !== 'Semua Kandang') {
paramsText.push(`Kandang: ${params.kandang_name}`);
}
if (params.start_date && params.end_date) {
const formattedStartDate = formatDate(params.start_date, 'DD MMM YYYY');
const formattedEndDate = formatDate(params.end_date, 'DD MMM YYYY');
paramsText.push(`Periode: ${formattedStartDate} - ${formattedEndDate}`);
} else if (params.start_date) {
const formattedStartDate = formatDate(params.start_date, 'DD MMM YYYY');
paramsText.push(`Tanggal Mulai: ${formattedStartDate}`);
} else if (params.end_date) {
const formattedEndDate = formatDate(params.end_date, 'DD MMM YYYY');
paramsText.push(`Tanggal Akhir: ${formattedEndDate}`);
}
const currentDate = formatDate(new Date().toISOString(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText;
};
const getStatusText = (status: string) => {
switch (status) {
case 'APPROVED':
return 'Disetujui';
case 'REJECTED':
return 'Ditolak';
case 'CREATED':
return 'Pengajuan';
default:
return status;
}
};
const createPDFDocument = (
data: UniformityExportPDFParams['data'],
params: UniformityExportPDFParams['params']
) => {
return (
<Document>
<Page size='A4' orientation='landscape' style={pdfStyles.page}>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>Production &gt; Uniformity</Text>
<View style={pdfStyles.parameterContainer}>
{getParameterText(params).map((param, index) => (
<View key={index} style={pdfStyles.parameterBadge}>
<Text>{param}</Text>
</View>
))}
</View>
</View>
{/* Table */}
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Lokasi</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Project Flock</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Kandang</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Tanggal (Week)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Status</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Uniformity (%)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>CV (%)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Chick Qty</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Uniform Qty</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Mean Up</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Mean Down</Text>
</View>
</View>
{/* Table Body */}
{data.map((item: Uniformity, index: number) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < data.length - 1 ? pdfStyles.tableBorderBottom : {},
]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.location_name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.flock_name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>{item.kandang_name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>
{formatDate(item.applied_at, 'DD MMM YYYY')} (Week {item.week}
)
</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<View style={pdfStyles.badge}>
<Text>{getStatusText(item.status)}</Text>
</View>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.uniformity)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.cv)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(item.chick_qty_of_weight)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.uniform_qty)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.mean_up)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.mean_down)}</Text>
</View>
</View>
))}
</View>
</Page>
</Document>
);
};
export const generateUniformityPDF = async (
data: UniformityExportPDFParams['data'],
params: UniformityExportPDFParams['params']
): Promise<void> => {
const PDFDocument = createPDFDocument(data, params);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const period =
params.start_date && params.end_date
? `${params.start_date}-${params.end_date}`
: formatDate(new Date(), 'YYYY-MM-DD');
link.download = `laporan-uniformity-${period}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw error;
}
};
@@ -0,0 +1,104 @@
import { formatNumber, formatDate } from '@/lib/helper';
import { toast } from 'react-hot-toast';
import * as XLSX from 'xlsx';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
export const generateUniformityTemplate = (
population: number,
projectFlockKandangLookup: ProjectFlockKandangLookup
) => {
try {
const sampleSize = Math.round(population * 0.02);
const kandangName = projectFlockKandangLookup.kandang?.name || 'Kandang';
const flockName = projectFlockKandangLookup.project_flock?.flock_name || '';
const flockPeriod = projectFlockKandangLookup.project_flock?.period || 1;
const locationName =
projectFlockKandangLookup.project_flock?.location?.name || '';
const instructions = [
['PETUNJUK PENGISIAN DATA UNIFORMITY'],
[''],
['INFORMASI FLOCK'],
['Lokasi', locationName],
['Nama Flock', flockName],
['Periode', flockPeriod],
['Kandang', kandangName],
['Total Populasi', formatNumber(population)],
['Jumlah Sampel (2%)', formatNumber(sampleSize)],
[''],
['CARA PENGISIAN:'],
['1. Pindah ke sheet ke-2 untuk mengisi data BW (Body Weight).'],
[
'2. Kolom NO sudah terisi otomatis dari 1 sampai ' +
formatNumber(sampleSize),
],
['3. Isi kolom BW dengan berat badan ayam dalam gram.'],
['4. Pastikan baris terisi dengan data yang valid.'],
['5. Simpan file dan upload kembali ke sistem.'],
[''],
['FORMAT DATA:'],
['• NO: Nomor urut ayam (1, 2, 3, ...).'],
['• BW: Berat badan dalam gram (contoh: 1500, 1650, 1800).'],
[''],
['CATATAN:'],
[
'1. File ini dibuat secara otomatis berdasarkan ukuran sampling (2% dari total populasi).',
],
[
'2. Jumlah baris sudah ditentukan dan boleh ditambah asal angkanya berurutan.',
],
['3. Silakan isi berat badan (gram) untuk setiap ayam yang disampling.'],
[
'4. Biarkan sel kosong jika data tidak tersedia, jangan dihapus nomornya.',
],
];
const instructionSheet = XLSX.utils.aoa_to_sheet(instructions);
instructionSheet['!cols'] = [
{ wch: 30 }, // Column A width
{ wch: 40 }, // Column B width
];
const data = Array.from({ length: sampleSize }, (_, index) => ({
NO: index + 1,
BW: '',
}));
const dataWorksheet = XLSX.utils.json_to_sheet(data, {
header: ['NO', 'BW'],
});
dataWorksheet['!cols'] = [{ wch: 10 }, { wch: 15 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, instructionSheet, 'Instruksi');
const dataSheetName =
kandangName.length > 31 ? kandangName.substring(0, 31) : kandangName;
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
const sanitizedFlockName = flockName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const sanitizedKandangName = kandangName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const filename = `${formatDate(
new Date(),
'YYYY-MM-DD'
)}-${sanitizedFlockName}-${sanitizedKandangName}-periode-${flockPeriod}-data-${sampleSize}.xlsx`;
XLSX.writeFile(workbook, filename);
toast.success(
`Template berhasil dibuat dengan ${formatNumber(sampleSize)} baris data (2% dari ${formatNumber(population)} populasi).`
);
} catch (error) {
console.error('Error generating uniformity template:', error);
toast.error('Gagal membuat template Excel. Silakan coba lagi.');
}
};
@@ -0,0 +1,105 @@
import * as Yup from 'yup';
import { Uniformity } from '@/types/api/production/uniformity';
type UniformityFormSchemaType = {
date: string;
week: number;
location?: {
value: number;
label: string;
} | null;
location_id: number;
project_flock?: {
value: number;
label: string;
} | null;
project_flock_id: number;
project_flock_kandang_id: number | null;
kandang?: {
value: number;
label: string;
} | null;
kandang_id: number;
document: File | undefined;
};
const FileSchema = Yup.mixed<File>()
.test('documentSize', 'Ukuran file maksimal 2 MB', (value): boolean => {
if (!value) return true;
if (value instanceof File) return value.size <= 2 * 1024 * 1024;
return false;
})
.test('documentType', 'Format file harus Excel', (value): boolean => {
if (!value) return true;
if (value instanceof File) {
const allowedTypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
];
return allowedTypes.includes(value.type);
}
return false;
});
export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> =
Yup.object({
date: Yup.string().required('Tanggal wajib diisi!'),
week: Yup.number()
.min(1, 'Minggu ke wajib diisi!')
.required('Minggu ke wajib diisi!')
.typeError('Minggu ke wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
location_id: Yup.number()
.min(1, 'Location wajib diisi!')
.required('Location wajib diisi!')
.typeError('Location wajib diisi!'),
project_flock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
project_flock_id: Yup.number()
.min(1, 'Project flock wajib diisi!')
.required('Project flock wajib diisi!')
.typeError('Project flock wajib diisi!'),
project_flock_kandang_id: Yup.number().optional().nullable().default(null),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!')
.typeError('Kandang wajib diisi!'),
document: FileSchema.required('File wajib diisi!'),
});
export type UniformityFormValues = Yup.InferType<typeof UniformityFormSchema>;
export type UniformityFormData = {
date: string;
week: number;
project_flock_kandang_id: number;
document: File | null;
document_name: string;
};
export const getUniformityFormInitialValues = (
initialValues?: Partial<Uniformity>
): UniformityFormValues => {
return {
date: initialValues?.week ? '' : '',
week: initialValues?.week ?? 0,
location: null,
location_id: 0,
project_flock: null,
project_flock_id: 0,
project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? null,
kandang: null,
kandang_id: 0,
document: undefined,
};
};
@@ -0,0 +1,708 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast';
import moment from 'moment';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store';
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RequirePermission from '@/components/helper/RequirePermission';
import {
UniformityFormSchema,
UniformityFormValues,
getUniformityFormInitialValues,
} from '@/components/pages/production/uniformity/form/UniformityForm.schema';
import { LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import { UniformityApi } from '@/services/api/uniformity';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
Uniformity,
VerifyUniformityPayload,
} from '@/types/api/production/uniformity';
import { type BaseApiResponse } from '@/types/api/api-general';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
import { generateUniformityTemplate } from '@/components/pages/production/uniformity/export/UniformityTemplate';
import useSWR from 'swr';
import { cn, formatNumber } from '@/lib/helper';
import Tooltip from '@/components/Tooltip';
interface UniformityFormProps {
formType?: 'add' | 'edit';
initialValues?: Uniformity;
}
const UniformityForm = ({
formType = 'add',
initialValues,
}: UniformityFormProps) => {
const router = useRouter();
const subscribeValidate = useUiStore((s) => s.subscribeValidate);
const setIsValid = useUiStore((s) => s.setIsValid);
const expandedDrawerOpen = useUiStore((s) => s.expandedDrawerOpen);
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
const setExpandedDrawerContent = useUiStore(
(s) => s.setExpandedDrawerContent
);
const isNextStep = useUiStore((s) => s.isNextStep);
const setIsNextStep = useUiStore((s) => s.setIsNextStep);
const setVerifyUniformityResult = useUniformityStore(
(s) => s.setVerifyUniformityResult
);
const setUniformityFormData = useUniformityStore(
(s) => s.setUniformityFormData
);
const uniformityStep = useUniformityStore((s) => s.uniformityStep);
const setUniformityStep = useUniformityStore((s) => s.setUniformityStep);
const [uniformityFormErrorMessage, setUniformityFormErrorMessage] =
useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// ===== SELECT INPUT DATA =====
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState('');
const [selectedProjectFlock, setSelectedProjectFlock] =
useState<OptionType | null>(null);
const [selectedKandang, setSelectedKandang] = useState<OptionType | null>(
null
);
const {
setInputValue: setLocationSelectInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
// ===== FETCH PROJECT FLOCKS DATA =====
const projectFlocksUrl = useMemo(() => {
const params = new URLSearchParams({
search: projectFlockSearchValue || '',
limit: '100',
});
if (selectedLocation) {
params.append('location_id', selectedLocation.value.toString());
}
return `${ProjectFlockApi.basePath}?${params.toString()}`;
}, [projectFlockSearchValue, selectedLocation]);
const { data: projectFlocksData, isLoading: isLoadingProjectFlocks } = useSWR(
projectFlocksUrl,
ProjectFlockApi.getAllFetcher
);
const projectFlocksDataList =
projectFlocksData?.status === 'success'
? projectFlocksData.data
: undefined;
// ===== PROJECT FLOCK OPTIONS =====
const projectFlockOptions = useMemo(() => {
let options: OptionType[] = [];
if (isResponseSuccess(projectFlocksData)) {
const flockOptions =
projectFlocksData?.data.map((projectFlock) => ({
value: projectFlock.id,
label: projectFlock.flock_name || '',
})) || [];
options = options.concat(flockOptions);
}
return options;
}, [projectFlocksData]);
// ===== APPROVED PROJECT FLOCK KANDANGS =====
const approvedProjectFlockKandangsUrl = useMemo(() => {
const params = new URLSearchParams({
step_name: 'Disetujui',
limit: '100',
});
return `${ProjectFlockKandangApi.basePath}?${params.toString()}`;
}, []);
const { data: approvedProjectFlockKandangsData } = useSWR(
approvedProjectFlockKandangsUrl,
ProjectFlockKandangApi.getAllFetcher
);
const approvedProjectFlockKandangs = useMemo(() => {
if (!isResponseSuccess(approvedProjectFlockKandangsData)) return [];
return approvedProjectFlockKandangsData.data;
}, [approvedProjectFlockKandangsData]);
// ===== KANDANG OPTIONS (FILTERED BY SELECTED PROJECT FLOCK) =====
const kandangOptions = useMemo(() => {
let options: OptionType[] = [];
if (selectedProjectFlock && projectFlocksDataList) {
const selectedProjectFlockData = projectFlocksDataList.find(
(pf) => pf.id === selectedProjectFlock.value
);
if (selectedProjectFlockData?.kandangs) {
const approvedKandangIds = approvedProjectFlockKandangs
.filter((pfk) => pfk.project_flock_id === selectedProjectFlock.value)
.map((pfk) => pfk.kandang_id);
const kandangOpts = selectedProjectFlockData.kandangs
.filter((kandang: Kandang) => {
if (formType === 'add') {
return approvedKandangIds.includes(kandang.id);
}
return true;
})
.map((kandang: Kandang) => ({
value: kandang.id,
label: kandang.name || '',
}));
options = options.concat(kandangOpts);
}
}
return options;
}, [
selectedProjectFlock,
projectFlocksDataList,
approvedProjectFlockKandangs,
formType,
]);
// ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => {
if (!selectedProjectFlock || !selectedKandang) return null;
const params = new URLSearchParams({
project_flock_id: selectedProjectFlock.value.toString(),
kandang_id: selectedKandang.value.toString(),
withpopulation: Boolean(true).toString(),
});
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
}, [selectedProjectFlock, selectedKandang]);
const { data: projectFlockKandangLookupData } = useSWR(
projectFlockKandangLookupUrl,
projectFlockKandangLookupUrl
? () =>
ProjectFlockApi.getAllFetcher(
projectFlockKandangLookupUrl
) as Promise<BaseApiResponse<ProjectFlockKandangLookup>>
: null
);
const projectFlockKandangLookup =
projectFlockKandangLookupData?.status === 'success'
? projectFlockKandangLookupData.data
: undefined;
// ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<UniformityFormValues>(
() => getUniformityFormInitialValues(initialValues),
[initialValues]
);
const formik = useFormik<UniformityFormValues>({
initialValues: formikInitialValues,
validationSchema: UniformityFormSchema,
validateOnChange: true,
validateOnBlur: true,
validateOnMount: false,
enableReinitialize: true,
onSubmit: async (values) => {
const projectFlockKandangId = projectFlockKandangLookup?.id;
if (!projectFlockKandangId) {
setUniformityFormErrorMessage(
'Project Flock Kandang tidak ditemukan. Silakan pilih Project Flock dan Kandang yang valid.'
);
return;
}
setUniformityFormData({
date: values.date,
week: values.week,
project_flock_kandang_id: projectFlockKandangId,
document: values.document as File,
document_name: (values.document as File).name,
});
const payload: VerifyUniformityPayload = {
document: values.document as File,
};
const res = await UniformityApi.verifyUniformity(payload);
if (isResponseError(res)) {
setUniformityFormErrorMessage(res.message);
return;
}
if (isResponseSuccess(res) && res.data) {
setVerifyUniformityResult(res.data);
}
toast.success(res?.message as string);
if (formType === 'add') {
setIsNextStep(true);
setExpandedDrawerOpen(true);
setUniformityStep('preview');
} else {
router.push('/production/uniformity');
}
},
});
// ===== FORM HANDLERS =====
const handleLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = Number(location?.value);
formik.setFieldTouched('location', true);
formik.setFieldValue('location', location);
formik.setFieldTouched('location_id', true);
formik.setFieldValue('location_id', locationId);
setSelectedLocation(location);
},
[]
);
const handleProjectFlockChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const projectFlock = val as OptionType | null;
const projectFlockId = Number(projectFlock?.value);
formik.setFieldTouched('project_flock', true);
formik.setFieldValue('project_flock', projectFlock);
formik.setFieldTouched('project_flock_id', true);
formik.setFieldValue('project_flock_id', projectFlockId);
setSelectedProjectFlock(projectFlock);
},
[]
);
const handleKandangChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const kandang = val as OptionType | null;
const kandangId = Number(kandang?.value);
formik.setFieldTouched('kandang', true);
formik.setFieldValue('kandang', kandang);
formik.setFieldTouched('kandang_id', true);
formik.setFieldValue('kandang_id', kandangId);
setSelectedKandang(kandang);
},
[]
);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const document = e.target.files?.[0];
formik.setFieldTouched('document', true);
if (!document) {
formik.setFieldValue('document', undefined);
return;
}
if (document.size > 2 * 1024 * 1024) {
toast.error(`Ukuran file ${document.name} maksimal 2 MB!`);
return;
}
const allowedTypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
];
if (!allowedTypes.includes(document.type)) {
toast.error(`Format file ${document.name} harus Excel atau CSV!`);
return;
}
formik.setFieldValue('document', document);
},
[]
);
const handleDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('date', e.target.value);
},
[]
);
const handleRemoveFile = useCallback(() => {
formik.setFieldValue('document', undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [formik]);
const handleDownloadTemplate = useCallback(() => {
const population = projectFlockKandangLookup?.population;
if (!population || !projectFlockKandangLookup) {
toast.error('Silakan pilih Project Flock dan Kandang terlebih dahulu.');
return;
}
generateUniformityTemplate(population, projectFlockKandangLookup);
}, [projectFlockKandangLookup]);
// ===== SIDE EFFECTS =====
useEffect(() => {
if (formik.values.date) {
const date = moment(formik.values.date);
const weekNumber = date.week() - moment(date).startOf('month').week() + 1;
const adjustedWeekNumber = weekNumber <= 0 ? weekNumber + 52 : weekNumber;
formik.setFieldValue('week', adjustedWeekNumber);
}
}, [formik.values.date]);
useEffect(() => {
const unsub = subscribeValidate(() => {
setIsValid(true);
});
return () => {
unsub?.();
useUiStore.getState().setExpandedDrawerOpen(false);
useUiStore.getState().setExpandedDrawerContent(null);
useUiStore.getState().setIsNextStep(false);
useUniformityStore.getState().setUniformityStep('preview');
useUniformityStore.getState().setVerifyUniformityResult(null);
};
}, [subscribeValidate, setIsValid]);
useEffect(() => {
if (expandedDrawerOpen) {
if (uniformityStep === 'preview') {
setExpandedDrawerContent(<UniformityPreviewForm />);
} else if (uniformityStep === 'result') {
setExpandedDrawerContent(<UniformityResultForm />);
}
} else {
setExpandedDrawerContent(null);
setIsNextStep(false);
setUniformityStep('preview');
setVerifyUniformityResult(null);
}
}, [
expandedDrawerOpen,
uniformityStep,
setExpandedDrawerContent,
setIsNextStep,
setUniformityStep,
setVerifyUniformityResult,
]);
return (
<>
<section className='w-full'>
<DrawerHeader
leftIcon={formType == 'add' ? 'mdi:close' : 'mdi:arrow-left'}
leftIconSize={24}
leftIconHref={
formType == 'add'
? '/production/uniformity'
: `/production/uniformity/detail`
}
leftIconClassName='hover:text-gray-400'
subtitle={formType == 'add' ? 'Add Uniformity' : 'Update Uniformity'}
subtitleClassName='text-sm text-neutral'
showDivider
/>
<div className='divider mt-3'></div>
<section className='w-full px-6 mb-6'>
<h2 className='text-2xl font-semibold mb-6'>Informasi Umum</h2>
<form onSubmit={formik.handleSubmit} className='flex flex-col gap-6'>
{uniformityFormErrorMessage && (
<div className='alert alert-error' role='alert'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{uniformityFormErrorMessage}</span>
</div>
)}
<DateInput
required
label='Tanggal'
name='date'
value={formik.values.date}
onChange={handleDateChange}
onBlur={formik.handleBlur}
isError={formik.touched.date && Boolean(formik.errors.date)}
errorMessage={formik.errors.date as string}
/>
<SelectInput
required
label='Lokasi'
placeholder='Pilih Lokasi...'
value={formik.values.location}
onChange={handleLocationChange}
options={locationOptions}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id as string}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
required
label='Project Flock'
placeholder='Pilih Project Flock...'
value={formik.values.project_flock}
onChange={handleProjectFlockChange}
options={projectFlockOptions}
onInputChange={setProjectFlockSearchValue}
isLoading={isLoadingProjectFlocks}
isDisabled={!formik.values.location_id}
isError={
formik.touched.project_flock_id &&
Boolean(formik.errors.project_flock_id)
}
errorMessage={formik.errors.project_flock_id as string}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
required
label='Kandang'
placeholder='Pilih Kandang...'
value={formik.values.kandang}
onChange={handleKandangChange}
options={kandangOptions}
isDisabled={!formik.values.project_flock_id}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
errorMessage={formik.errors.kandang_id as string}
isClearable
className={{ wrapper: 'w-full' }}
/>
<div>
<div className='flex items-center justify-between'>
<label
htmlFor='file-upload-input'
className={cn(
"w-full text-sm font-normal leading-5 after:content-['*'] after:ml-0.5 after:text-red-500",
formik.touched.document &&
formik.errors.document &&
'text-red-500'
)}
>
Upload File
</label>
{formik.values.document && !isNextStep ? (
<button
onClick={handleRemoveFile}
className='cursor-pointer'
type='button'
>
<Icon
icon='heroicons-solid:trash'
width={20}
height={20}
className='text-gray-400 hover:text-gray-600'
/>
</button>
) : !formik.values.document && !isNextStep ? (
<button className='cursor-pointer' type='button'>
<Tooltip
position='left'
content='Pastikan file yang diunggah sesuai dengan template yang disediakan, template akan menyesuaikan dengan jumlah populasi pada kandang yang dipilih.'
>
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='text-gray-400 hover:text-gray-600'
/>
</Tooltip>
</button>
) : null}
</div>
<section
className={cn(
'h-full w-full border rounded-2xl border-dashed cursor-pointer mt-2',
formik.touched.document && formik.errors.document
? 'border-red-500'
: 'border-gray-300'
)}
onClick={() =>
document.getElementById('file-upload-input')?.click()
}
>
{formik.values.document ? (
<div className='flex flex-col items-center justify-center gap-2 my-10'>
<div className='border border-[#18181B]/25 rounded-2xl p-1 flex items-center justify-center'>
<Button
type='button'
className='rounded-2xl border border-sky-500 bg-[#0069E0] text-white'
onClick={(e) => {
e.stopPropagation();
document.getElementById('file-upload-input')?.click();
}}
>
<Icon
icon={'heroicons:document-text'}
className='text-2xl text-white'
/>
</Button>
</div>
<span className='text-md font-semibold text-black line-clamp-2 text-center max-w-xs break-all'>
{formik.values.document.name}
</span>
</div>
) : (
<>
<div
className={`flex flex-col items-center justify-center text-center gap-2 ${projectFlockKandangLookup?.available_quantity ? 'my-10 mb-0' : 'my-10'}`}
>
<div className='border border-[#18181B]/25 rounded-2xl p-1 flex items-center justify-center'>
<Button
type='button'
className='rounded-2xl border border-sky-500 bg-[#0069E0] text-white'
onClick={(e) => {
e.stopPropagation();
document
.getElementById('file-upload-input')
?.click();
}}
>
<Icon
icon={'heroicons-solid:arrow-up-tray'}
className='text-2xl text-white'
/>
</Button>
</div>
<span className='text-md font-semibold text-[#18181B80]'>
Choose file to upload
</span>
<span className='text-xs font-light text-[#18181B80] text-center max-w-xs px-4'>
{projectFlockKandangLookup?.population
? `Jumlah data yang dibutuhkan: ${formatNumber(Math.round(projectFlockKandangLookup.population * 0.02))} (2% dari ${formatNumber(projectFlockKandangLookup.population)} populasi).`
: 'Upload data file (*.xlsx)'}
</span>
</div>
{projectFlockKandangLookup?.population && (
<>
<div className='flex items-center justify-center gap-2 py-4'>
<div className='h-px bg-[#18181B33] w-8'></div>
<span className='text-[#18181B33] text-xs'>
Templates
</span>
<div className='h-px bg-[#18181B33] w-8'></div>
</div>
<div className='flex items-center justify-center mb-10'>
<Button
type='button'
variant='outline'
className='btn-sm rounded-2xl shadow-md border border-base-300'
onClick={(e) => {
e.stopPropagation();
handleDownloadTemplate();
}}
>
<Icon
icon='heroicons:arrow-down-tray'
width={18}
height={18}
/>
Template XLSX
</Button>
</div>
</>
)}
</>
)}
</section>
<input
ref={fileInputRef}
type='file'
id='file-upload-input'
name='document'
accept='application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv'
onChange={handleFileChange}
className='hidden'
/>
{formik.touched.document && formik.errors.document && (
<p className='w-full text-sm text-red-500 mt-2'>
{formik.errors.document as string}
</p>
)}
</div>
{!isNextStep && (
<RequirePermission permissions='lti.production.uniformity.create'>
<Button
type='submit'
color='primary'
className='w-full'
disabled={!formik.isValid || formik.isSubmitting}
>
{formik.isSubmitting ? (
<span className='loading loading-spinner'></span>
) : (
'Next'
)}
</Button>
</RequirePermission>
)}
</form>
</section>
</section>
</>
);
};
export default UniformityForm;
@@ -0,0 +1,115 @@
'use client';
import { useMemo } from 'react';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store';
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
import RequirePermission from '@/components/helper/RequirePermission';
import Table from '@/components/Table';
import {
BodyWeightData,
UniformityDetailItem,
} from '@/types/api/production/uniformity';
const UniformityPreviewForm = () => {
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
const setIsNextStep = useUiStore((s) => s.setIsNextStep);
const setUniformityStep = useUniformityStore((s) => s.setUniformityStep);
const verifyUniformityResult = useUniformityStore(
(s) => s.verifyUniformityResult
);
const uniformityFormData = useUniformityStore((s) => s.uniformityFormData);
const handleClose = () => {
setExpandedDrawerOpen(false);
setIsNextStep(false);
setUniformityStep('preview');
};
const handleNext = () => {
setUniformityStep('result');
};
const tableData = useMemo(() => {
if (!verifyUniformityResult) return [];
return verifyUniformityResult.uniformity_details.map(
(detail: UniformityDetailItem, index: number) => ({
id: `weight-${index}`,
number: index + 1,
weight: detail.weight,
})
);
}, [verifyUniformityResult]);
const columns: ColumnDef<BodyWeightData>[] = useMemo(
() => [
{
accessorKey: 'number',
header: 'No',
cell: (props) => props.row.original.number,
},
{
accessorKey: 'weight',
header: 'Weight (g)',
cell: (props) => <span>{props.row.original.weight}</span>,
},
],
[]
);
return (
<section className='w-full h-full bg-white border-l border-gray-200'>
{/* Header */}
<DrawerHeader
leftIcon=''
subtitle={uniformityFormData?.file_name || 'Add Body Weight'}
subtitleClassName='text-sm text-neutral line-clamp-1'
showDivider={false}
>
<Button variant='link' className='p-0 text-error' onClick={handleClose}>
<Tooltip content='Hapus' position='left'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} />
</Tooltip>
</Button>
</DrawerHeader>
{/* Form Section */}
<div className='divider mt-3.5'></div>
<section className='w-full px-6'>
{verifyUniformityResult ? (
<div className='flex flex-col gap-4'>
<Table<BodyWeightData>
data={tableData}
columns={columns}
pageSize={15}
className={{ containerClassName: 'mb-5' }}
/>
<RequirePermission permissions='lti.production.uniformity.create'>
<Button color='primary' onClick={handleNext} className='mb-10'>
Next
</Button>
</RequirePermission>
</div>
) : (
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
<Icon
icon='mdi:file-document-outline'
width={64}
height={64}
className='mb-4'
/>
<p className='text-lg'>No data available</p>
<p className='text-sm'>Upload a file to verify uniformity</p>
</div>
)}
</section>
</section>
);
};
export default UniformityPreviewForm;
@@ -0,0 +1,322 @@
'use client';
import React, { useMemo } from 'react';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store';
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
import RequirePermission from '@/components/helper/RequirePermission';
import Table from '@/components/Table';
import { useRouter } from 'next/navigation';
import toast from 'react-hot-toast';
import { UniformityApi } from '@/services/api/uniformity';
import { isResponseError } from '@/lib/api-helper';
import Badge from '@/components/Badge';
import { formatNumber } from '@/lib/helper';
import {
getWeightStatusColor,
getWeightStatusIndicatorColor,
getWeightStatusText,
} from '@/components/pages/production/uniformity/uniformity-utils';
import { DetailOptionType } from '@/types/api/production/uniformity';
import {
BodyWeightData,
UniformityDetailItem,
} from '@/types/api/production/uniformity';
const UniformityResultForm = () => {
const router = useRouter();
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
const setIsNextStep = useUiStore((s) => s.setIsNextStep);
const setUniformityStep = useUniformityStore((s) => s.setUniformityStep);
const verifyUniformityResult = useUniformityStore(
(s) => s.verifyUniformityResult
);
const setVerifyUniformityResult = useUniformityStore(
(s) => s.setVerifyUniformityResult
);
const uniformityFormData = useUniformityStore((s) => s.uniformityFormData);
const setIsSuccess = useUniformityStore((s) => s.setIsSuccess);
const setCreatedUniformity = useUniformityStore(
(s) => s.setCreatedUniformity
);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const handleClose = () => {
setExpandedDrawerOpen(false);
setIsNextStep(false);
setUniformityStep('preview');
setVerifyUniformityResult(null);
};
const handleSubmit = async () => {
if (!uniformityFormData || !uniformityFormData.document) {
toast.error('Form data is missing. Please try again.');
return;
}
setIsSubmitting(true);
try {
const payload = {
date: uniformityFormData.date,
week: uniformityFormData.week,
project_flock_kandang_id: uniformityFormData.project_flock_kandang_id,
document: uniformityFormData.document,
};
const res = await UniformityApi.createUniformity(payload);
if (!res || isResponseError(res)) {
toast.error(res?.message || 'Failed to create uniformity');
return;
}
setCreatedUniformity(res.data);
setIsSuccess(true);
setExpandedDrawerOpen(false);
setIsNextStep(false);
setUniformityStep('preview');
setVerifyUniformityResult(null);
router.push('/production/uniformity');
} finally {
setIsSubmitting(false);
}
};
const samplingTableData: DetailOptionType[] = useMemo(() => {
if (!verifyUniformityResult) return [];
const { sampling } = verifyUniformityResult;
return [
{
id: 'sampling-size',
label: 'Sampling size',
value: `${formatNumber(sampling.chick_qty_of_weight)} of Birds`,
},
{
id: 'mean-weight',
label: 'Mean Weight',
value: `${sampling.mean_weight} g`,
},
{
id: 'min-limit',
label: 'Min Limit (-10%)',
value: `${sampling.mean_down} g`,
},
{
id: 'max-limit',
label: 'Max Limit (+10%)',
value: `${sampling.mean_up} g`,
},
];
}, [verifyUniformityResult]);
const columnsSampling: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => <span>{props.row.original.value}</span>,
},
],
[]
);
const resultTableData: DetailOptionType[] = useMemo(() => {
if (!verifyUniformityResult) return [];
const { result } = verifyUniformityResult;
return [
{
id: 'ideal-birds',
label: 'Ideal Birds',
value: `${formatNumber(result.uniform_qty)} of Birds`,
},
{
id: 'outside-range',
label: 'Outside Range',
value: `${formatNumber(result.outside_qty)} of Birds`,
},
{
id: 'uniformity',
label: 'Uniformity',
value: `${result.uniformity} %`,
},
];
}, [verifyUniformityResult]);
const resultColumns: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => <span>{props.row.original.value}</span>,
},
],
[]
);
const tableData = useMemo(() => {
if (!verifyUniformityResult) return [];
return verifyUniformityResult.uniformity_details.map(
(detail: UniformityDetailItem, index: number) => ({
id: `body-weight-${index + 1}`,
number: index + 1,
weight: detail.weight,
status: detail.range.toLowerCase() as 'ideal' | 'outside',
})
);
}, [verifyUniformityResult]);
const columnsUniformity: ColumnDef<BodyWeightData>[] = useMemo(
() => [
{
accessorKey: 'number',
header: 'No',
cell: (props) => props.row.original.number,
},
{
accessorKey: 'weight',
header: 'Weight (g)',
cell: (props) => <span>{props.row.original.weight}</span>,
},
{
accessorKey: 'status',
header: 'Status',
cell: (props) => {
const status = props.row.original.status;
return status ? (
<div className='w-full'>
<Badge
statusIndicator={true}
variant='soft'
className={{
badge: `rounded-xl w-full justify-start border border-gray-200 text-black ${getWeightStatusColor(status)}`,
status: getWeightStatusIndicatorColor(status),
}}
>
{getWeightStatusText(status)}
</Badge>
</div>
) : (
<Badge
statusIndicator={true}
variant='soft'
className={{
badge:
'rounded-xl w-full justify-start border border-gray-200 text-black bg-info/10',
status: 'bg-info',
}}
>
Unknown
</Badge>
);
},
},
],
[]
);
return (
<section className='w-full h-full bg-white border-l border-gray-200'>
{/* Header */}
<DrawerHeader
leftIcon=''
subtitle={uniformityFormData?.document_name || 'Uniformity Result'}
subtitleClassName='text-sm text-neutral line-clamp-1'
showDivider={false}
>
<Button variant='link' className='p-0 text-error' onClick={handleClose}>
<Tooltip content='Hapus' position='left'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} />
</Tooltip>
</Button>
</DrawerHeader>
{/* Form Section */}
<div className='divider mt-3.5'></div>
<section className='w-full px-6'>
{verifyUniformityResult ? (
<div className='flex flex-col gap-4'>
<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>
<div className=''>
<p className='text-sm font-medium mb-5'>Result</p>
<Table<DetailOptionType>
data={resultTableData}
columns={resultColumns}
pageSize={3}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
<div className='mt-4'>
<Table<BodyWeightData>
data={tableData}
columns={columnsUniformity}
pageSize={15}
className={{ containerClassName: 'mb-5' }}
/>
</div>
{/* Action Buttons */}
<RequirePermission permissions='lti.production.uniformity.create'>
<Button
color='primary'
onClick={handleSubmit}
isLoading={isSubmitting}
disabled={!uniformityFormData}
className='mb-10'
>
Submit
</Button>
</RequirePermission>
</div>
) : (
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
<Icon
icon='mdi:file-document-outline'
width={64}
height={64}
className='mb-4'
/>
<p className='text-lg'>No data available</p>
<p className='text-sm'>Upload a file to verify uniformity</p>
</div>
)}
</section>
</section>
);
};
export default UniformityResultForm;
@@ -0,0 +1,99 @@
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
const LeftLegend = () => {
return (
<>
<div className='skeleton h-30 w-4 flex items-center justify-center self-center mb-10' />
<div className='grid grid-cols-1 justify-center items-center'>
{[...Array(5)].map((_, index) => (
<div
key={`grid-${index}`}
className='shrink-0 flex flex-col justify-center mb-10'
>
<div className='skeleton h-4 w-8' />
</div>
))}
</div>
</>
);
};
const ChartArea = () => {
const ranges = [
'48-52',
'52-56',
'56-60',
'60-64',
'64-68',
'68-72',
'72-76',
];
return (
<>
<div className='flex-1 min-w-0 flex flex-col relative'>
<div className='flex-1 ml-6 min-w-0 flex flex-col'>
<div className='flex-1 relative flex flex-col justify-between py-4'>
{[...Array(5)].map((_, index) => (
<div
key={`grid-${index}`}
className='w-full border-b border-gray-200 absolute'
style={{ top: `${(index / 4) * 100}%` }}
/>
))}
</div>
<div className='flex justify-between gap-2 sm:gap-4 md:gap-8 lg:gap-12 px-2 sm:px-4 py-2'>
{ranges.map((range) => (
<div
key={range}
className='skeleton h-3 w-8 sm:w-12 md:w-16 shrink-0'
/>
))}
</div>
<div className='flex justify-center pb-1'>
<div className='skeleton h-3 w-20 sm:w-28' />
</div>
</div>
</div>
</>
);
};
const EmptyState = () => {
return (
<>
<div className='absolute inset-0 flex flex-col items-center justify-center z-10 gap-2'>
<div className='border border-[#18181B]/25 rounded-2xl p-1 flex items-center justify-center my-2'>
<Button className='rounded-2xl border border-sky-500 bg-[#0069E0] text-white'>
<Icon icon={'heroicons:funnel'} className='text-4xl text-whitd' />
</Button>
</div>
<span className='text-xl font-semibold text-[#18181B80] leading-5'>
No Filters Selected
</span>
<span className='text-xs font-light text-[#18181B80] leading-4 text-center max-w-xs px-4'>
Please choose filters to narrow down your results and make your search
easier.
</span>
</div>
</>
);
};
const UniformityBarChartSkeleton = () => {
return (
<div className='relative w-full h-full min-h-[300px] xl:min-h-[350px]'>
<div className='sm:flex hidden h-full gap-4'>
<LeftLegend />
<ChartArea />
</div>
<EmptyState />
</div>
);
};
export default UniformityBarChartSkeleton;
@@ -0,0 +1,81 @@
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import React from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts';
interface UniformityGaugeChartSkeletonProps {
label?: string;
kandang?: string;
week?: string;
currentValue?: number;
totalValue?: number;
}
const UniformityGaugeChartSkeleton: React.FC<
UniformityGaugeChartSkeletonProps
> = ({}) => {
const numberOfSegments = 50;
const value = 0;
const filledSegments = Math.round((value / 100) * numberOfSegments);
const data = Array.from({ length: numberOfSegments }, (_, index) => ({
name: index,
value: 1,
filled: index < filledSegments,
}));
const activeColor = '#1890ff';
const inactiveColor = '#f0f0f0';
return (
<div className='flex flex-col w-full'>
<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}>
<PieChart>
<Pie
data={data}
cx='50%'
cy='70%'
startAngle={180}
endAngle={0}
innerRadius='75%'
outerRadius='100%'
paddingAngle={2}
dataKey='value'
stroke='none'
isAnimationActive={false}
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.filled ? activeColor : inactiveColor}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className='absolute inset-x-0 top-24 flex flex-col items-center justify-center'>
<div className='border border-[#18181B]/25 rounded-2xl p-1 flex items-center justify-center mt-5'>
<Button className='rounded-2xl border border-sky-500 bg-[#0069E0] text-white'>
<Icon
icon={'heroicons:funnel'}
className='text-4xl text-whitd'
/>
</Button>
</div>
<span className='text-lg font-semibold text-[#18181B80] leading-5 my-3'>
No Filters Selected
</span>
<span className='text-xs font-light text-[#18181B80] leading-4 text-center max-w-xs px-4'>
Please choose filters to narrow down your results and make your
search easier.
</span>
</div>
</div>
</div>
</div>
);
};
export default UniformityGaugeChartSkeleton;
@@ -0,0 +1,26 @@
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
const UniformityTableSkeleton = () => {
return (
<div className='flex flex-col items-center justify-center gap-2 my-20'>
<div className='border border-[#18181B]/25 rounded-2xl p-1 flex items-center justify-center'>
<Button className='rounded-2xl border border-sky-500 bg-[#0069E0] text-white'>
<Icon
icon={'heroicons-outline:chart-bar'}
className='text-4xl text-whitd'
/>
</Button>
</div>
<span className='text-xl font-semibold text-[#18181B80] leading-5'>
No Data Available
</span>
<span className='text-xs font-light text-[#18181B80] leading-4 text-center max-w-xs px-4'>
There is no uniformity data displayed. Enter uniformity check data to
get started.
</span>
</div>
);
};
export default UniformityTableSkeleton;
@@ -0,0 +1,65 @@
export const weightStatusColorMap: Record<string, string> = {
ideal: 'bg-[#00D39033]',
outside: 'bg-error/10',
};
export const weightStatusIndicatorColorMap: Record<string, string> = {
ideal: 'bg-[#008000]',
outside: 'bg-error',
};
export const weightStatusTextMap: Record<string, string> = {
ideal: 'Ideal',
outside: 'Outside',
};
export const getWeightStatusColor = (status: string): string => {
return weightStatusColorMap[status] || 'bg-info';
};
export const getWeightStatusIndicatorColor = (status: string): string => {
return weightStatusIndicatorColorMap[status] || 'bg-info';
};
export const getWeightStatusText = (status: string): string => {
return weightStatusTextMap[status] || status;
};
export const statusColorMap: Record<string, string> = {
APPROVED: 'bg-[#00D39033]',
Disetujui: 'bg-[#00D39033]',
REJECTED: 'bg-error/10',
Ditolak: 'bg-error/10',
CREATED: 'bg-[#f3f3f4]',
Pengajuan: 'bg-[#f3f3f4]',
};
export const statusIndicatorColorMap: Record<string, string> = {
APPROVED: 'bg-[#008000]',
Disetujui: 'bg-[#008000]',
REJECTED: 'bg-error',
Ditolak: 'bg-error',
CREATED: 'bg-[#D9D9D9]',
Pengajuan: 'bg-[#D9D9D9]',
};
export const statusTextMap: Record<string, string> = {
APPROVED: 'Disetujui',
Disetujui: 'Disetujui',
REJECTED: 'Ditolak',
Ditolak: 'Ditolak',
CREATED: 'Pengajuan',
Pengajuan: 'Pengajuan',
};
export const getStatusColor = (status: string): string => {
return statusColorMap[status] || 'bg-info';
};
export const getStatusIndicatorColor = (status: string): string => {
return statusIndicatorColorMap[status] || 'bg-info';
};
export const getStatusText = (status: string): string => {
return statusTextMap[status] || status;
};