Merge branch 'feat/FE/US-281/TASK-316-317-slicing-ui-and-integrate-api-daily-recording-growing-uniformity-page' into 'development'

[FEAT/FE][US#281/TASK-316-317] Uniformity Page and Adjustment Purchase Accept Issue

See merge request mbugroup/lti-web-client!128
This commit is contained in:
Rivaldi A N S
2025-12-31 04:29:45 +00:00
41 changed files with 5054 additions and 69 deletions
@@ -0,0 +1,7 @@
import UniformityForm from '@/components/pages/production/uniformity/form/UniformityForm';
const AddUniformity = () => {
return <UniformityForm formType='add' />;
};
export default AddUniformity;
@@ -0,0 +1,49 @@
'use client';
import UniformityDetail from '@/components/pages/production/uniformity/detail/UniformityDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { UniformityApi } from '@/services/api/uniformity';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const UniformityDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const uniformityId = searchParams.get('uniformityId');
const { data: uniformity, isLoading: isLoadingUniformity } = useSWR(
uniformityId,
(id: string) => UniformityApi.getUniformityDetail(parseInt(id))
);
if (!uniformityId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingUniformity && (!uniformity || isResponseError(uniformity))) {
router.replace('/404');
return;
}
return (
<div className='w-full h-full flex flex-col justify-center'>
{isLoadingUniformity && (
<div className='w-full flex flex-row justify-center items-center p-4 min-h-screen'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{isResponseSuccess(uniformity) && (
<UniformityDetail initialValues={uniformity.data} />
)}
</div>
);
};
export default UniformityDetailPage;
+10
View File
@@ -0,0 +1,10 @@
import { ReactNode } from 'react';
import UniformityPageWrapper from '@/components/pages/production/uniformity/UniformityPageWrapper';
export default function UniformityLayout({
children,
}: {
children: ReactNode;
}) {
return <UniformityPageWrapper>{children}</UniformityPageWrapper>;
}
+7
View File
@@ -0,0 +1,7 @@
import UniformityTable from '@/components/pages/production/uniformity/UniformityTable';
const Uniformity = () => {
return <UniformityTable />;
};
export default Uniformity;
+34 -14
View File
@@ -3,29 +3,25 @@
import { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/helper';
import type { Color, Variant, Size } from '@/types/theme';
export interface BadgeProps
extends Omit<HTMLAttributes<HTMLSpanElement>, 'className'> {
children?: ReactNode;
className?: {
badge?: string;
status?: string;
};
variant?: 'default' | 'outline' | 'ghost' | 'soft' | 'dash';
color?:
| 'neutral'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error';
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
statusIndicator?: boolean;
variant?: Variant;
color?: Color;
size?: Size;
}
const Badge = ({
children,
className,
statusIndicator = false,
variant = 'default',
color,
size = 'md',
@@ -34,7 +30,7 @@ const Badge = ({
const getBadgeClasses = () => {
const baseClasses = 'badge';
const variantClasses = {
const variantClasses: Record<Variant, string> = {
default: '',
outline: 'badge-outline',
ghost: 'badge-ghost',
@@ -42,7 +38,7 @@ const Badge = ({
dash: 'badge-dash',
};
const colorClasses = {
const colorClasses: Record<Color, string> = {
neutral: 'badge-neutral',
primary: 'badge-primary',
secondary: 'badge-secondary',
@@ -51,9 +47,10 @@ const Badge = ({
success: 'badge-success',
warning: 'badge-warning',
error: 'badge-error',
none: '',
};
const sizeClasses = {
const sizeClasses: Record<Size, string> = {
xs: 'badge-xs',
sm: 'badge-sm',
md: 'badge-md',
@@ -70,8 +67,31 @@ const Badge = ({
);
};
const getStatusClasses = () => {
if (!statusIndicator) return '';
const statusIndicatorClasses: Record<Color, string> = {
neutral: 'bg-neutral',
primary: 'bg-primary',
secondary: 'bg-secondary',
accent: 'bg-accent',
info: 'bg-info',
success: 'bg-success',
warning: 'bg-warning',
error: 'bg-error',
none: '',
};
return cn(
'w-2.5 h-2.5 rounded-full',
color && statusIndicatorClasses[color],
className?.status
);
};
return (
<span className={getBadgeClasses()} {...props}>
{statusIndicator && <span className={getStatusClasses()} />}
{children}
</span>
);
+44 -8
View File
@@ -15,6 +15,8 @@ interface DrawerProps {
className?: DrawerClassName;
onBackdropClick?: () => void;
closeOnBackdropClick?: boolean;
expandedContent?: ReactNode;
expandedWidth?: string;
}
type DrawerClassName = {
@@ -36,6 +38,8 @@ const Drawer = ({
className,
onBackdropClick,
closeOnBackdropClick = true,
expandedContent,
expandedWidth = 'w-[400px]',
}: DrawerProps) => {
const getDrawerClassNames = (): DrawerClassName => {
const baseClassNames = {
@@ -46,12 +50,21 @@ const Drawer = ({
drawerSidebarContent: 'min-h-full bg-base-100',
};
const getSidebarWidth = () => {
if (variant === 'sidebar') {
return expandedContent
? 'w-full lg:min-w-[600px] lg:max-w-[600px]'
: 'w-full max-w-[300px] lg:w-[300px]';
}
return 'w-full sm:min-w-120 sm:w-fit';
};
if (variant === 'sidebar') {
return {
...baseClassNames,
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full max-w-[300px] lg:w-[300px]'
getSidebarWidth()
),
};
} else if (variant === 'right') {
@@ -60,11 +73,11 @@ const Drawer = ({
drawer: cn(baseClassNames.drawer, 'drawer-end'),
drawerSide: cn(
baseClassNames.drawerSide,
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
'border-l border-solid border-gray-200 sm:drawer-side w-screen top-0 right-0 fixed z-21'
),
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full sm:min-w-120 sm:w-fit'
getSidebarWidth()
),
};
} else if (variant === 'left') {
@@ -76,7 +89,7 @@ const Drawer = ({
),
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full sm:min-w-120 sm:w-fit'
getSidebarWidth()
),
};
}
@@ -138,14 +151,37 @@ const Drawer = ({
onClick={closeDrawer}
/>
{/* Sidebar Content */}
{/* Sidebar Content - Full height container */}
<div
className={cn(
varianClassName?.drawerSidebarContent,
className?.drawerContent
'flex h-screen bg-base-100 overflow-hidden',
variant === 'right' && 'flex-row'
)}
>
{sidebarContent}
{/* Primary Sidebar Content */}
<div
className={cn(
varianClassName?.drawerSidebarContent,
className?.drawerContent,
'overflow-y-auto'
)}
>
{sidebarContent}
</div>
{/* Expanded Drawer (Right side, side-by-side) */}
{expandedContent && (
<div
className={cn(
'border-l border-gray-200 bg-white flex flex-col h-full',
expandedWidth
)}
>
<div className='overflow-y-auto flex-1 h-full'>
{expandedContent}
</div>
</div>
)}
</div>
</div>
</div>
+106 -39
View File
@@ -8,10 +8,13 @@ import Button, { ButtonProps } from '@/components/Button';
import { cn } from '@/lib/helper';
export type IconPosition = 'left' | 'center' | 'right';
export interface ConfirmationModalProps {
ref: RefObject<HTMLDialogElement | null>;
type?: 'info' | 'success' | 'error';
text?: string;
subtitleText?: string;
closeOnBackdrop?: boolean;
primaryButton?: ButtonProps & {
text?: string;
@@ -24,17 +27,84 @@ export interface ConfirmationModalProps {
modalBox?: string;
};
children?: React.ReactNode;
iconSize?: number;
iconPosition?: IconPosition;
}
const iconConfig = {
info: {
icon: 'material-symbols:info-outline-rounded',
iconClassName: 'text-info-content',
bgClassName: 'bg-info',
outerRingClassName: 'bg-info/20',
borderClassName: 'border-info',
},
success: {
icon: 'heroicons:check',
iconClassName: 'text-white',
bgClassName: 'bg-[#00D390]',
outerRingClassName: 'bg-[#00D3901F]',
borderClassName: 'border-[#CCF7EB]',
},
error: {
icon: 'solar:danger-triangle-linear',
iconClassName: 'text-error-content',
bgClassName: 'bg-[#f03338]',
outerRingClassName: 'bg-[#f3cdcd]',
borderClassName: 'border-[#fff0ef]',
},
} as const;
const ConfirmationModalIcon = ({
type,
size = 24,
}: {
type: 'info' | 'success' | 'error';
size?: number;
}) => {
const config = iconConfig[type];
return (
<div className='flex items-center justify-center p-2'>
<div
className={cn(
'rounded-full border-4 p-1',
config.outerRingClassName,
config.borderClassName
)}
>
<div className={cn('rounded-full p-1', config.outerRingClassName)}>
<div
className={cn(
'rounded-full p-3 flex items-center justify-center',
config.bgClassName
)}
>
<Icon
icon={config.icon}
width={size}
height={size}
className={config.iconClassName}
/>
</div>
</div>
</div>
</div>
);
};
const ConfirmationModal = ({
ref,
type = 'info',
text,
subtitleText,
closeOnBackdrop,
primaryButton,
secondaryButton,
className,
children,
iconSize = 32,
iconPosition = 'center',
}: ConfirmationModalProps) => {
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
@@ -55,47 +125,44 @@ const ConfirmationModal = ({
return (
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
<div className='w-full flex flex-col gap-4'>
<div
className={cn(
'w-fit p-4 mx-auto flex flex-row justify-center items-center rounded-full',
{
'bg-error': type === 'error',
'bg-info': type === 'info',
'bg-success': type === 'success',
}
)}
>
{type === 'info' && (
<Icon
icon='material-symbols:info-outline-rounded'
width={64}
height={64}
className='text-info-content'
/>
)}
{iconPosition === 'center' ? (
<>
<div className='w-fit mx-auto'>
<ConfirmationModalIcon type={type} size={iconSize} />
</div>
{type === 'success' && (
<Icon
icon='qlementine-icons:success-12'
width={64}
height={64}
className='text-success-content'
/>
)}
<p className='text-center font-medium'>
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p>
{type === 'error' && (
<Icon
icon='solar:danger-triangle-linear'
width={64}
height={64}
className='text-error-content'
/>
)}
</div>
{subtitleText && (
<p className='text-center text-sm text-gray-400'>
{subtitleText}
</p>
)}
</>
) : (
<div
className={cn('flex flex-row items-center gap-4', {
'flex-row': iconPosition === 'left',
'flex-row-reverse': iconPosition === 'right',
})}
>
<div className='w-fit'>
<ConfirmationModalIcon type={type} size={iconSize} />
</div>
<p className='text-center font-medium'>
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p>
<div className='flex flex-col gap-1'>
<p className='font-medium'>
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p>
{subtitleText && (
<p className='text-sm text-gray-400'>{subtitleText}</p>
)}
</div>
</div>
)}
{children && <div className='w-full'>{children}</div>}
@@ -103,7 +170,7 @@ const ConfirmationModal = ({
{secondaryButton && secondaryButton.text && (
<Button
{...secondaryButton}
variant='ghost'
variant='outline'
color={secondaryButton?.color}
isLoading={secondaryButton?.isLoading}
disabled={
@@ -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;
};
@@ -8,6 +8,7 @@ import { useSearchParams } from 'next/navigation';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput';
import FileInput from '@/components/input/FileInput';
import SelectInput, {
OptionType,
useSelect,
@@ -66,6 +67,7 @@ const PurchaseOrderAcceptApprovalForm = ({
| 'expedition_vendor_id'
| 'received_qty'
| 'transport_per_item'
| 'travel_documents'
): { isError: boolean; errorMessage: string } => {
const touchedItem = formik.touched.items?.[idx];
const errorItem = formik.errors.items?.[idx] as
@@ -185,6 +187,7 @@ const PurchaseOrderAcceptApprovalForm = ({
: formItem.transport_per_item || 0,
};
}) || [],
travel_documents: values.travel_documents || [],
};
switch (type) {
@@ -236,15 +239,29 @@ const PurchaseOrderAcceptApprovalForm = ({
travel_document_path: item.travel_document_path || '',
vehicle_number: item.vehicle_number || '',
expedition_vendor: null,
expedition_vendor_id: 0,
expedition_vendor_id: item.expedition_vendor_id || 0,
received_qty: item.total_qty || '',
transport_per_item: '',
transport_per_item: item.transport_per_item || '',
};
});
formik.setFieldValue('items', updatedItems);
}
}, [purchaseItems, initialValues]);
useEffect(() => {
if (
formik.values.travel_documents &&
formik.values.travel_documents.length > 0
) {
const fileNames = formik.values.travel_documents
.map((file) => file.name)
.join(', ');
formik.values.items?.forEach((item, idx) => {
formik.setFieldValue(`items.${idx}.travel_document_path`, fileNames);
});
}
}, [formik.values.travel_documents]);
// ===== HELPER FUNCTIONS =====
const getQuantityExceededError = useCallback(
(idx: number, receivedQty: number) => {
@@ -343,7 +360,7 @@ const PurchaseOrderAcceptApprovalForm = ({
No. Surat Jalan
<span className='text-error'>*</span>
</th>
<th>
<th className='hidden'>
Dokumen Surat Jalan
<span className='text-error'>*</span>
</th>
@@ -478,7 +495,7 @@ const PurchaseOrderAcceptApprovalForm = ({
}}
/>
</td>
<td>
<td className='hidden'>
<TextInput
required
name={`items.${idx}.travel_document_path`}
@@ -636,7 +653,7 @@ const PurchaseOrderAcceptApprovalForm = ({
</tbody>
</table>
</div>
<div className={'col-span-2'}>
<div className={'col-span-2 my-2'}>
<TextInput
label='Notes'
name='notes'
@@ -649,6 +666,31 @@ const PurchaseOrderAcceptApprovalForm = ({
/>
</div>
<div className={'col-span-2 my-2'}>
<FileInput
required
name='travel_documents'
label='Dokumen Surat Jalan'
accept='.pdf,.jpg,.jpeg,.png'
onChange={(e) => {
const files = Array.from(e.target.files || []);
formik.setFieldValue('travel_documents', files);
}}
onBlur={formik.handleBlur}
bottomLabel={
formik.values.travel_documents &&
formik.values.travel_documents.length > 0
? `${formik.values.travel_documents.length} file(s) dipilih`
: undefined
}
isError={
formik.touched.travel_documents &&
Boolean(formik.errors.travel_documents)
}
errorMessage={formik.errors.travel_documents as string}
/>
</div>
{/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap mt-5'>
<div className='flex flex-row justify-end gap-2 w-full'>
@@ -48,6 +48,7 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
received_qty: number | string;
transport_per_item: number | string;
}[];
travel_documents: File[];
};
export type PurchaseStaffApprovalItemSchema = {
@@ -379,6 +380,11 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
.min(1, 'Minimal harus ada 1 item pembelian!')
.required('Item pembelian wajib diisi!')
.typeError('Item pembelian wajib diisi!'),
travel_documents: Yup.array()
.of(Yup.mixed<File>().required())
.required('Dokumen surat jalan wajib diupload!')
.min(1, 'Minimal upload 1 dokumen surat jalan!')
.typeError('Dokumen surat jalan wajib diupload!'),
});
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
@@ -397,6 +403,7 @@ export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcce
transport_per_item: '',
},
],
travel_documents: [],
};
export const PurchaseRequestAcceptApprovalFormDefaultValues = (
@@ -428,6 +435,7 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
transport_per_item: '',
},
],
travel_documents: [],
};
};
@@ -583,7 +583,7 @@ const PurchaseOrderDetail = ({
{
header: 'Ekspedisi',
accessorKey: 'expedition_name',
cell: (props) => '-',
cell: (props) => props.row.original.expedition_vendor.name || '-',
},
{
header: 'Transport /Item',
+5
View File
@@ -29,6 +29,11 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
text: 'Transfer to Laying',
link: '/production/transfer-to-laying',
},
{
text: 'Uniformity',
link: '/production/uniformity',
permission: ['lti.production.uniformity.list'],
},
],
},
{
+6
View File
@@ -45,6 +45,12 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'lti.production.transfer_to_laying.update',
],
// Production - Uniformity
'/production/uniformity/': ['lti.production.uniformity.list'],
'/production/uniformity/add/': ['lti.production.uniformity.create'],
'/production/uniformity/detail/': ['lti.production.uniformity.detail'],
'/production/uniformity/detail/edit/': ['lti.production.uniformity.update'],
// Purchase
'/purchase/': ['lti.purchase.list'],
'/purchase/add/': ['lti.purchase.create'],
+19 -1
View File
@@ -72,11 +72,29 @@ export const PurchaseApi = {
purchaseRequestId: number,
payload: CreateAcceptApprovalRequestPayload
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
const formData = new FormData();
formData.append('action', payload.action);
if (payload.notes) {
formData.append('notes', payload.notes);
}
if (payload.items) {
formData.append('items', JSON.stringify(payload.items));
}
if (payload.travel_documents) {
payload.travel_documents.forEach((file) => {
formData.append('travel_documents', file);
});
}
return await basePurchaseApi.customRequest<
BaseApiResponse<{ message: string }>
>(`${purchaseRequestId}/receipts`, {
method: 'POST',
payload,
payload: formData as unknown as Record<string, unknown>,
});
},
},
+138
View File
@@ -0,0 +1,138 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import {
Uniformity,
UniformityDetail,
VerifyUniformityPayload,
VerifyUniformityResponse,
CreateUniformityPayload,
} from '@/types/api/production/uniformity';
export class UniformityApiService extends BaseApiService<
Uniformity,
CreateUniformityPayload,
VerifyUniformityPayload
> {
constructor(basePath: string) {
super(basePath);
}
async getUniformity(
project_flock_kandang_id?: number,
start_date?: string,
end_date?: string,
page?: number,
limit?: number
): Promise<BaseApiResponse<Uniformity> | undefined> {
return await this.customRequest<BaseApiResponse<Uniformity>>('', {
method: 'GET',
params: {
project_flock_kandang_id: project_flock_kandang_id,
start_date: start_date,
end_date: end_date,
page: page,
limit: limit,
},
});
}
async getUniformityDetail(
id: number,
with_details = false
): Promise<BaseApiResponse<UniformityDetail> | undefined> {
return await this.customRequest<BaseApiResponse<UniformityDetail>>(
`/${id}`,
{
method: 'GET',
params: {
with_details: with_details,
},
}
);
}
async createUniformity(
payload: CreateUniformityPayload
): Promise<BaseApiResponse<UniformityDetail> | undefined> {
const formData = new FormData();
formData.append('date', payload.date);
formData.append('week', payload.week.toString());
formData.append(
'project_flock_kandang_id',
payload.project_flock_kandang_id.toString()
);
if (payload.document) {
formData.append('document', payload.document);
}
return await this.customRequest<BaseApiResponse<UniformityDetail>>('', {
method: 'POST',
payload: formData as unknown as Record<string, unknown>,
});
}
async verifyUniformity(
payload: VerifyUniformityPayload
): Promise<BaseApiResponse<VerifyUniformityResponse> | undefined> {
const formData = new FormData();
if (payload.document) {
formData.append('document', payload.document);
}
return await this.customRequest<BaseApiResponse<VerifyUniformityResponse>>(
'/verify',
{
method: 'POST',
payload: formData as unknown as Record<string, unknown>,
}
);
}
async approve(
idOrIds: number | number[],
notes?: string
): Promise<BaseApiResponse<Uniformity[]> | undefined> {
const approvable_ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
return await this.customRequest<BaseApiResponse<Uniformity[]>>(
'approvals',
{
method: 'POST',
payload: {
action: 'APPROVED',
approvable_ids,
notes,
},
}
);
}
async reject(
idOrIds: number | number[],
notes: string = ''
): Promise<BaseApiResponse<Uniformity[]> | undefined> {
const approvable_ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
return await this.customRequest<BaseApiResponse<Uniformity[]>>(
'approvals',
{
method: 'POST',
payload: {
action: 'REJECTED',
approvable_ids,
notes,
},
}
);
}
async delete(id: number): Promise<BaseApiResponse<Uniformity> | undefined> {
return await this.customRequest<BaseApiResponse<Uniformity>>(`/${id}`, {
method: 'DELETE',
});
}
}
export const UniformityApi = new UniformityApiService(
'production/uniformities'
// 'http://localhost:4010/api/production/uniformities'
);
+11
View File
@@ -1,5 +1,6 @@
import { DrawerUISlice } from '@/types/stores';
import { StateCreator } from 'zustand';
import { ReactNode } from 'react';
export const createDrawerUISlice: StateCreator<
DrawerUISlice,
@@ -37,4 +38,14 @@ export const createDrawerUISlice: StateCreator<
callback(Boolean(state.isValid));
});
},
expandedDrawerOpen: false,
setExpandedDrawerOpen: (open: boolean) => set({ expandedDrawerOpen: open }),
expandedDrawerContent: null as ReactNode | null,
setExpandedDrawerContent: (content: ReactNode) =>
set({ expandedDrawerContent: content }),
isNextStep: false,
setIsNextStep: (isNextStep: boolean) => set({ isNextStep }),
});
@@ -0,0 +1,37 @@
import { UniformitySlice } from '@/types/stores';
import { StateCreator } from 'zustand';
export const createUniformitySlice: StateCreator<
UniformitySlice,
[],
[],
UniformitySlice
> = (set) => ({
// Initial state
uniformityStep: 'preview',
verifyUniformityResult: null,
uniformityFormData: null,
isSuccess: false,
createdUniformity: null,
// Actions
setUniformityStep: (step) => set({ uniformityStep: step }),
setVerifyUniformityResult: (result) =>
set({ verifyUniformityResult: result }),
setUniformityFormData: (data) => set({ uniformityFormData: data }),
setIsSuccess: (success) => set({ isSuccess: success }),
setCreatedUniformity: (data) => set({ createdUniformity: data }),
resetUniformity: () =>
set({
uniformityStep: 'preview',
verifyUniformityResult: null,
uniformityFormData: null,
isSuccess: false,
createdUniformity: null,
}),
});
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createUniformitySlice } from '@/stores/uniformity/slices/uniformity.slice';
import { UniformitySlice } from '@/types/stores';
export type UniformityStore = UniformitySlice;
export const useUniformityStore = create<UniformityStore>()(
devtools(
(...args) => ({
...createUniformitySlice(...args),
}),
{
name: 'UniformityStore',
}
)
);
+2
View File
@@ -71,6 +71,8 @@ export type ProjectFlockKandangLookup = {
kandang: Kandang;
project_flock: ProjectFlock;
quantity: number;
available_quantity?: number;
population: number;
};
export type ProjectFlockAvailableQuantity = {
+103
View File
@@ -0,0 +1,103 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Location } from '@/types/api/location/location';
import { ProjectFlock } from '@/types/api/project-flock/project-flock';
import { Kandang } from '@/types/api/kandang/kandang';
import { BaseApproval } from '@/types/api/approval/approval';
// ==================== GET ALL RESPONSE ====================
export type Uniformity = BaseMetadata & {
id: number;
project_flock_kandang_id: number;
location_name: string;
flock_name: string;
kandang_name: string;
applied_at: string;
week: number;
status: string;
uniformity: number;
cv: number;
chick_qty_of_weight: number;
uniform_qty: number;
mean_up: number;
mean_down: number;
standard_mean_weight: number | null;
standard_uniformity: number | null;
created_at: string;
created_by: number;
latest_approval?: BaseApproval;
};
// ==================== GET ONE RESPONSE ====================
export type UniformityInfoUmum = {
tanggal: string;
lokasi_farm: string;
project_flock: string;
kandang: string;
file_name: string;
};
export type UniformitySampling = {
chick_qty_of_weight: number;
mean_weight: number;
mean_down: number;
mean_up: number;
};
export type UniformityResult = {
uniform_qty: number;
outside_qty: number;
uniformity: number;
cv: number;
};
export type UniformityDetailItem = {
id: number;
weight: number;
range: 'Ideal' | 'Outside';
};
export type UniformityStandard = {
mean_weight: number;
uniformity: number;
} | null;
export type UniformityDetail = BaseMetadata & {
id: number;
info_umum: UniformityInfoUmum;
sampling: UniformitySampling;
result: UniformityResult;
standard: UniformityStandard;
uniformity_details?: UniformityDetailItem[];
latest_approval?: BaseApproval;
};
// ==================== VERIFY RESPONSE ====================
export type VerifyUniformityResponse = {
sampling: UniformitySampling;
result: UniformityResult;
uniformity_details: UniformityDetailItem[];
};
// ==================== PAYLOADS ====================
export type CreateUniformityPayload = {
date: string;
project_flock_kandang_id: number;
document: File;
week: number;
};
export type VerifyUniformityPayload = {
document: File;
};
// ==================== OTHER TYPES ====================
export type BodyWeightData = {
id: string;
number: number;
weight: number;
status?: 'ideal' | 'outside';
};
export type DetailOptionType = OptionType & {
id: string;
};
+7
View File
@@ -42,6 +42,12 @@ export type PurchaseItem = {
expedition_vendor_name?: string | null;
received_qty?: number | null;
transport_per_item?: number | null;
expedition_vendor: {
id: number;
name: string;
alias: string;
category: string;
};
};
export type BasePurchase = {
@@ -118,6 +124,7 @@ export type CreateAcceptApprovalRequestPayload = {
received_qty: number;
transport_per_item: number;
}[];
travel_documents?: File[];
};
export type DeletePurchaseRequestItemPayload = {
+31 -1
View File
@@ -1,4 +1,9 @@
import type { ProductionStandardRepeaterFormSchemaValues } from '@/components/pages/master-data/production-standard/form/ProductionStandardForm.schema';
import type {
UniformityFormData,
UniformityDetail,
VerifyUniformityResponse,
} from '@/types/api/production/uniformity';
type MainUiSlice = {
mainDrawerOpen: boolean;
@@ -8,10 +13,16 @@ type MainUiSlice = {
type DrawerUISlice = {
triggerValidate: boolean;
toggleValidate: () => void;
subscribeValidate: (callback: () => void) => void;
subscribeValidate: (callback: () => void) => () => void;
isValid: boolean;
setIsValid: (v: boolean) => void;
subscribeIsValid: (callback: (isValid: boolean) => void) => () => void;
expandedDrawerOpen: boolean;
setExpandedDrawerOpen: (open: boolean) => void;
expandedDrawerContent: ReactNode | null;
setExpandedDrawerContent: (content: ReactNode) => void;
isNextStep: boolean;
setIsNextStep: (v: boolean) => void;
};
export type UIStore = MainUiSlice & DrawerUISlice;
@@ -40,3 +51,22 @@ type ProductionStandardFormSlice = {
};
export type FormStore = ProductionStandardFormSlice;
export type UniformityStep = 'preview' | 'result';
export type UniformitySlice = {
// State
uniformityStep: UniformityStep;
verifyUniformityResult: VerifyUniformityResponse | null;
uniformityFormData: UniformityFormData | null;
isSuccess: boolean;
createdUniformity: UniformityDetail | null;
// Actions
setUniformityStep: (step: UniformityStep) => void;
setVerifyUniformityResult: (result: VerifyUniformityResponse | null) => void;
setUniformityFormData: (data: UniformityFormData | null) => void;
setIsSuccess: (success: boolean) => void;
setCreatedUniformity: (data: UniformityDetail | null) => void;
resetUniformity: () => void;
};
+2
View File
@@ -9,4 +9,6 @@ export type Color =
| 'error'
| 'none';
export type Variant = 'default' | 'outline' | 'ghost' | 'soft' | 'dash';
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';