diff --git a/package-lock.json b/package-lock.json
index a39060ef..4a583dbd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"axios": "^1.12.2",
"clsx": "^2.1.1",
"formik": "^2.4.6",
+ "inputmask": "^5.0.9",
"moment": "^2.30.1",
"next": "15.5.3",
"react": "19.1.0",
@@ -29,6 +30,7 @@
"@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4",
+ "@types/inputmask": "^5.0.7",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1640,6 +1642,13 @@
"@types/react": "*"
}
},
+ "node_modules/@types/inputmask": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz",
+ "integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1675,6 +1684,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1744,6 +1754,7 @@
"integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.43.0",
"@typescript-eslint/types": "8.43.0",
@@ -2261,6 +2272,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2829,7 +2841,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/daisyui": {
"version": "5.1.12",
@@ -3263,6 +3276,7 @@
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3437,6 +3451,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4229,6 +4244,12 @@
"node": ">=0.8.19"
}
},
+ "node_modules/inputmask": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz",
+ "integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==",
+ "license": "MIT"
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5766,6 +5787,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5775,6 +5797,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -6591,6 +6614,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -6758,6 +6782,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/package.json b/package.json
index e970499c..70e5737f 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"axios": "^1.12.2",
"clsx": "^2.1.1",
"formik": "^2.4.6",
+ "inputmask": "^5.0.9",
"moment": "^2.30.1",
"next": "15.5.3",
"react": "19.1.0",
@@ -31,6 +32,7 @@
"@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4",
+ "@types/inputmask": "^5.0.7",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/src/app/production/recording/add/page.tsx b/src/app/production/recording/add/page.tsx
new file mode 100644
index 00000000..d41fc183
--- /dev/null
+++ b/src/app/production/recording/add/page.tsx
@@ -0,0 +1,11 @@
+import RecordingForm from '@/components/pages/production/recording/form/RecordingForm';
+
+const AddRecording = () => {
+ return (
+
+
+
+ );
+};
+
+export default AddRecording;
diff --git a/src/app/production/recording/detail/edit/page.tsx b/src/app/production/recording/detail/edit/page.tsx
new file mode 100644
index 00000000..de53a354
--- /dev/null
+++ b/src/app/production/recording/detail/edit/page.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+import useSWR from 'swr';
+import RecordingForm from '@/components/pages/production/recording/form/RecordingForm';
+import { RecordingApi } from '@/services/api/production';
+import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
+
+const RecordingEdit = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const recordingId = searchParams.get('recordingId');
+
+ const { data: recording, isLoading: isLoadingRecording } = useSWR(
+ recordingId,
+ (id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi
+ );
+
+ if (!recordingId) {
+ router.back();
+
+ return (
+
+
+
+ );
+ }
+
+ if (!isLoadingRecording && (!recording || isResponseError(recording))) {
+ router.replace('/404');
+ return;
+ }
+
+ return (
+
+ {isLoadingRecording && (
+
+ )}
+ {!isLoadingRecording && isResponseSuccess(recording) && (
+
+ )}
+
+ );
+};
+
+export default RecordingEdit;
diff --git a/src/app/production/recording/detail/layout.tsx b/src/app/production/recording/detail/layout.tsx
new file mode 100644
index 00000000..7220dfa1
--- /dev/null
+++ b/src/app/production/recording/detail/layout.tsx
@@ -0,0 +1,11 @@
+import SuspenseHelper from '@/components/helper/SuspenseHelper';
+
+const Layout = ({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) => {
+ return {children};
+};
+
+export default Layout;
diff --git a/src/app/production/recording/detail/page.tsx b/src/app/production/recording/detail/page.tsx
new file mode 100644
index 00000000..77b82a68
--- /dev/null
+++ b/src/app/production/recording/detail/page.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+import useSWR from 'swr';
+import RecordingForm from '@/components/pages/production/recording/form/RecordingForm';
+import { RecordingApi } from '@/services/api/production';
+import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
+
+const RecordingDetail = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const recordingId = searchParams.get('recordingId');
+
+ const { data: recording, isLoading: isLoadingRecording } = useSWR(
+ recordingId,
+ (id: number) => RecordingApi.getSingle(id)
+ );
+
+ if (!recordingId) {
+ router.back();
+
+ return (
+
+
+
+ );
+ }
+
+ if (!isLoadingRecording && (!recording || isResponseError(recording))) {
+ router.replace('/404');
+ return;
+ }
+
+ return (
+
+ {isLoadingRecording && (
+
+ )}
+ {!isLoadingRecording && isResponseSuccess(recording) && (
+
+ )}
+
+ );
+};
+
+export default RecordingDetail;
diff --git a/src/app/production/recording/page.tsx b/src/app/production/recording/page.tsx
new file mode 100644
index 00000000..f31ac19a
--- /dev/null
+++ b/src/app/production/recording/page.tsx
@@ -0,0 +1,11 @@
+import RecordingTable from '@/components/pages/production/recording/RecordingTable';
+
+const Recording = () => {
+ return (
+
+ );
+};
+
+export default Recording;
diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx
new file mode 100644
index 00000000..5dc5022d
--- /dev/null
+++ b/src/components/Badge.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { HTMLAttributes, ReactNode } from 'react';
+
+import { cn } from '@/lib/helper';
+
+export interface BadgeProps
+ extends Omit, 'className'> {
+ children?: ReactNode;
+ className?: {
+ badge?: string;
+ };
+ variant?: 'default' | 'outline' | 'ghost' | 'soft' | 'dash';
+ color?:
+ | 'neutral'
+ | 'primary'
+ | 'secondary'
+ | 'accent'
+ | 'info'
+ | 'success'
+ | 'warning'
+ | 'error';
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+}
+
+const Badge = ({
+ children,
+ className,
+ variant = 'default',
+ color,
+ size = 'md',
+ ...props
+}: BadgeProps) => {
+ const getBadgeClasses = () => {
+ const baseClasses = 'badge';
+
+ const variantClasses = {
+ default: '',
+ outline: 'badge-outline',
+ ghost: 'badge-ghost',
+ soft: 'badge-soft',
+ dash: 'badge-dash',
+ };
+
+ const colorClasses = {
+ neutral: 'badge-neutral',
+ primary: 'badge-primary',
+ secondary: 'badge-secondary',
+ accent: 'badge-accent',
+ info: 'badge-info',
+ success: 'badge-success',
+ warning: 'badge-warning',
+ error: 'badge-error',
+ };
+
+ const sizeClasses = {
+ xs: 'badge-xs',
+ sm: 'badge-sm',
+ md: 'badge-md',
+ lg: 'badge-lg',
+ xl: 'badge-xl',
+ };
+
+ return cn(
+ baseClasses,
+ variantClasses[variant],
+ color && colorClasses[color],
+ sizeClasses[size],
+ className?.badge
+ );
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default Badge;
diff --git a/src/components/Card.tsx b/src/components/Card.tsx
new file mode 100644
index 00000000..ba573dfb
--- /dev/null
+++ b/src/components/Card.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import {
+ HTMLAttributes,
+ ReactNode,
+} from 'react';
+
+import { cn } from '@/lib/helper';
+
+export interface CardProps extends Omit, 'className'> {
+ title?: string;
+ subtitle?: string;
+ image?: string;
+ imageAlt?: string;
+ actions?: ReactNode;
+ footer?: ReactNode;
+ className?: {
+ wrapper?: string;
+ image?: string;
+ body?: string;
+ title?: string;
+ subtitle?: string;
+ actions?: string;
+ footer?: string;
+ };
+ variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
+ size?: 'sm' | 'md' | 'lg';
+}
+
+const Card = ({
+ title,
+ subtitle,
+ image,
+ imageAlt,
+ actions,
+ footer,
+ className,
+ variant = 'default',
+ size = 'md',
+ children,
+ ...props
+}: CardProps) => {
+ const getCardClasses = () => {
+ const baseClasses = 'card bg-base-100';
+
+ const variantClasses = {
+ 'default': '',
+ 'compact': 'card-compact',
+ 'bordered': 'border border-base-300',
+ 'shadow': 'shadow-xl',
+ 'image-full': 'card-side card-compact shadow-xl',
+ };
+
+ const sizeClasses = {
+ 'sm': 'w-64',
+ 'md': 'w-96',
+ 'lg': 'w-[28rem]',
+ };
+
+ return cn(
+ baseClasses,
+ variantClasses[variant],
+ variant !== 'image-full' ? sizeClasses[size] : '',
+ className?.wrapper
+ );
+ };
+
+ const getImageClasses = () => {
+ if (variant === 'image-full') {
+ return cn('w-32 h-32 object-cover', className?.image);
+ }
+ return cn('h-48 object-cover', className?.image);
+ };
+
+ const getBodyClasses = () => {
+ const baseClasses = 'card-body';
+
+ if (variant === 'compact' || variant === 'image-full') {
+ return cn(baseClasses, 'p-4', className?.body);
+ }
+
+ return cn(baseClasses, 'p-6', className?.body);
+ };
+
+ const getTitleClasses = () => {
+ const sizeClasses = {
+ 'sm': 'text-lg',
+ 'md': 'text-xl',
+ 'lg': 'text-2xl',
+ };
+
+ return cn('card-title font-bold', sizeClasses[size], className?.title);
+ };
+
+ const getSubtitleClasses = () => {
+ return cn('text-base-content/70 text-sm mt-1', className?.subtitle);
+ };
+
+ const getActionsClasses = () => {
+ return cn('card-actions justify-end mt-4', className?.actions);
+ };
+
+ const getFooterClasses = () => {
+ return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
+ };
+
+ if (variant === 'image-full' && image) {
+ return (
+
+
+
+
+
+ {title &&
{title}
}
+ {subtitle &&
{subtitle}
}
+ {children}
+ {actions &&
{actions}
}
+
+ {footer &&
{footer}
}
+
+ );
+ }
+
+ return (
+
+ {image && (
+
+
+
+ )}
+
+ {title &&
{title}
}
+ {subtitle &&
{subtitle}
}
+ {children}
+ {actions &&
{actions}
}
+
+ {footer &&
{footer}
}
+
+ );
+};
+
+export default Card;
\ No newline at end of file
diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx
index cb05d5b0..8506f65c 100644
--- a/src/components/Collapse.tsx
+++ b/src/components/Collapse.tsx
@@ -68,7 +68,7 @@ export const Collapse = ({
'collapse',
variant === 'arrow' && 'collapse-arrow',
variant === 'plus' && 'collapse-plus',
- bordered && 'border base-content/20 border-opacity-20 rounded-box',
+ bordered && 'border base-content/20 border-opacity-20 rounded',
disabled && 'opacity-60 pointer-events-none',
!open && 'w-fit',
className
diff --git a/src/components/input/CheckboxInput.tsx b/src/components/input/CheckboxInput.tsx
new file mode 100644
index 00000000..fb0c95c7
--- /dev/null
+++ b/src/components/input/CheckboxInput.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { HTMLProps, useEffect, useRef } from 'react';
+import { cn } from '@/lib/helper';
+
+interface CheckboxInputProps extends HTMLProps {
+ name: string;
+ label?: string;
+ indeterminate?: boolean;
+ classNames?: {
+ wrapper?: string;
+ inputWrapper?: string;
+ checkbox?: string;
+ label?: string;
+ };
+ isError?: boolean;
+ isValid?: boolean;
+ errorMessage?: string;
+}
+
+const CheckboxInput = ({
+ indeterminate,
+ name,
+ label,
+ className,
+ classNames,
+ isValid,
+ isError,
+ errorMessage,
+ ...rest
+}: CheckboxInputProps) => {
+ const ref = useRef(null!);
+
+ useEffect(() => {
+ if (typeof indeterminate === 'boolean') {
+ ref.current.indeterminate = !rest.checked && indeterminate;
+ }
+ }, [ref, indeterminate]);
+
+ return (
+
+
+
+
+ {label && (
+
+ )}
+
+
+ {errorMessage &&
{errorMessage}}
+
+ );
+};
+
+export default CheckboxInput;
diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx
new file mode 100644
index 00000000..5b2188ee
--- /dev/null
+++ b/src/components/input/NumberInput.tsx
@@ -0,0 +1,415 @@
+'use client';
+
+import {
+ ChangeEvent,
+ ChangeEventHandler,
+ FocusEventHandler,
+ ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import { cn } from '@/lib/helper';
+import Inputmask from 'inputmask';
+
+const createInputMask = (
+ maskType: MaskType,
+ decimals: number,
+ thousandSeparator: string,
+ decimalSeparator: string,
+ allowNegative: boolean,
+ oncomplete?: () => void,
+ onincomplete?: () => void,
+ oncleared?: () => void
+): Inputmask.Instance => {
+ const options: Inputmask.Options = {
+ alias: 'numeric',
+ groupSeparator: thousandSeparator,
+ radixPoint: decimalSeparator,
+ digits: decimals,
+ allowMinus: allowNegative,
+ rightAlign: false,
+ insertMode: true,
+ autoUnmask: false,
+ clearMaskOnLostFocus: false,
+ digitsOptional: decimals > 0,
+ placeholder: '0',
+ numericInput: false,
+ positionCaretOnClick: 'radixFocus',
+ greedy: true,
+ oncomplete,
+ onincomplete,
+ oncleared
+ };
+
+ return new Inputmask(options);
+};
+
+export type MaskType = 'currency' | 'weight' | 'decimal' | 'number' | 'text';
+
+export interface NumberInputProps {
+ label?: string;
+ bottomLabel?: string;
+ name: string;
+ value?: number | string;
+ placeholder?: string;
+
+ className?: {
+ wrapper?: string;
+ label?: string;
+ inputWrapper?: string;
+ input?: string;
+ };
+
+ isError?: boolean;
+ isValid?: boolean;
+ errorMessage?: string;
+ disabled?: boolean;
+ readOnly?: boolean;
+ required?: boolean;
+ isLoading?: boolean;
+
+ startAdornment?: ReactNode;
+ endAdornment?: ReactNode;
+
+ onChange?: ChangeEventHandler;
+ onBlur?: FocusEventHandler;
+ onFocus?: FocusEventHandler;
+
+ maskType?: MaskType;
+ decimals?: number;
+ thousandSeparator?: string;
+ decimalSeparator?: string;
+ currencyPrefix?: string;
+ weightUnit?: string;
+
+ min?: number;
+ max?: number;
+ allowNegative?: boolean;
+
+ oncomplete?: () => void;
+ onincomplete?: () => void;
+ oncleared?: () => void;
+}
+
+const NumberInput = ({
+ label,
+ bottomLabel,
+ name,
+ value,
+ placeholder,
+ className,
+ isError,
+ isValid,
+ errorMessage,
+ startAdornment,
+ endAdornment,
+ disabled = false,
+ required = false,
+ onChange,
+ onBlur,
+ onFocus,
+ readOnly = false,
+ isLoading = false,
+ maskType = 'number',
+ decimals = 0,
+ thousandSeparator = ',',
+ decimalSeparator = '.',
+ currencyPrefix = 'Rp ',
+ weightUnit = 'kg',
+ allowNegative = false,
+ oncomplete,
+ onincomplete,
+ oncleared,
+ }: NumberInputProps) => {
+ const inputRef = useRef(null);
+ const inputmaskRef = useRef(null);
+ const [maskComplete, setMaskComplete] = useState(false);
+ const [maskIncomplete, setMaskIncomplete] = useState(false);
+ const [maskCleared, setMaskCleared] = useState(false);
+
+ const getInputPrefix = (): string => {
+ switch (maskType) {
+ case 'currency':
+ return currencyPrefix;
+ default:
+ return '';
+ }
+ };
+
+ const getInputSuffix = (): string => {
+ switch (maskType) {
+ case 'weight':
+ return weightUnit;
+ default:
+ return '';
+ }
+ };
+
+ useEffect(() => {
+ if (inputRef.current && !readOnly && !disabled) {
+ if (inputmaskRef.current) {
+ try {
+ inputmaskRef.current.remove();
+ } catch (error) {
+ console.warn('Error removing Inputmask:', error);
+ }
+ }
+
+ const handleComplete = () => {
+ setMaskComplete(true);
+ setMaskIncomplete(false);
+ setMaskCleared(false);
+ if (oncomplete) oncomplete();
+ };
+
+ const handleIncomplete = () => {
+ setMaskIncomplete(true);
+ setMaskComplete(false);
+ setMaskCleared(false);
+ if (onincomplete) onincomplete();
+ };
+
+ const handleCleared = () => {
+ setMaskCleared(true);
+ setMaskComplete(false);
+ setMaskIncomplete(false);
+ if (oncleared) oncleared();
+ };
+
+ const im = createInputMask(
+ maskType,
+ decimals,
+ ',',
+ '.',
+ allowNegative,
+ handleComplete,
+ handleIncomplete,
+ handleCleared
+ );
+
+ try {
+ im.mask(inputRef.current);
+ inputmaskRef.current = im;
+ } catch (error) {
+ console.warn('Error applying Inputmask:', error);
+ inputmaskRef.current = null;
+ }
+ }
+
+ return () => {
+ if (inputmaskRef.current) {
+ try {
+ inputmaskRef.current.remove();
+ } catch (error) {
+ console.warn('Error removing Inputmask on cleanup:', error);
+ }
+ }
+ };
+ }, [maskType, decimals, thousandSeparator, decimalSeparator, allowNegative, readOnly, disabled, oncomplete, onincomplete, oncleared]);
+
+ useEffect(() => {
+ if (inputRef.current && value !== undefined) {
+ if (value === null || value === '') {
+ inputRef.current.value = '';
+ } else {
+ inputRef.current.value = String(value);
+ }
+ }
+ }, [value]);
+
+ const handleKeyUp = (e: React.KeyboardEvent) => {
+ const currentValue = (e.currentTarget as HTMLInputElement).value;
+ console.log('✅ After format:', currentValue);
+
+ if (onChange) {
+ const syntheticEvent = {
+ target: {
+ name,
+ value: currentValue,
+ },
+ } as ChangeEvent;
+ onChange(syntheticEvent);
+ }
+ };
+
+ const inputPrefix = getInputPrefix();
+ const inputSuffix = getInputSuffix();
+
+ return (
+
+ {label && (
+
+ )}
+
+
+ {inputPrefix && (
+
+
+ {inputPrefix}
+
+
+ )}
+
+
+ {startAdornment && startAdornment}
+
+
+
+ {(isLoading || endAdornment) && (
+
+ {isLoading && }
+ {endAdornment && endAdornment}
+
+ )}
+
+
+ {inputSuffix && (
+
+
+ {inputSuffix}
+
+
+ )}
+
+
+ {(maskType === 'text' || (oncomplete || onincomplete || oncleared)) && (
+
+
+ Complete
+
+
+ Incomplete
+
+
+ Cleared
+
+
+ )}
+
+ {!isError && bottomLabel && (
+
{bottomLabel}
+ )}
+ {isError && errorMessage && (
+
{errorMessage}
+ )}
+
+ );
+};
+
+export default NumberInput;
+
diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx
index b35ad7dd..28eb9786 100644
--- a/src/components/input/SelectInput.tsx
+++ b/src/components/input/SelectInput.tsx
@@ -104,7 +104,7 @@ const SelectInput = (props: SelectInputProps) => {
const SelectComponent = createables ? CreatableSelect : Select;
/** 🎯 handleChange tanpa any */
- const handleChange = (val: MultiValue | SingleValue | null): void => {
+ const handleChange = (val: MultiValue | SingleValue): void => {
if (!val) {
onChange?.(null);
return;
diff --git a/src/components/input/TextArea.tsx b/src/components/input/TextArea.tsx
index e9517277..03bf7c0b 100644
--- a/src/components/input/TextArea.tsx
+++ b/src/components/input/TextArea.tsx
@@ -1,10 +1,6 @@
'use client';
-import {
- ChangeEventHandler,
- FocusEventHandler,
- ReactNode,
-} from 'react';
+import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react';
import { cn } from '@/lib/helper';
@@ -52,7 +48,7 @@ const TextArea = ({
onBlur,
readOnly = false,
isLoading = false,
- rows = 3
+ rows = 3,
}: TextAreaProps) => {
return (
)}
- {startAdornment && startAdornment}
+ {startAdornment && startAdornment}
-
-
- {(isLoading || endAdornment) && (
-
- {isLoading && }
-
- {endAdornment && endAdornment}
-
+
+
+ {(isLoading || endAdornment) && (
+
+ {isLoading && }
+
+ {endAdornment && endAdornment}
+
+ )}
{!isError && bottomLabel && (
{bottomLabel}
diff --git a/src/components/input/TextInput.tsx b/src/components/input/TextInput.tsx
index eec312c1..43797637 100644
--- a/src/components/input/TextInput.tsx
+++ b/src/components/input/TextInput.tsx
@@ -87,7 +87,7 @@ const TextInput = ({
= Yup.object({
.transform((value) => (isNaN(value) || value === 0 ? undefined : value))
.min(1, 'Biaya minimal 1!')
.typeError('Biaya harus berupa angka!')
- .test(
- 'one-of-cost-fields',
- 'Biaya pengiriman atau biaya per item wajib diisi!',
- function (value) {
- const { delivery_cost_per_item } = this.parent;
- return (
- (value !== undefined && value > 0) ||
- (delivery_cost_per_item !== undefined && delivery_cost_per_item > 0)
- );
- }
- ),
+ .test('one-of-cost-fields', 'Wajib diisi salah satu!', function (value) {
+ const { delivery_cost_per_item } = this.parent;
+ return (
+ (value !== undefined && value > 0) ||
+ (delivery_cost_per_item !== undefined && delivery_cost_per_item > 0)
+ );
+ }),
delivery_cost_per_item: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value))
.min(1, 'Biaya per item minimal 1!')
.typeError('Biaya per item harus berupa angka!')
- .test(
- 'one-of-cost-fields',
- 'Biaya pengiriman atau biaya per item wajib diisi!',
- function (value) {
- const { delivery_cost } = this.parent;
- return (
- (value !== undefined && value > 0) ||
- (delivery_cost !== undefined && delivery_cost > 0)
- );
- }
- ),
+ .test('one-of-cost-fields', 'Wajib diisi salah satu!', function (value) {
+ const { delivery_cost } = this.parent;
+ return (
+ (value !== undefined && value > 0) ||
+ (delivery_cost !== undefined && delivery_cost > 0)
+ );
+ }),
document_path: Yup.string().optional(),
document_index: Yup.number().optional(),
document: Yup.mixed
()
diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx
index 34027209..d2c91168 100644
--- a/src/components/pages/inventory/movement/form/MovementForm.tsx
+++ b/src/components/pages/inventory/movement/form/MovementForm.tsx
@@ -7,8 +7,8 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
+import NumberInput from '@/components/input/NumberInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
-import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { FormHeader } from '@/components/helper/form/FormHeader';
import { FormActions } from '@/components/helper/form/FormActions';
import {
@@ -29,6 +29,7 @@ import { SupplierApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouseApi } from '@/services/api/inventory';
import { toast } from 'react-hot-toast';
import FileInput from '@/components/input/FileInput';
+import CheckboxInput from '@/components/input/CheckboxInput';
interface MovementFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -217,7 +218,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) {
return {
isError: false,
- errorMessage: undefined,
+ errorMessage: '',
};
}
@@ -229,7 +230,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return {
isError: touchedField && Boolean(errorField?.[column as string]),
- errorMessage: touchedField ? errorField?.[column as string] : undefined,
+ errorMessage:
+ touchedField && errorField?.[column as string]
+ ? errorField[column as string]
+ : '',
};
};
@@ -246,7 +250,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!touchedDelivery?.products || !errorDelivery?.products) {
return {
isError: false,
- errorMessage: undefined,
+ errorMessage: '',
};
}
@@ -255,7 +259,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return {
isError: Boolean(touchedField && errorField),
- errorMessage: touchedField ? errorField : undefined,
+ errorMessage: touchedField && errorField ? errorField : '',
};
};
@@ -706,7 +710,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang'
value={formik.values.source_warehouse}
onChange={(val) => {
+ formik.setFieldTouched('source_warehouse', true);
formik.setFieldValue('source_warehouse', val);
+ formik.setFieldTouched('source_warehouse_id', true);
formik.setFieldValue(
'source_warehouse_id',
(val as WarehouseOptionType)?.value
@@ -764,7 +770,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang'
value={formik.values.destination_warehouse}
onChange={(val) => {
+ formik.setFieldTouched('destination_warehouse', true);
formik.setFieldValue('destination_warehouse', val);
+ formik.setFieldTouched('destination_warehouse_id', true);
formik.setFieldValue(
'destination_warehouse_id',
(val as WarehouseOptionType)?.value
@@ -831,30 +839,53 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{type !== 'detail' && (
|
- 0
- }
- onChange={(e) => {
- if (e.target.checked) {
- setSelectedProducts(
- formik.values.products?.map(
- (_, idx) => idx
- ) ?? []
- );
- } else {
- setSelectedProducts([]);
+
+ 0
}
- }}
- />
+ onChange={(
+ e: React.ChangeEvent
+ ) => {
+ if (e.target.checked) {
+ setSelectedProducts(
+ formik.values.products?.map(
+ (_, idx) => idx
+ ) ?? []
+ );
+ } else {
+ setSelectedProducts([]);
+ }
+ }}
+ classNames={{
+ wrapper: 'flex justify-center',
+ checkbox: 'checkbox checkbox-sm',
+ }}
+ />
+
|
)}
- Produk |
- Qty |
+
+ Produk
+
+ *
+
+ |
+
+ Qty
+
+ *
+
+ |
{type !== 'detail' && Aksi | }
@@ -863,23 +894,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{type !== 'detail' && (
|
- {
- if (e.target.checked) {
- setSelectedProducts([
- ...selectedProducts,
- idx,
- ]);
- } else {
- setSelectedProducts(
- selectedProducts.filter((i) => i !== idx)
- );
- }
- }}
- />
+
+
+ ) => {
+ if (e.target.checked) {
+ setSelectedProducts([
+ ...selectedProducts,
+ idx,
+ ]);
+ } else {
+ setSelectedProducts(
+ selectedProducts.filter((i) => i !== idx)
+ );
+ }
+ }}
+ classNames={{
+ wrapper: 'flex justify-center',
+ checkbox: 'checkbox checkbox-sm',
+ }}
+ />
+
|
)}
@@ -887,10 +925,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
value={product.product ?? undefined}
onChange={(val) => {
+ formik.setFieldTouched(
+ `products.${idx}.product`,
+ true
+ );
formik.setFieldValue(
`products.${idx}.product`,
val
);
+ formik.setFieldTouched(
+ `products.${idx}.product_id`,
+ true
+ );
formik.setFieldValue(
`products.${idx}.product_id`,
(val as ProductWarehouseOptionType)?.value
@@ -911,7 +957,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isClearable
{...isRepeaterInputError(
'products',
- 'product',
+ 'product_id',
idx
)}
className={{
@@ -954,17 +1000,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
{type !== 'detail' && (
-
+
+
+
|
)}
@@ -1006,43 +1054,106 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{/* Deliveries table */}
-
Pengiriman
+
Pengiriman
{type !== 'detail' && (
|
- 0
- }
- onChange={(e) => {
- if (e.target.checked) {
- setSelectedDeliveries(
- formik.values.deliveries?.map(
- (_, idx) => idx
- ) ?? []
- );
- } else {
- setSelectedDeliveries([]);
+
+ 0
}
- }}
- />
+ onChange={(
+ e: React.ChangeEvent
+ ) => {
+ if (e.target.checked) {
+ setSelectedDeliveries(
+ formik.values.deliveries?.map(
+ (_, idx) => idx
+ ) ?? []
+ );
+ } else {
+ setSelectedDeliveries([]);
+ }
+ }}
+ classNames={{
+ wrapper: 'flex justify-center',
+ checkbox: 'checkbox checkbox-sm',
+ }}
+ />
+
|
)}
- Produk |
- Qty |
- Supplier |
- Plat Nomor |
+
+ Produk
+
+ *
+
+ |
+
+ Qty
+
+ *
+
+ |
+
+ Supplier
+
+ *
+
+ |
+
+ Plat Nomor
+
+ *
+
+ |
Dokumen |
- Biaya Pengiriman (Rp.) |
- Biaya Per Item (Rp.) |
- Nama Sopir |
+
+ Biaya Pengiriman (Rp.)
+
+ *
+
+ |
+
+ Biaya Per Item (Rp.)
+
+ *
+
+ |
+
+ Nama Sopir
+
+ *
+
+ |
{type !== 'detail' && Aksi | }
@@ -1051,23 +1162,32 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{type !== 'detail' && (
|
- {
- if (e.target.checked) {
- setSelectedDeliveries([
- ...selectedDeliveries,
- idx,
- ]);
- } else {
- setSelectedDeliveries(
- selectedDeliveries.filter((i) => i !== idx)
- );
- }
- }}
- />
+
+
+ ) => {
+ if (e.target.checked) {
+ setSelectedDeliveries([
+ ...selectedDeliveries,
+ idx,
+ ]);
+ } else {
+ setSelectedDeliveries(
+ selectedDeliveries.filter(
+ (i) => i !== idx
+ )
+ );
+ }
+ }}
+ classNames={{
+ wrapper: 'flex justify-center',
+ checkbox: 'checkbox checkbox-sm',
+ }}
+ />
+
|
)}
@@ -1075,10 +1195,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
value={delivery.products[0]?.product ?? undefined}
onChange={(val) => {
+ formik.setFieldTouched(
+ `deliveries.${idx}.products.0.product`,
+ true
+ );
formik.setFieldValue(
`deliveries.${idx}.products.0.product`,
val
);
+ formik.setFieldTouched(
+ `deliveries.${idx}.products.0.product_id`,
+ true
+ );
formik.setFieldValue(
`deliveries.${idx}.products.0.product_id`,
(val as OptionType)?.value
@@ -1087,6 +1215,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
options={getFilteredProductWarehouseOptions()}
isDisabled={type === 'detail'}
isClearable
+ isError={
+ isDeliveryProductInputError(idx, 0, 'product_id')
+ .isError
+ }
+ errorMessage={
+ isDeliveryProductInputError(idx, 0, 'product_id')
+ .errorMessage
+ }
className={{
wrapper:
'w-full min-w-52 md:min-w-72 lg:min-w-80',
@@ -1122,10 +1258,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
value={delivery.supplier}
onChange={(val) => {
+ formik.setFieldTouched(
+ `deliveries.${idx}.supplier`,
+ true
+ );
formik.setFieldValue(
`deliveries.${idx}.supplier`,
val
);
+ formik.setFieldTouched(
+ `deliveries.${idx}.supplier_id`,
+ true
+ );
formik.setFieldValue(
`deliveries.${idx}.supplier_id`,
(val as OptionType)?.value
@@ -1136,6 +1280,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isLoading={isLoadingSuppliers}
isDisabled={type === 'detail'}
isClearable
+ {...isRepeaterInputError(
+ 'deliveries',
+ 'supplier_id',
+ idx
+ )}
className={{
wrapper:
'w-full min-w-52 md:min-w-72 lg:min-w-80',
@@ -1163,27 +1312,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
{type === 'detail' ? (
-
+ <>
+
+
+
+ >
) : (
{
toast.error(
'Ukuran dokumen maksimal 2 MB!'
);
+ e.target.value = '';
return;
}
formik.setFieldValue(
@@ -1215,15 +1369,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
)}
|
-
handleDeliveryCostChange(idx, e.target.value)
}
onBlur={formik.handleBlur}
+ maskType='currency'
+ decimals={0}
+ min={0}
{...isRepeaterInputError(
'deliveries',
'delivery_cost',
@@ -1231,14 +1387,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
)}
readOnly={type === 'detail'}
className={{
- wrapper: 'w-full min-w-48',
+ wrapper:
+ 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}}
/>
|
-
@@ -1248,6 +1404,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
)
}
onBlur={formik.handleBlur}
+ maskType='currency'
+ decimals={0}
+ min={0}
{...isRepeaterInputError(
'deliveries',
'delivery_cost_per_item',
@@ -1255,7 +1414,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
)}
readOnly={type === 'detail'}
className={{
- wrapper: 'w-full min-w-48',
+ wrapper:
+ 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}}
/>
|
@@ -1280,17 +1440,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{type !== 'detail' && (
-
+
+
+
|
)}
diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx
new file mode 100644
index 00000000..85ec092f
--- /dev/null
+++ b/src/components/pages/production/recording/RecordingTable.tsx
@@ -0,0 +1,495 @@
+'use client';
+
+import { useCallback, useMemo, useState } from 'react';
+import { Icon } from '@iconify/react';
+import { SortingState } from '@tanstack/react-table';
+import { cn } from '@/lib/helper';
+import { useModal } from '@/components/Modal';
+import Button from '@/components/Button';
+import ConfirmationModal from '@/components/modal/ConfirmationModal';
+import { OptionType } from '@/components/input/SelectInput';
+import { ROWS_OPTIONS } from '@/config/constant';
+import { TableToolbar } from '@/components/table/TableToolbar';
+import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
+import Table from '@/components/Table';
+import RowDropdownOptions from '@/components/table/RowDropdownOptions';
+import RowCollapseOptions from '@/components/table/RowCollapseOptions';
+import { type CellContext } from '@tanstack/react-table';
+import { type Recording } from '@/types/api/production/recording';
+
+const dummyRecordings: Recording[] = [
+ {
+ id: 1,
+ flock: {
+ id: 1,
+ name: 'Flock Recording 1',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ created_user: {
+ id: 1,
+ id_user: 1,
+ email: 'admin@example.com',
+ name: 'Admin',
+ },
+ },
+ recording_date: '2024-01-01',
+ location: {
+ id: 1,
+ name: 'Location 1',
+ address: 'Jl. Contoh No. 1',
+ area: {
+ id: 1,
+ name: 'Area 1',
+ },
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ created_user: {
+ id: 1,
+ id_user: 1,
+ email: 'admin@example.com',
+ name: 'Admin',
+ },
+ },
+ coop: {
+ id: 1,
+ name: 'Coop 1',
+ status: 'ACTIVE',
+ location: {
+ id: 1,
+ name: 'Location 1',
+ address: 'Jl. Contoh No. 1',
+ area: {
+ id: 1,
+ name: 'Area 1',
+ },
+ },
+ pic: {
+ id: 1,
+ id_user: 1,
+ email: 'pic@example.com',
+ name: 'PIC User',
+ },
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ created_user: {
+ id: 1,
+ id_user: 1,
+ email: 'admin@example.com',
+ name: 'Admin',
+ },
+ },
+ feed_data: [
+ {
+ feed_name: 'Feed 1',
+ feed_qty: 100,
+ feed_stock: 500,
+ },
+ ],
+ body_weight: [
+ {
+ chicken_weight: 2.5,
+ chicken_count: 1000,
+ average_chicken_weight: 2.5,
+ },
+ ],
+ vaccination: [
+ {
+ vaccine_name: 'Vaccine 1',
+ total_stock: 200,
+ used_stock: 150,
+ },
+ ],
+ mortality: [
+ {
+ condition: 'NORMAL',
+ count: 5,
+ },
+ ],
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ created_user: {
+ id: 1,
+ id_user: 1,
+ email: 'admin@example.com',
+ name: 'Admin',
+ },
+ },
+];
+
+const RowOptionsMenu = ({
+ type = 'dropdown',
+ props,
+ deleteClickHandler,
+}: {
+ type: 'dropdown' | 'collapse';
+ props: CellContext;
+ deleteClickHandler: () => void;
+}) => {
+ return (
+
+
+
+
+
+ );
+};
+
+const RecordingTable = () => {
+ const [search, setSearch] = useState('');
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
+ const [sorting, setSorting] = useState([]);
+ const [selectedRecordings, setSelectedRecordings] = useState([]);
+ const [, setSelectedRecording] = useState(undefined);
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+ const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false);
+ const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false);
+
+ const singleDeleteModal = useModal();
+ const bulkApproveModal = useModal();
+ const bulkRejectModal = useModal();
+
+ const searchChangeHandler = useCallback(
+ (e: React.ChangeEvent) => {
+ setSearch(e.target.value);
+ setPage(1);
+ },
+ []
+ );
+
+ const pageSizeChangeHandler = useCallback(
+ (val: OptionType | OptionType[] | null) => {
+ const newVal = val as OptionType;
+ setPageSize(newVal.value as number);
+ setPage(1);
+ },
+ []
+ );
+
+ const paginatedData = useMemo(() => {
+ const filteredData = dummyRecordings.filter(
+ (recording) =>
+ recording.flock.name.toLowerCase().includes(search.toLowerCase()) ||
+ recording.location.name.toLowerCase().includes(search.toLowerCase()) ||
+ recording.coop.name.toLowerCase().includes(search.toLowerCase())
+ );
+ const start = (page - 1) * pageSize;
+ return filteredData.slice(start, start + pageSize);
+ }, [page, pageSize, search]);
+
+ const bulkApproveHandler = async () => {
+ setIsBulkApproveLoading(true);
+ console.log(
+ 'Approved recordings:',
+ paginatedData.filter((_, idx) => selectedRecordings.includes(idx))
+ );
+ setTimeout(() => {
+ setIsBulkApproveLoading(false);
+ setSelectedRecordings([]);
+ bulkApproveModal.closeModal();
+ }, 1000);
+ };
+
+ const bulkRejectHandler = async () => {
+ setIsBulkRejectLoading(true);
+ console.log(
+ 'Rejected recordings:',
+ paginatedData.filter((_, idx) => selectedRecordings.includes(idx))
+ );
+ setTimeout(() => {
+ setIsBulkRejectLoading(false);
+ setSelectedRecordings([]);
+ bulkRejectModal.closeModal();
+ }, 1000);
+ };
+
+ const singleDeleteHandler = async () => {
+ setIsDeleteLoading(true);
+ setTimeout(() => {
+ setIsDeleteLoading(false);
+ singleDeleteModal.closeModal();
+ }, 1000);
+ };
+
+ return (
+
+
+
+ {/* Bulk action buttons */}
+
+ {selectedRecordings.length > 0 && (
+
+
+
+
+ )}
+
+
+
+
+
+
+
(
+ 0 &&
+ table
+ .getRowModel()
+ .rows.every((row) => selectedRecordings.includes(row.index))
+ }
+ onChange={(e) => {
+ if (e.target.checked) {
+ setSelectedRecordings(
+ table.getRowModel().rows.map((row) => row.index)
+ );
+ } else {
+ setSelectedRecordings([]);
+ }
+ }}
+ />
+ ),
+ cell: ({ row }) => (
+ {
+ if (e.target.checked) {
+ setSelectedRecordings([...selectedRecordings, row.index]);
+ } else {
+ setSelectedRecordings(
+ selectedRecordings.filter((i) => i !== row.index)
+ );
+ }
+ }}
+ />
+ ),
+ },
+ {
+ header: '#',
+ cell: (props) => pageSize * (page - 1) + props.row.index + 1,
+ },
+ {
+ accessorKey: 'flock.name',
+ header: 'Flock',
+ },
+ {
+ accessorKey: 'recording_date',
+ header: 'Tanggal Recording',
+ cell: (props) =>
+ new Date(props.row.original.recording_date).toLocaleDateString(),
+ },
+ {
+ accessorKey: 'location.name',
+ header: 'Lokasi',
+ },
+ {
+ accessorKey: 'coop.name',
+ header: 'Kandang',
+ },
+ {
+ accessorKey: 'mortality',
+ header: 'Total Mortality',
+ cell: (props) =>
+ props.row.original.mortality.reduce(
+ (acc, curr) => acc + curr.count,
+ 0
+ ),
+ },
+ {
+ header: 'Aksi',
+ cell: (props: CellContext) => {
+ const currentPageSize =
+ props.table.getPaginationRowModel().rows.length;
+ const currentPageRows =
+ props.table.getPaginationRowModel().flatRows;
+ const currentRowRelativeIndex =
+ currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
+
+ const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
+
+ const deleteClickHandler = () => {
+ setSelectedRecording(props.row.original);
+ singleDeleteModal.openModal();
+ };
+
+ return (
+ <>
+ {currentPageSize > 2 && (
+
+
+
+ )}
+
+ {currentPageSize <= 2 && (
+
+
+
+ )}
+ >
+ );
+ },
+ },
+ ]}
+ pageSize={pageSize}
+ page={page}
+ totalItems={dummyRecordings.length}
+ onPageChange={setPage}
+ isLoading={false}
+ sorting={sorting}
+ setSorting={setSorting}
+ className={{
+ containerClassName: cn({
+ 'mb-20': paginatedData.length === 0,
+ }),
+ tableWrapperClassName: 'overflow-x-auto min-h-full!',
+ tableClassName: 'font-inter w-full table-auto min-h-full!',
+ headerRowClassName: 'border-b border-b-gray-200',
+ headerColumnClassName:
+ 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
+ bodyRowClassName: 'border-b border-b-gray-200',
+ bodyColumnClassName:
+ 'px-6 py-3 last:flex last:flex-row last:justify-end',
+ }}
+ />
+
+
+
+ );
+};
+
+export default RecordingTable;
diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts
new file mode 100644
index 00000000..4b0b37dd
--- /dev/null
+++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts
@@ -0,0 +1,212 @@
+import * as Yup from 'yup';
+import { RECORDING_FLAG_OPTIONS } from '@/config/constant';
+import { Recording } from '@/types/api/production/recording';
+
+export const RecordingFormSchema = Yup.object({
+ flock: Yup.object({
+ value: Yup.number().min(1).required(),
+ label: Yup.string().required(),
+ }).nullable(),
+ flock_id: Yup.number()
+ .default(0)
+ .typeError('Flock wajib diisi!')
+ .test(
+ 'is-valid-flock',
+ 'Flock wajib diisi!',
+ (value) => value !== undefined && value !== null && value > 0
+ )
+ .required('Flock wajib diisi!'),
+ location: Yup.object({
+ value: Yup.number().min(1).required(),
+ label: Yup.string().required(),
+ }).nullable(),
+ location_id: Yup.number()
+ .default(0)
+ .typeError('Lokasi wajib diisi!')
+ .test(
+ 'is-valid-location',
+ 'Lokasi wajib diisi!',
+ (value) => value !== undefined && value !== null && value > 0
+ )
+ .required('Lokasi wajib diisi!'),
+ coop: Yup.object({
+ value: Yup.number().min(1).required(),
+ label: Yup.string().required(),
+ }).nullable(),
+ coop_id: Yup.number()
+ .default(0)
+ .typeError('Kandang wajib diisi!')
+ .test(
+ 'is-valid-coop',
+ 'Kandang wajib diisi!',
+ (value) => value !== undefined && value !== null && value > 0
+ )
+ .required('Kandang wajib diisi!'),
+ recording_date: Yup.date()
+ .required('Tanggal recording wajib diisi')
+ .typeError('Format tanggal tidak valid'),
+ feed_data: Yup.array()
+ .of(
+ Yup.object({
+ feed_id: Yup.string().required('Nama pakan wajib diisi!'),
+ feed_qty: Yup.mixed().notRequired(),
+ feed_stock: Yup.number()
+ .required('Jumlah pakan yang digunakan wajib diisi!')
+ .min(1, 'Jumlah pakan minimal 1!')
+ .typeError('Jumlah pakan yang digunakan harus berupa angka!')
+ .test(
+ 'is-not-exceed-qty',
+ 'Jumlah pakan yang digunakan tidak boleh melebihi stok tersedia!',
+ function (value) {
+ const { feed_qty } = this.parent;
+ if (value === undefined) return true;
+ if (
+ feed_qty === undefined ||
+ feed_qty === '' ||
+ typeof feed_qty !== 'number'
+ )
+ return true;
+ return value <= feed_qty;
+ }
+ ),
+ })
+ )
+ .min(1, 'Minimal harus ada 1 data pakan!')
+ .required('Data pakan wajib diisi!'),
+ body_weight: Yup.array()
+ .of(
+ Yup.object({
+ chicken_weight: Yup.number()
+ .required('Berat ayam wajib diisi!')
+ .min(1, 'Berat ayam minimal 1 gram!')
+ .typeError('Berat ayam harus berupa angka!'),
+ chicken_count: Yup.number()
+ .required('Jumlah ayam wajib diisi!')
+ .min(1, 'Jumlah ayam minimal 1 ekor!')
+ .typeError('Jumlah ayam harus berupa angka!'),
+ average_chicken_weight: Yup.number()
+ .required('Rata-rata berat ayam wajib diisi!')
+ .min(1, 'Rata-rata berat ayam minimal 1 gram!')
+ .typeError('Rata-rata berat ayam harus berupa angka!'),
+ })
+ )
+ .min(1, 'Minimal harus ada 1 data bobot badan!')
+ .required('Data bobot badan wajib diisi!'),
+ vaccination: Yup.array()
+ .of(
+ Yup.object({
+ vaccine_id: Yup.string().required('Nama vaksin wajib diisi!'),
+ total_stock: Yup.mixed().notRequired(),
+ used_stock: Yup.number()
+ .required('Jumlah vaksin yang digunakan wajib diisi!')
+ .min(1, 'Jumlah vaksin minimal 1!')
+ .typeError('Jumlah vaksin yang digunakan harus berupa angka!')
+ .test(
+ 'is-not-exceed-total',
+ 'Jumlah vaksin yang digunakan tidak boleh melebihi stok tersedia!',
+ function (value) {
+ const { total_stock } = this.parent;
+ if (value === undefined) return true;
+ if (
+ total_stock === undefined ||
+ total_stock === '' ||
+ typeof total_stock !== 'number'
+ )
+ return true;
+ return value <= total_stock;
+ }
+ ),
+ })
+ )
+ .min(1, 'Minimal harus ada 1 data vaksinasi!')
+ .required('Data vaksinasi wajib diisi!'),
+ mortality: Yup.array()
+ .of(
+ Yup.object({
+ condition: Yup.mixed()
+ .oneOf(
+ RECORDING_FLAG_OPTIONS.map((opt) => opt.value),
+ 'Kondisi tidak valid!'
+ )
+ .required('Kondisi wajib diisi!'),
+ count: Yup.number()
+ .required('Jumlah mortalitas wajib diisi!')
+ .min(1, 'Jumlah mortalitas minimal 1 ekor!')
+ .typeError('Jumlah mortalitas harus berupa angka!'),
+ })
+ )
+ .min(1, 'Minimal harus ada 1 data mortalitas!')
+ .required('Data mortalitas wajib diisi!'),
+});
+
+export const UpdateRecordingFormSchema = RecordingFormSchema;
+
+export type RecordingFormValues = Yup.InferType;
+
+export const getRecordingFormInitialValues = (
+ initialValues?: Recording
+): RecordingFormValues => ({
+ flock: initialValues?.flock
+ ? {
+ value: initialValues.flock.id,
+ label: initialValues.flock.name,
+ }
+ : null,
+ flock_id: initialValues?.flock?.id ?? 0,
+ location: initialValues?.location
+ ? {
+ value: initialValues.location.id,
+ label: initialValues.location.name,
+ }
+ : null,
+ location_id: initialValues?.location?.id ?? 0,
+ coop: initialValues?.coop
+ ? {
+ value: initialValues.coop.id,
+ label: initialValues.coop.name,
+ }
+ : null,
+ coop_id: initialValues?.coop?.id ?? 0,
+ recording_date: initialValues?.recording_date
+ ? new Date(initialValues.recording_date)
+ : new Date(),
+ feed_data: initialValues?.feed_data
+ ? initialValues.feed_data.map((feed) => ({
+ feed_id: feed.feed_name,
+ feed_qty: feed.feed_qty,
+ feed_stock: feed.feed_stock,
+ }))
+ : [
+ {
+ feed_id: '',
+ feed_qty: '',
+ feed_stock: 0,
+ },
+ ],
+ body_weight: initialValues?.body_weight ?? [
+ {
+ chicken_weight: 0,
+ chicken_count: 0,
+ average_chicken_weight: 0,
+ },
+ ],
+ vaccination: initialValues?.vaccination
+ ? initialValues.vaccination.map((vaccine) => ({
+ vaccine_id: vaccine.vaccine_name,
+ total_stock: vaccine.total_stock,
+ used_stock: vaccine.used_stock,
+ }))
+ : [
+ {
+ vaccine_id: '',
+ total_stock: '',
+ used_stock: 0,
+ },
+ ],
+ mortality: initialValues?.mortality ?? [
+ {
+ condition: '',
+ count: 0,
+ },
+ ],
+});
diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx
new file mode 100644
index 00000000..4d8ef9ca
--- /dev/null
+++ b/src/components/pages/production/recording/form/RecordingForm.tsx
@@ -0,0 +1,1780 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useFormik } from 'formik';
+import { Icon } from '@iconify/react';
+import Button from '@/components/Button';
+import TextInput from '@/components/input/TextInput';
+import NumberInput from '@/components/input/NumberInput';
+import CheckboxInput from '@/components/input/CheckboxInput';
+import SelectInput, { OptionType } from '@/components/input/SelectInput';
+import ConfirmationModal from '@/components/modal/ConfirmationModal';
+import { FormHeader } from '@/components/helper/form/FormHeader';
+import { FormActions } from '@/components/helper/form/FormActions';
+import {
+ CreateRecordingPayload,
+ Recording,
+} from '@/types/api/production/recording';
+import {
+ RecordingFormSchema,
+ RecordingFormValues,
+ getRecordingFormInitialValues,
+ UpdateRecordingFormSchema,
+} from './RecordingForm.schema';
+import { useRecordingFormHandlers } from './useRecordingFormHandlers';
+import { ProjectFlockApi } from '@/services/api/production';
+import { isResponseSuccess } from '@/lib/api-helper';
+import { RECORDING_FLAG_OPTIONS } from '@/config/constant';
+import useSWR from 'swr';
+import { ProductWarehouseApi } from '@/services/api/inventory';
+import { ProjectFlock } from '@/types/api/production/project-flock';
+import { Warehouse } from '@/types/api/master-data/warehouse';
+import { LocationApi } from '@/services/api/master-data';
+import Card from '@/components/Card';
+
+interface RecordingFormProps {
+ type?: 'add' | 'edit' | 'detail';
+ initialValues?: Recording;
+}
+
+const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
+ const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
+ const [flockSelectInputValue, setFlockSelectInputValue] = useState('');
+ const [selectedProjectFlock, setSelectedProjectFlock] =
+ useState(null);
+ const [selectedFeed, setSelectedFeed] = useState([]);
+ const [selectedWeight, setSelectedWeight] = useState([]);
+ const [selectedVaccine, setSelectedVaccine] = useState([]);
+ const [selectedMortality, setSelectedMortality] = useState([]);
+ const [, setRecordingFormErrorMessage] = useState('');
+
+ const {
+ deleteModal,
+ recordingFormErrorMessage,
+ isDeleteLoading,
+ createRecordingHandler,
+ updateRecordingHandler,
+ deleteRecordingClickHandler,
+ confirmationModalDeleteClickHandler,
+ } = useRecordingFormHandlers(initialValues?.id);
+
+ const formikInitialValues = useMemo(
+ () => getRecordingFormInitialValues(initialValues),
+ [initialValues]
+ );
+
+ const formik = useFormik({
+ initialValues: formikInitialValues,
+ validationSchema:
+ type === 'edit' ? UpdateRecordingFormSchema : RecordingFormSchema,
+ validateOnChange: true,
+ validateOnBlur: true,
+ onSubmit: async (values) => {
+ setRecordingFormErrorMessage('');
+ const payload: CreateRecordingPayload = {
+ flock_id: values.flock_id,
+ location_id: values.location_id,
+ coop_id: values.coop_id,
+ recording_date:
+ values.recording_date instanceof Date
+ ? values.recording_date.toISOString()
+ : '',
+ feed_data: (values.feed_data ?? []).map((p) => ({
+ feed_id: p.feed_id,
+ feed_qty:
+ typeof p.feed_qty === 'number'
+ ? p.feed_qty
+ : parseFloat(String(p.feed_qty)) || 0,
+ feed_stock:
+ typeof p.feed_stock === 'number'
+ ? p.feed_stock
+ : parseFloat(String(p.feed_stock)) || 0,
+ })),
+ body_weight: (values.body_weight ?? []).map((b) => ({
+ chicken_weight:
+ typeof b.chicken_weight === 'number'
+ ? b.chicken_weight
+ : parseFloat(String(b.chicken_weight)) || 0,
+ chicken_count:
+ typeof b.chicken_count === 'number'
+ ? b.chicken_count
+ : parseFloat(String(b.chicken_count)) || 0,
+ average_chicken_weight:
+ typeof b.average_chicken_weight === 'number'
+ ? b.average_chicken_weight
+ : parseFloat(String(b.average_chicken_weight)) || 0,
+ })),
+ vaccination: (values.vaccination ?? []).map((v) => ({
+ vaccine_id: v.vaccine_id,
+ total_stock:
+ typeof v.total_stock === 'number'
+ ? v.total_stock
+ : parseFloat(String(v.total_stock)) || 0,
+ used_stock:
+ typeof v.used_stock === 'number'
+ ? v.used_stock
+ : parseFloat(String(v.used_stock)) || 0,
+ })),
+ mortality: (values.mortality ?? []).map((m) => ({
+ condition: m.condition,
+ count:
+ typeof m.count === 'number'
+ ? m.count
+ : parseFloat(String(m.count)) || 0,
+ })),
+ };
+
+ switch (type) {
+ case 'add':
+ await createRecordingHandler(payload);
+ break;
+ case 'edit':
+ await updateRecordingHandler(initialValues?.id as number, payload);
+ break;
+ }
+ },
+ });
+
+ // Locations
+ const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue }).toString()}`;
+ const { data: locations, isLoading: isLoadingLocations } = useSWR(
+ locationsUrl,
+ LocationApi.getAllFetcher
+ );
+
+ // Project Flocks
+ const projectFlocksUrl = useMemo(() => {
+ if (!formik.values.location_id) return null;
+ const params = new URLSearchParams({
+ search: flockSelectInputValue,
+ location_id: formik.values.location_id.toString(),
+ });
+ return `${ProjectFlockApi.basePath}?${params.toString()}`;
+ }, [formik.values.location_id, flockSelectInputValue]);
+
+ const { data: projectFlocks, isLoading: isLoadingFlocks } = useSWR(
+ projectFlocksUrl,
+ ProjectFlockApi.getAllFetcher
+ );
+
+ // Pakan Products
+ const pakanUrl = useMemo(() => {
+ if (!formik.values.location_id) return null;
+ const params = new URLSearchParams({
+ flag: 'PAKAN',
+ search: '',
+ location_id: formik.values.location_id.toString(),
+ });
+ return `${ProductWarehouseApi.basePath}?${params.toString()}`;
+ }, [formik.values.location_id]);
+
+ const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR(
+ pakanUrl,
+ ProductWarehouseApi.getAllFetcher
+ );
+
+ // OVK Products
+ const ovkUrl = useMemo(() => {
+ if (!formik.values.location_id) return null;
+ const params = new URLSearchParams({
+ flag: 'OVK',
+ search: '',
+ location_id: formik.values.location_id.toString(),
+ });
+ return `${ProductWarehouseApi.basePath}?${params.toString()}`;
+ }, [formik.values.location_id]);
+
+ const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR(
+ ovkUrl,
+ ProductWarehouseApi.getAllFetcher
+ );
+
+ // COMPUTED VALUES
+ const buildWarehouseLabel = useCallback((warehouse: Warehouse) => {
+ const parts: string[] = [warehouse.name];
+
+ if ('kandang' in warehouse && warehouse.kandang) {
+ parts.push(warehouse.kandang.name);
+ }
+
+ if ('location' in warehouse && warehouse.location) {
+ parts.push(warehouse.location.name);
+ }
+
+ if (warehouse.area) {
+ parts.push(warehouse.area.name);
+ }
+
+ return parts.join(' - ');
+ }, []);
+
+ const locationOptions = isResponseSuccess(locations)
+ ? locations.data.map((loc) => ({ value: loc.id, label: loc.name }))
+ : [];
+
+ const flockOptions = isResponseSuccess(projectFlocks)
+ ? projectFlocks.data.map((flock) => ({
+ value: flock.id,
+ label: flock.flock.name,
+ }))
+ : [];
+
+ const coopOptions = useMemo(() => {
+ if (!selectedProjectFlock || !selectedProjectFlock.kandangs) return [];
+ return selectedProjectFlock.kandangs.map((kandang) => ({
+ value: kandang.id,
+ label: kandang.name,
+ }));
+ }, [selectedProjectFlock]);
+
+ const filteredPakanProducts = useMemo(() => {
+ if (!isResponseSuccess(pakanProducts) || !formik.values.location_id)
+ return [];
+
+ return pakanProducts.data.filter((product) => {
+ const warehouse = product.warehouse;
+
+ const hasLocationMatch =
+ 'location' in warehouse && warehouse.location
+ ? warehouse.location.id === formik.values.location_id
+ : false;
+
+ const hasPakanFlag = product.product.flags?.includes('PAKAN');
+
+ return hasLocationMatch && hasPakanFlag;
+ });
+ }, [pakanProducts, formik.values.location_id]);
+
+ const pakanOptions = useMemo(
+ () =>
+ filteredPakanProducts.map((product) => ({
+ value: product.id,
+ label: `${product.product.name} - ${buildWarehouseLabel(product.warehouse)} (Stock: ${product.quantity.toLocaleString('id-ID')})`,
+ })),
+ [filteredPakanProducts, buildWarehouseLabel]
+ );
+
+ const pakanStockMap = useMemo(() => {
+ const map = new Map();
+ filteredPakanProducts.forEach((product) => {
+ map.set(product.id, product.quantity);
+ });
+ return map;
+ }, [filteredPakanProducts]);
+
+ const filteredOvkProducts = useMemo(() => {
+ if (!isResponseSuccess(ovkProducts) || !formik.values.location_id)
+ return [];
+
+ return ovkProducts.data.filter((product) => {
+ const warehouse = product.warehouse;
+
+ // Validate location match
+ const hasLocationMatch =
+ 'location' in warehouse && warehouse.location
+ ? warehouse.location.id === formik.values.location_id
+ : false;
+
+ // Validate product has OVK flag
+ const hasOvkFlag = product.product.flags?.includes('OVK');
+
+ return hasLocationMatch && hasOvkFlag;
+ });
+ }, [ovkProducts, formik.values.location_id]);
+
+ const ovkOptions = useMemo(
+ () =>
+ filteredOvkProducts.map((product) => ({
+ value: product.id,
+ label: `${product.product.name} - ${buildWarehouseLabel(product.warehouse)} (Stock: ${product.quantity.toLocaleString('id-ID')})`,
+ })),
+ [filteredOvkProducts, buildWarehouseLabel]
+ );
+
+ const ovkStockMap = useMemo(() => {
+ const map = new Map();
+ filteredOvkProducts.forEach((product) => {
+ map.set(product.id, product.quantity);
+ });
+ return map;
+ }, [filteredOvkProducts]);
+
+ // EFFECTS
+ useEffect(() => {
+ if (initialValues?.flock && isResponseSuccess(projectFlocks)) {
+ const flock = projectFlocks.data.find(
+ (f) => f.id === initialValues.flock.id
+ );
+ if (flock) {
+ setSelectedProjectFlock(flock);
+ }
+ }
+ }, [initialValues, projectFlocks]);
+
+ // Auto-calculate average weight when chicken weight or count changes
+ useEffect(() => {
+ if (formik.values.body_weight) {
+ const updatedBodyWeight = formik.values.body_weight.map((weight) => ({
+ ...weight,
+ average_chicken_weight:
+ weight.chicken_count > 0
+ ? Math.round(weight.chicken_weight / weight.chicken_count)
+ : 0,
+ }));
+
+ // Only update if values are different to avoid infinite loops
+ const hasChanges = updatedBodyWeight.some(
+ (updated, idx) =>
+ updated.average_chicken_weight !==
+ formik.values.body_weight[idx]?.average_chicken_weight
+ );
+
+ if (hasChanges) {
+ formik.setFieldValue('body_weight', updatedBodyWeight);
+ }
+ }
+ }, [
+ formik.values.body_weight?.map((w) => w.chicken_weight),
+ formik.values.body_weight?.map((w) => w.chicken_count),
+ ]);
+
+ // EVENT HANDLERS - Select Inputs
+ const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
+ const locationValue = (val as OptionType)?.value;
+
+ formik.setFieldValue('location', val, false);
+ formik.setFieldValue('location_id', locationValue || 0, false);
+
+ formik.setFieldValue('flock', null, false);
+ formik.setFieldValue('flock_id', 0, false);
+ formik.setFieldValue('coop', null, false);
+ formik.setFieldValue('coop_id', 0, false);
+ setSelectedProjectFlock(null);
+ setFlockSelectInputValue('');
+ };
+
+ const flockChangeHandler = (val: OptionType | OptionType[] | null) => {
+ const flockValue = (val as OptionType)?.value;
+
+ const selected = isResponseSuccess(projectFlocks)
+ ? projectFlocks.data.find((flock) => flock.id === flockValue)
+ : null;
+
+ setSelectedProjectFlock(selected || null);
+
+ formik.setFieldValue('flock', val, false);
+ formik.setFieldValue('flock_id', flockValue || 0, false);
+
+ formik.setFieldValue('coop', null, false);
+ formik.setFieldValue('coop_id', 0, false);
+ };
+
+ const coopChangeHandler = (val: OptionType | OptionType[] | null) => {
+ const coopValue = (val as OptionType)?.value;
+
+ formik.setFieldValue('coop', val, false);
+ formik.setFieldValue('coop_id', coopValue || 0, false);
+ };
+
+ // EVENT HANDLERS - Feed Data
+ const addFeedData = () => {
+ const newFeedData = [
+ ...(formik.values.feed_data || []),
+ {
+ feed: null,
+ feed_id: '',
+ feed_qty: '',
+ feed_stock: 0,
+ },
+ ];
+ formik.setFieldValue('feed_data', newFeedData);
+ };
+
+ const removeFeedData = (idx: number) => {
+ const updatedFeedData = formik.values.feed_data?.filter(
+ (_, i) => i !== idx
+ );
+ formik.setFieldValue('feed_data', updatedFeedData);
+ };
+
+ const removeSelectedFeedData = () => {
+ const updatedFeedData = formik.values.feed_data?.filter(
+ (_, idx) => !selectedFeed.includes(idx)
+ );
+ formik.setFieldValue('feed_data', updatedFeedData);
+ setSelectedFeed([]);
+ };
+
+ // EVENT HANDLERS - Body Weight
+ const addBodyWeight = () => {
+ const newBodyWeight = [
+ ...(formik.values.body_weight || []),
+ {
+ chicken_weight: 0,
+ chicken_count: 0,
+ average_chicken_weight: 0,
+ },
+ ];
+ formik.setFieldValue('body_weight', newBodyWeight);
+ };
+
+ // Handle calculation when chicken_weight changes
+ const handleChickenWeightChange = useCallback(
+ (idx: number, value: number) => {
+ formik.setFieldValue(`body_weight.${idx}.chicken_weight`, value);
+
+ const currentWeight = formik.values.body_weight?.[idx];
+ if (currentWeight) {
+ const chickenCount = currentWeight.chicken_count;
+ if (chickenCount > 0 && value > 0) {
+ const averageWeight = Math.round(value / chickenCount);
+ formik.setFieldValue(
+ `body_weight.${idx}.average_chicken_weight`,
+ averageWeight
+ );
+ } else {
+ formik.setFieldValue(`body_weight.${idx}.average_chicken_weight`, '');
+ }
+ }
+ },
+ [formik]
+ );
+
+ // Handle calculation when chicken_count changes
+ const handleChickenCountChange = useCallback(
+ (idx: number, value: number) => {
+ formik.setFieldValue(`body_weight.${idx}.chicken_count`, value);
+
+ const currentWeight = formik.values.body_weight?.[idx];
+ if (currentWeight) {
+ const chickenWeight = currentWeight.chicken_weight;
+ if (chickenWeight > 0 && value > 0) {
+ const averageWeight = Math.round(chickenWeight / value);
+ formik.setFieldValue(
+ `body_weight.${idx}.average_chicken_weight`,
+ averageWeight
+ );
+ } else {
+ formik.setFieldValue(`body_weight.${idx}.average_chicken_weight`, '');
+ }
+ }
+ },
+ [formik]
+ );
+
+ // Handle calculation when average_weight changes
+ const handleAverageWeightChange = useCallback(
+ (idx: number, value: number) => {
+ formik.setFieldValue(`body_weight.${idx}.average_chicken_weight`, value);
+
+ const currentWeight = formik.values.body_weight?.[idx];
+ if (currentWeight) {
+ const chickenCount = currentWeight.chicken_count;
+ if (chickenCount > 0 && value > 0) {
+ const totalWeight = value * chickenCount;
+ formik.setFieldValue(
+ `body_weight.${idx}.chicken_weight`,
+ totalWeight
+ );
+ } else if (value === 0) {
+ formik.setFieldValue(`body_weight.${idx}.chicken_weight`, '');
+ }
+ }
+ },
+ [formik]
+ );
+
+ // Create wrapper handlers that match NumberInput's onChange signature
+ const handleChickenWeightChangeWrapper = useCallback(
+ (idx: number) => (e: React.ChangeEvent) => {
+ const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0;
+ handleChickenWeightChange(idx, value);
+ },
+ [handleChickenWeightChange]
+ );
+
+ const handleChickenCountChangeWrapper = useCallback(
+ (idx: number) => (e: React.ChangeEvent) => {
+ const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0;
+ handleChickenCountChange(idx, value);
+ },
+ [handleChickenCountChange]
+ );
+
+ const handleAverageWeightChangeWrapper = useCallback(
+ (idx: number) => (e: React.ChangeEvent) => {
+ const value = parseFloat(e.target.value.replace(/[^\d,.-]/g, '').replace(/,/g, '')) || 0;
+ handleAverageWeightChange(idx, value);
+ },
+ [handleAverageWeightChange]
+ );
+
+ const handleVaccinationStockChangeWrapper = useCallback(
+ (idx: number) => (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0;
+ formik.setFieldValue(`vaccination.${idx}.used_stock`, value);
+ },
+ [formik]
+ );
+
+ const handleMortalityCountChangeWrapper = useCallback(
+ (idx: number) => (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0;
+ formik.setFieldValue(`mortality.${idx}.count`, value);
+ },
+ [formik]
+ );
+
+ const removeBodyWeight = (idx: number) => {
+ const updatedBodyWeight = formik.values.body_weight?.filter(
+ (_, i) => i !== idx
+ );
+ formik.setFieldValue('body_weight', updatedBodyWeight);
+ };
+
+ const removeSelectedBodyWeight = () => {
+ const updatedBodyWeight = formik.values.body_weight?.filter(
+ (_, idx) => !selectedWeight.includes(idx)
+ );
+ formik.setFieldValue('body_weight', updatedBodyWeight);
+ setSelectedWeight([]);
+ };
+
+ // EVENT HANDLERS - Vaccination
+ const addVaccination = () => {
+ const newVaccination = [
+ ...(formik.values.vaccination || []),
+ {
+ vaccine: null,
+ vaccine_id: '',
+ total_stock: '',
+ used_stock: 0,
+ },
+ ];
+ formik.setFieldValue('vaccination', newVaccination);
+ };
+
+ const removeVaccination = (idx: number) => {
+ const updatedVaccination = formik.values.vaccination?.filter(
+ (_, i) => i !== idx
+ );
+ formik.setFieldValue('vaccination', updatedVaccination);
+ };
+
+ const removeSelectedVaccination = () => {
+ const updatedVaccination = formik.values.vaccination?.filter(
+ (_, idx) => !selectedVaccine.includes(idx)
+ );
+ formik.setFieldValue('vaccination', updatedVaccination);
+ setSelectedVaccine([]);
+ };
+
+ // EVENT HANDLERS - Mortality
+ const addMortality = () => {
+ const newMortality = [
+ ...(formik.values.mortality || []),
+ {
+ condition: RECORDING_FLAG_OPTIONS[0].value,
+ count: 0,
+ },
+ ];
+ formik.setFieldValue('mortality', newMortality);
+ };
+
+ const removeMortality = (idx: number) => {
+ const updatedMortality = formik.values.mortality?.filter(
+ (_, i) => i !== idx
+ );
+ formik.setFieldValue('mortality', updatedMortality);
+ };
+
+ const removeSelectedMortality = () => {
+ const updatedMortality = formik.values.mortality?.filter(
+ (_, idx) => !selectedMortality.includes(idx)
+ );
+ formik.setFieldValue('mortality', updatedMortality);
+ setSelectedMortality([]);
+ };
+
+ const handleFeedStockChangeWrapper = useCallback(
+ (idx: number) => (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value.replace(/[^\d.-]/g, '')) || 0;
+ formik.setFieldValue(`feed_data.${idx}.feed_stock`, value);
+ },
+ [formik]
+ );
+
+ // HELPER FUNCTIONS
+ const isRepeaterInputError = <
+ T extends 'feed_data' | 'body_weight' | 'vaccination' | 'mortality',
+ >(
+ arrayName: T,
+ column: T extends 'feed_data'
+ ? keyof RecordingFormValues['feed_data'][0]
+ : T extends 'body_weight'
+ ? keyof RecordingFormValues['body_weight'][0]
+ : T extends 'vaccination'
+ ? keyof RecordingFormValues['vaccination'][0]
+ : T extends 'mortality'
+ ? keyof RecordingFormValues['mortality'][0]
+ : never,
+ idx: number
+ ) => {
+ if (
+ !formik.touched[arrayName] ||
+ !Array.isArray(formik.touched[arrayName])
+ ) {
+ return {
+ isError: false,
+ errorMessage: '',
+ };
+ }
+
+ const touchedField = formik.touched[arrayName]?.[idx]?.[column as string];
+ const errorField = formik.errors[arrayName]?.[idx] as Record<
+ string,
+ string
+ >;
+
+ return {
+ isError: touchedField && Boolean(errorField?.[column as string]),
+ errorMessage:
+ touchedField && errorField?.[column as string]
+ ? errorField[column as string]
+ : '',
+ };
+ };
+
+ return (
+ <>
+
+
+ {type !== 'add' && (
+
+ )}
+ >
+ );
+};
+
+export default RecordingForm;
diff --git a/src/components/pages/production/recording/form/useRecordingFormHandlers.ts b/src/components/pages/production/recording/form/useRecordingFormHandlers.ts
new file mode 100644
index 00000000..334b791d
--- /dev/null
+++ b/src/components/pages/production/recording/form/useRecordingFormHandlers.ts
@@ -0,0 +1,70 @@
+import { useCallback, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { toast } from 'react-hot-toast';
+import { useModal } from '@/components/Modal';
+import { RecordingApi } from '@/services/api/production';
+import {
+ CreateRecordingPayload,
+ UpdateRecordingPayload,
+} from '@/types/api/production/recording';
+import { isResponseError } from '@/lib/api-helper';
+
+export const useRecordingFormHandlers = (initialValuesId?: number) => {
+ const router = useRouter();
+ const deleteModal = useModal();
+ const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
+ useState('');
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+
+ const createRecordingHandler = useCallback(
+ async (payload: CreateRecordingPayload) => {
+ const res = await RecordingApi.create(payload);
+ if (isResponseError(res)) {
+ setRecordingFormErrorMessage(res.message);
+ return;
+ }
+ toast.success(res?.message as string);
+ router.push('/flock/recording');
+ },
+ [router]
+ );
+
+ const updateRecordingHandler = useCallback(
+ async (recordingId: number, payload: UpdateRecordingPayload) => {
+ const res = await RecordingApi.update(recordingId, payload);
+ if (res?.status === 'error') {
+ setRecordingFormErrorMessage(res.message);
+ return;
+ }
+ toast.success(res?.message as string);
+ router.refresh();
+ router.push('/flock/recording');
+ },
+ [router]
+ );
+
+ const deleteRecordingClickHandler = useCallback(() => {
+ deleteModal.openModal();
+ }, [deleteModal]);
+
+ const confirmationModalDeleteClickHandler = useCallback(async () => {
+ if (!initialValuesId) return;
+
+ setIsDeleteLoading(true);
+ await RecordingApi.delete(initialValuesId);
+ deleteModal.closeModal();
+ toast.success('Successfully delete Recording!');
+ setIsDeleteLoading(false);
+ router.push('/flock/recording');
+ }, [deleteModal, initialValuesId, router]);
+
+ return {
+ deleteModal,
+ recordingFormErrorMessage,
+ isDeleteLoading,
+ createRecordingHandler,
+ updateRecordingHandler,
+ deleteRecordingClickHandler,
+ confirmationModalDeleteClickHandler,
+ };
+};
diff --git a/src/config/constant.ts b/src/config/constant.ts
index 9a648e03..2b87c4d7 100644
--- a/src/config/constant.ts
+++ b/src/config/constant.ts
@@ -126,13 +126,12 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
{
title: 'Flock',
link: '/master-data/flock',
- icon: 'material-symbols:raven-outline-rounded'
+ icon: 'material-symbols:raven-outline-rounded',
},
],
},
] as const;
-
export const ROWS_OPTIONS = [
{
label: '10',
@@ -215,3 +214,9 @@ export const PRODUCT_FLAG_OPTIONS = [
export const SUPPLIER_FLAG_OPTIONS = [
{ label: 'EKSPEDISI', value: 'EKSPEDISI' },
];
+
+export const RECORDING_FLAG_OPTIONS = [
+ { label: 'Ayam Afkir', value: 'Ayam Afkir' },
+ { label: 'Ayam Culling', value: 'Ayam Culling' },
+ { label: 'Ayam Mati', value: 'Ayam Mati' },
+];
diff --git a/src/services/api/production.ts b/src/services/api/production.ts
index 23a3fe68..1ccf4a6e 100644
--- a/src/services/api/production.ts
+++ b/src/services/api/production.ts
@@ -1,19 +1,30 @@
+import { BaseApiService } from './base';
import {
- ProjectFlock,
CreateProjectFlockPayload,
+ ProjectFlock,
+ UpdateProjectFlockPayload,
} from '@/types/api/production/project-flock';
+import {
+ CreateRecordingPayload,
+ Recording,
+ UpdateRecordingPayload,
+} from '@/types/api/production/recording';
import {
Chickin,
CreateChickinPayload,
UpdateChickinPayload,
} from '@/types/api/production/chickin';
-import { BaseApiService } from '@/services/api/base';
export const ProjectFlockApi = new BaseApiService<
ProjectFlock,
CreateProjectFlockPayload,
- unknown
+ UpdateProjectFlockPayload
>('/production/project_flocks');
+export const RecordingApi = new BaseApiService<
+ Recording,
+ CreateRecordingPayload,
+ UpdateRecordingPayload
+>('/flock/recordings');
export const ChickinApi = new BaseApiService<
Chickin,
CreateChickinPayload,
diff --git a/src/styles/daisyui.css b/src/styles/daisyui.css
index 9a148fb4..dadaa2fa 100644
--- a/src/styles/daisyui.css
+++ b/src/styles/daisyui.css
@@ -8,4 +8,8 @@
--step-bg: var(--color-error);
--step-fg: var(--color-error-content);
}
+
+ .table :where(th, td) {
+ vertical-align: top;
+ }
}
diff --git a/src/types/api/inventory/adjustment.d.ts b/src/types/api/inventory/adjustment.d.ts
index 852389fe..4cb4dab4 100644
--- a/src/types/api/inventory/adjustment.d.ts
+++ b/src/types/api/inventory/adjustment.d.ts
@@ -1,4 +1,5 @@
import { Product } from '@/types/api/master-data/product';
+import { BaseMetadata } from '../base-metadata';
import { Warehouse } from '@/types/api/master-data/warehouse';
export type BaseInventoryAdjustment = {
@@ -28,3 +29,6 @@ export type CreateInventoryAdjustmentPayload = {
quantity: number;
note: string;
};
+
+export type UpdateInventoryAdjustmentPayload =
+ Partial;
diff --git a/src/types/api/master-data/flock.d.ts b/src/types/api/master-data/flock.d.ts
index 3ac5d390..e0dcfda4 100644
--- a/src/types/api/master-data/flock.d.ts
+++ b/src/types/api/master-data/flock.d.ts
@@ -1,14 +1,14 @@
-import { BaseMetadata } from "@/types/api/api-general";
+import { BaseMetadata } from '@/types/api/api-general';
export type BaseFlock = {
id: number;
name: string;
-}
+};
export type Flock = BaseMetadata & BaseFlock;
export type CreateFlockPayload = {
name: string;
-}
+};
-export type UpdateFlockPayload = CreateFlockPayload;
\ No newline at end of file
+export type UpdateFlockPayload = CreateFlockPayload;
diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts
index 306c32f1..ce4043c4 100644
--- a/src/types/api/production/project-flock.d.ts
+++ b/src/types/api/production/project-flock.d.ts
@@ -1,9 +1,9 @@
-import { Area } from "@/types/api/master-data/area";
-import { Fcr } from "@/types/api/master-data/fcr";
-import { Flock } from "@/types/api/master-data/flock";
-import { Kandang } from "@/types/api/master-data/kandang";
-import { Location } from "@/types/api/master-data/location";
-import { BaseMetadata } from "@/types/api/api-general";
+import { Area } from '@/types/api/master-data/area';
+import { Fcr } from '@/types/api/master-data/fcr';
+import { Flock } from '@/types/api/master-data/flock';
+import { Kandang } from '@/types/api/master-data/kandang';
+import { Location } from '@/types/api/master-data/location';
+import { BaseMetadata } from '@/types/api/api-general';
export type BaseProjectFlock = {
id: number;
@@ -21,15 +21,14 @@ export type BaseProjectFlock = {
period: number;
kandang_ids: number[];
kandangs: Kandang[];
-}
+};
export type PeriodFlock = {
flock: Flock;
next_period: number;
-}
+};
-
-export type ProjectFlock = BaseMetadata & BaseProjectFlock
+export type ProjectFlock = BaseMetadata & BaseProjectFlock;
export type CreateProjectFlockPayload = {
flock_id: number;
@@ -39,6 +38,6 @@ export type CreateProjectFlockPayload = {
location_id: number;
period: number;
kandang_ids: number[];
-}
+};
-export type UpdateProjectFlockPayload = CreateProjectFlockPayload;
\ No newline at end of file
+export type UpdateProjectFlockPayload = CreateProjectFlockPayload;
diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts
new file mode 100644
index 00000000..6fac0bc8
--- /dev/null
+++ b/src/types/api/production/recording.d.ts
@@ -0,0 +1,61 @@
+import { BaseMetadata } from '@/types/api/api-general';
+import { Location } from '@/types/api/master-data/location';
+import { Kandang } from '@/types/api/master-data/kandang';
+import { Flock } from '@/types/api/master-data/flock';
+
+export type BaseRecording = {
+ id: number;
+ flock: Flock;
+ recording_date: string;
+ location: Location;
+ coop: Kandang;
+ feed_data: {
+ feed_name: string;
+ feed_qty: number;
+ feed_stock: number;
+ }[];
+ body_weight: {
+ chicken_weight: number;
+ chicken_count: number;
+ average_chicken_weight: number;
+ }[];
+ vaccination: {
+ vaccine_name: string;
+ total_stock: number;
+ used_stock: number;
+ }[];
+ mortality: {
+ condition: string;
+ count: number;
+ }[];
+};
+
+export type Recording = BaseMetadata & BaseRecording;
+
+export type CreateRecordingPayload = {
+ flock_id: number;
+ recording_date: string;
+ location_id: number;
+ coop_id: number;
+ feed_data: {
+ feed_id: string;
+ feed_qty: number;
+ feed_stock: number;
+ }[];
+ body_weight: {
+ chicken_weight: number;
+ chicken_count: number;
+ average_chicken_weight: number;
+ }[];
+ vaccination: {
+ vaccine_id: string;
+ total_stock: number;
+ used_stock: number;
+ }[];
+ mortality: {
+ condition: string;
+ count: number;
+ }[];
+};
+
+export type UpdateRecordingPayload = CreateRecordingPayload;