mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
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:
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import UniformityTable from '@/components/pages/production/uniformity/UniformityTable';
|
||||
|
||||
const Uniformity = () => {
|
||||
return <UniformityTable />;
|
||||
};
|
||||
|
||||
export default Uniformity;
|
||||
+34
-14
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 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',
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -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
@@ -71,6 +71,8 @@ export type ProjectFlockKandangLookup = {
|
||||
kandang: Kandang;
|
||||
project_flock: ProjectFlock;
|
||||
quantity: number;
|
||||
available_quantity?: number;
|
||||
population: number;
|
||||
};
|
||||
|
||||
export type ProjectFlockAvailableQuantity = {
|
||||
|
||||
+103
@@ -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;
|
||||
};
|
||||
Vendored
+7
@@ -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 = {
|
||||
|
||||
Vendored
+31
-1
@@ -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;
|
||||
};
|
||||
|
||||
Vendored
+2
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user