fix(FE-86): resolve merge conflict

This commit is contained in:
randy-ar
2025-10-27 11:27:08 +07:00
26 changed files with 3665 additions and 310 deletions
+18 -3
View File
@@ -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",
@@ -30,6 +31,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",
@@ -1636,6 +1638,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",
@@ -2793,9 +2802,9 @@
"license": "MIT"
},
"node_modules/daisyui": {
"version": "5.3.9",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.3.9.tgz",
"integrity": "sha512-741x1pGGSGHcrBYtdE7iKbqW1OoiijYdAZ8oJPZR9MhSKLcMBlHjKfN3YlM2/K7t5jd7O0sg4SqkVNPylalRFw==",
"version": "5.3.10",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.3.10.tgz",
"integrity": "sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -4193,6 +4202,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",
+2
View File
@@ -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",
@@ -32,6 +33,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",
@@ -18,6 +18,11 @@ import { useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
/**
* TODO: Refactor code - pindahin detail ke reuseable component
* setelah implement approval and reject
*/
const DetailChickin = () => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -112,6 +117,7 @@ const DetailChickin = () => {
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
deleteModal.closeModal();
setIsDeleteLoading(false);
};
+11
View File
@@ -0,0 +1,11 @@
import RecordingForm from '@/components/pages/production/recording/form/RecordingForm';
const AddRecording = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<RecordingForm />
</div>
);
};
export default AddRecording;
@@ -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 (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingRecording && (!recording || isResponseError(recording))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingRecording && isResponseSuccess(recording) && (
<RecordingForm type='edit' initialValues={recording.data} />
)}
</div>
);
};
export default RecordingEdit;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -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 (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingRecording && (!recording || isResponseError(recording))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingRecording && isResponseSuccess(recording) && (
<RecordingForm type='detail' initialValues={recording.data} />
)}
</div>
);
};
export default RecordingDetail;
+11
View File
@@ -0,0 +1,11 @@
import RecordingTable from '@/components/pages/production/recording/RecordingTable';
const Recording = () => {
return (
<section className='w-full p-4'>
<RecordingTable />
</section>
);
};
export default Recording;
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/helper';
export interface BadgeProps
extends Omit<HTMLAttributes<HTMLSpanElement>, '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 (
<span className={getBadgeClasses()} {...props}>
{children}
</span>
);
};
export default Badge;
+41 -69
View File
@@ -1,18 +1,17 @@
"use client";
'use client';
import { HTMLAttributes, ReactNode } from "react";
import {
HTMLAttributes,
ReactNode,
} from 'react';
import { cn } from "@/lib/helper";
import Image from "next/image";
import { cn } from '@/lib/helper';
export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, "className"> {
export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
title?: string;
subtitle?: string;
image?: string;
imageAlt?: string;
imageWidth?: number;
imageHeight?: number;
actions?: ReactNode;
footer?: ReactNode;
className?: {
@@ -24,8 +23,8 @@ export interface CardProps
actions?: string;
footer?: string;
};
variant?: "default" | "compact" | "bordered" | "shadow" | "image-full";
size?: "sm" | "md" | "lg";
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
size?: 'sm' | 'md' | 'lg';
}
const Card = ({
@@ -33,110 +32,85 @@ const Card = ({
subtitle,
image,
imageAlt,
imageWidth,
imageHeight,
actions,
footer,
className,
variant = "default",
size = "md",
variant = 'default',
size = 'md',
children,
...props
}: CardProps) => {
const getCardClasses = () => {
const baseClasses = "card bg-base-100";
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",
'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]",
'sm': 'w-64',
'md': 'w-96',
'lg': 'w-[28rem]',
};
return cn(
baseClasses,
variantClasses[variant],
variant !== "image-full" ? sizeClasses[size] : "",
className?.wrapper,
variant !== 'image-full' ? sizeClasses[size] : '',
className?.wrapper
);
};
const getImageDimensions = () => {
if (variant === "image-full") {
return {
width: imageWidth || 128,
height: imageHeight || 128,
};
}
const cardWidths = {
sm: 256, // w-64
md: 384, // w-96
lg: 448, // w-[28rem]
};
return {
width: imageWidth || cardWidths[size],
height: imageHeight || 192,
};
};
const getImageClasses = () => {
if (variant === "image-full") {
return cn("object-cover", className?.image);
if (variant === 'image-full') {
return cn('w-32 h-32 object-cover', className?.image);
}
return cn("w-full object-cover", className?.image);
return cn('h-48 object-cover', className?.image);
};
const getBodyClasses = () => {
const baseClasses = "card-body";
const baseClasses = 'card-body';
if (variant === "compact" || variant === "image-full") {
return cn(baseClasses, "p-4", className?.body);
if (variant === 'compact' || variant === 'image-full') {
return cn(baseClasses, 'p-4', className?.body);
}
return cn(baseClasses, "p-6", className?.body);
return cn(baseClasses, 'p-6', className?.body);
};
const getTitleClasses = () => {
const sizeClasses = {
sm: "text-lg",
md: "text-xl",
lg: "text-2xl",
'sm': 'text-lg',
'md': 'text-xl',
'lg': 'text-2xl',
};
return cn("card-title font-bold", sizeClasses[size], className?.title);
return cn('card-title font-bold', sizeClasses[size], className?.title);
};
const getSubtitleClasses = () => {
return cn("text-base-content/70 text-sm mt-1", className?.subtitle);
return cn('text-base-content/70 text-sm mt-1', className?.subtitle);
};
const getActionsClasses = () => {
return cn("card-actions justify-end mt-4", className?.actions);
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);
return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
};
if (variant === "image-full" && image) {
const imageDimensions = getImageDimensions();
if (variant === 'image-full' && image) {
return (
<div className={getCardClasses()} {...props}>
<figure>
<Image
<img
src={image}
alt={imageAlt || title || "Card image"}
width={imageDimensions.width}
height={imageDimensions.height}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
@@ -155,11 +129,9 @@ const Card = ({
<div className={getCardClasses()} {...props}>
{image && (
<figure>
<Image
<img
src={image}
alt={imageAlt || title || "Card image"}
width={getImageDimensions().width}
height={getImageDimensions().height}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
+1 -1
View File
@@ -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
+396 -35
View File
@@ -1,52 +1,413 @@
'use client';
import { ChangeEvent } from 'react';
import { NumericFormat, OnValueChange } from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
import {
ChangeEvent,
ChangeEventHandler,
FocusEventHandler,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
interface NumberInputProps extends Omit<TextInputProps, 'type'> {
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<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
maskType?: MaskType;
decimals?: number;
thousandSeparator?: string;
decimalSeparator?: string;
decimalScale?: number;
currencyPrefix?: string;
weightUnit?: string;
min?: number;
max?: number;
allowNegative?: boolean;
prefix?: string;
suffix?: string;
fixedDecimalScale?: boolean;
oncomplete?: () => void;
onincomplete?: () => void;
oncleared?: () => void;
}
const NumberInput = ({
thousandSeparator = ',',
decimalSeparator = '.',
decimalScale = 5,
allowNegative = true,
onChange,
...restProps
}: NumberInputProps) => {
const valueChangeHandler: OnValueChange = (
numberFormatValues,
sourceInfo
) => {
const newChangeEvent = sourceInfo.event as
| ChangeEvent<HTMLInputElement>
| undefined;
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<HTMLInputElement>(null);
const inputmaskRef = useRef<Inputmask.Instance | null>(null);
const [maskComplete, setMaskComplete] = useState<boolean>(false);
const [maskIncomplete, setMaskIncomplete] = useState<boolean>(false);
const [maskCleared, setMaskCleared] = useState<boolean>(false);
if (newChangeEvent) {
newChangeEvent.target.value = numberFormatValues.value;
onChange?.(newChangeEvent);
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<HTMLInputElement>) => {
const currentValue = (e.currentTarget as HTMLInputElement).value;
console.log('✅ After format:', currentValue);
if (onChange) {
const syntheticEvent = {
target: {
name,
value: currentValue,
},
} as ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
}
};
const inputPrefix = getInputPrefix();
const inputSuffix = getInputSuffix();
return (
<NumericFormat
thousandSeparator={thousandSeparator}
decimalSeparator={decimalSeparator}
customInput={TextInput}
onValueChange={valueChangeHandler}
decimalScale={decimalScale}
allowNegative={allowNegative}
{...restProps}
/>
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</label>
)}
<div className='relative flex'>
{inputPrefix && (
<div
className={cn(
'inline-flex items-center px-4 py-2 border border-r-0 rounded-l-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
<span
className={cn(
'text-sm font-medium select-none whitespace-nowrap',
{
'text-gray-600': !disabled,
'text-gray-400': disabled,
}
)}
>
{inputPrefix}
</span>
</div>
)}
<div
className={cn(
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white',
{
'border-error': isError,
'border-success!': isValid,
'rounded-l-none!': inputPrefix,
'rounded-r-none!': inputSuffix,
'input-disabled': disabled,
'cursor-not-allowed': disabled,
'bg-gray-50': disabled,
},
className?.inputWrapper
)}
>
{startAdornment && startAdornment}
<input
type='text'
id={name}
name={name}
ref={inputRef}
placeholder={placeholder || '0'}
onKeyUp={handleKeyUp}
onFocus={onFocus}
onBlur={onBlur}
disabled={disabled}
className={cn(
'grow bg-transparent outline-none',
{
'cursor-not-allowed': disabled,
'text-gray-500': disabled,
},
className?.input
)}
readOnly={readOnly}
inputMode='text'
autoComplete='off'
spellCheck={false}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
</div>
{inputSuffix && (
<div
className={cn(
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
<span
className={cn(
'text-sm font-medium select-none whitespace-nowrap',
{
'text-gray-600': !disabled,
'text-gray-400': disabled,
}
)}
>
{inputSuffix}
</span>
</div>
)}
</div>
{(maskType === 'text' || (oncomplete || onincomplete || oncleared)) && (
<div className='flex gap-2 text-xs'>
<span
className={cn(
'px-2 py-1 rounded transition-all duration-200',
maskComplete
? 'bg-green-100 text-green-700 border border-green-200'
: 'bg-gray-50 text-gray-400 border border-gray-200'
)}
>
Complete
</span>
<span
className={cn(
'px-2 py-1 rounded transition-all duration-200',
maskIncomplete
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
: 'bg-gray-50 text-gray-400 border border-gray-200'
)}
>
Incomplete
</span>
<span
className={cn(
'px-2 py-1 rounded transition-all duration-200',
maskCleared
? 'bg-blue-100 text-blue-700 border border-blue-200'
: 'bg-gray-50 text-gray-400 border border-gray-200'
)}
>
Cleared
</span>
</div>
)}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
)}
</div>
);
};
+1 -1
View File
@@ -104,7 +104,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const SelectComponent = createables ? CreatableSelect : Select;
/** 🎯 handleChange tanpa any */
const handleChange = (val: MultiValue<T> | SingleValue<T> | null): void => {
const handleChange = (val: MultiValue<T> | SingleValue<T>): void => {
if (!val) {
onChange?.(null);
return;
+29 -33
View File
@@ -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 (
<div
@@ -83,35 +79,35 @@ const TextArea = ({
)}
</label>
)}
{startAdornment && startAdornment}
{startAdornment && startAdornment}
<textarea
className={cn(
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
id={name}
name={name}
placeholder={placeholder}
value={value}
rows={rows}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
<textarea
className={cn(
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all bg-white',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
id={name}
name={name}
placeholder={placeholder}
value={value}
rows={rows}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
@@ -61,32 +61,24 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = 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<File | string>()
@@ -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) => {
<tr>
{type !== 'detail' && (
<th>
<input
type='checkbox'
className='checkbox'
checked={
formik.values.products?.length ===
selectedProducts.length &&
formik.values.products?.length > 0
}
onChange={(e) => {
if (e.target.checked) {
setSelectedProducts(
formik.values.products?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedProducts([]);
<div className='flex justify-center'>
<CheckboxInput
name='select-all-products'
checked={
formik.values.products?.length ===
selectedProducts.length &&
formik.values.products?.length > 0
}
}}
/>
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts(
formik.values.products?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedProducts([]);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
</th>
)}
<th>Produk</th>
<th>Qty</th>
<th>
Produk
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>
Qty
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
{type !== 'detail' && <th>Aksi</th>}
</tr>
</thead>
@@ -863,23 +894,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<tr key={`product-row-${idx}-${product.product_id}`}>
{type !== 'detail' && (
<td>
<input
type='checkbox'
className='checkbox'
checked={selectedProducts.includes(idx)}
onChange={(e) => {
if (e.target.checked) {
setSelectedProducts([
...selectedProducts,
idx,
]);
} else {
setSelectedProducts(
selectedProducts.filter((i) => i !== idx)
);
}
}}
/>
<div className='flex justify-center'>
<CheckboxInput
name={`product-${idx}`}
checked={selectedProducts.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts([
...selectedProducts,
idx,
]);
} else {
setSelectedProducts(
selectedProducts.filter((i) => i !== idx)
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
</td>
)}
<td>
@@ -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) => {
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => removeProduct(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
<div className='flex flex-col items-start gap-2'>
<Button
type='button'
color='error'
onClick={() => removeProduct(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</div>
</td>
)}
</tr>
@@ -1006,43 +1054,106 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{/* Deliveries table */}
<div className='card bg-base-100 shadow mb-4'>
<div className='card-body'>
<h2 className='card-title mb-4'>Pengiriman</h2>
<h2 className='card-title mb-8'>Pengiriman</h2>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
{type !== 'detail' && (
<th>
<input
type='checkbox'
className='checkbox'
checked={
formik.values.deliveries?.length ===
selectedDeliveries.length &&
formik.values.deliveries?.length > 0
}
onChange={(e) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedDeliveries([]);
<div className='flex justify-center'>
<CheckboxInput
name='select-all-deliveries'
checked={
formik.values.deliveries?.length ===
selectedDeliveries.length &&
formik.values.deliveries?.length > 0
}
}}
/>
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedDeliveries([]);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
</th>
)}
<th>Produk</th>
<th>Qty</th>
<th>Supplier</th>
<th>Plat Nomor</th>
<th>
Produk
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>
Qty
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>
Supplier
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>
Plat Nomor
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>Dokumen</th>
<th>Biaya Pengiriman (Rp.)</th>
<th>Biaya Per Item (Rp.)</th>
<th>Nama Sopir</th>
<th>
Biaya Pengiriman (Rp.)
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>
Biaya Per Item (Rp.)
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>
Nama Sopir
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
{type !== 'detail' && <th>Aksi</th>}
</tr>
</thead>
@@ -1051,23 +1162,32 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<tr key={`delivery-row-${idx}`}>
{type !== 'detail' && (
<td>
<input
type='checkbox'
className='checkbox'
checked={selectedDeliveries.includes(idx)}
onChange={(e) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
idx,
]);
} else {
setSelectedDeliveries(
selectedDeliveries.filter((i) => i !== idx)
);
}
}}
/>
<div className='flex justify-center'>
<CheckboxInput
name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
idx,
]);
} else {
setSelectedDeliveries(
selectedDeliveries.filter(
(i) => i !== idx
)
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
</td>
)}
<td>
@@ -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) => {
</td>
<td>
{type === 'detail' ? (
<Button
color='primary'
className='w-full min-w-52 flex items-center justify-center gap-2'
disabled={!delivery.document_path}
href={delivery.document_path ?? undefined}
target='_blank'
rel='noopener noreferrer'
>
{delivery.document_path ? (
<>
<Icon
icon='material-symbols:file-open-outline'
width={20}
height={20}
/>
Lihat Dokumen
</>
) : (
'-'
)}
</Button>
<>
<div className='flex flex-col items-start gap-2'>
<Button
color='primary'
className='w-full min-w-52 flex items-center justify-center gap-2'
disabled={!delivery.document_path}
href={delivery.document_path ?? undefined}
target='_blank'
rel='noopener noreferrer'
>
{delivery.document_path ? (
<>
<Icon
icon='material-symbols:file-open-outline'
width={20}
height={20}
/>
Lihat Dokumen
</>
) : (
'-'
)}
</Button>
</div>
</>
) : (
<FileInput
name={`deliveries.${idx}.document`}
@@ -1194,6 +1347,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
toast.error(
'Ukuran dokumen maksimal 2 MB!'
);
e.target.value = '';
return;
}
formik.setFieldValue(
@@ -1215,15 +1369,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
)}
</td>
<td>
<TextInput
<NumberInput
required
type='number'
name={`deliveries.${idx}.delivery_cost`}
value={delivery.delivery_cost || ''}
onChange={(e) =>
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',
}}
/>
</td>
<td>
<TextInput
<NumberInput
required
type='number'
name={`deliveries.${idx}.delivery_cost_per_item`}
value={delivery.delivery_cost_per_item || ''}
onChange={(e) =>
@@ -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',
}}
/>
</td>
@@ -1280,17 +1440,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => removeDelivery(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
<div className='flex flex-col items-start gap-2'>
<Button
type='button'
color='error'
onClick={() => removeDelivery(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</div>
</td>
)}
</tr>
@@ -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<Recording, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`recording/detail/?recordingId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`recording/detail/edit/?recordingId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='mdi:delete-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const RecordingTable = () => {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedRecordings, setSelectedRecordings] = useState<number[]>([]);
const [, setSelectedRecording] = useState<Recording | undefined>(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<HTMLInputElement>) => {
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 (
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2 mb-4'>
<TableToolbar
addButton={{
href: 'recording/add',
label: 'Tambah Recording',
}}
search={{
value: search,
onChange: searchChangeHandler,
placeholder: 'Cari Recording',
}}
/>
<TableRowSizeSelector
value={pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/>
</div>
{/* Bulk action buttons */}
<div className={'flex justify-end items-center'}>
{selectedRecordings.length > 0 && (
<div className='flex gap-2 mb-4'>
<Button
type='button'
color='success'
onClick={() => bulkApproveModal.openModal()}
className='flex items-center gap-2'
>
<Icon
icon='material-symbols:check-circle-outline'
width={20}
height={20}
/>
Approve ({selectedRecordings.length})
</Button>
<Button
type='button'
color='error'
onClick={() => bulkRejectModal.openModal()}
className='flex items-center gap-2'
>
<Icon
icon='material-symbols:cancel-outline'
width={20}
height={20}
/>
Reject ({selectedRecordings.length})
</Button>
</div>
)}
<ConfirmationModal
ref={bulkApproveModal.ref}
type='success'
text={`Apakah anda yakin ingin menyetujui ${selectedRecordings.length} data Recording yang dipilih?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isBulkApproveLoading,
onClick: bulkApproveHandler,
}}
/>
<ConfirmationModal
ref={bulkRejectModal.ref}
type='error'
text={`Apakah anda yakin ingin menolak ${selectedRecordings.length} data Recording yang dipilih?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isBulkRejectLoading,
onClick: bulkRejectHandler,
}}
/>
</div>
<Table
data={paginatedData}
columns={[
{
id: 'select',
accessorKey: 'id',
header: ({ table }) => (
<input
type='checkbox'
className='checkbox'
checked={
table.getRowModel().rows.length > 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 }) => (
<input
type='checkbox'
className='checkbox'
checked={selectedRecordings.includes(row.index)}
onChange={(e) => {
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<Recording, unknown>) => {
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 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
]}
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',
}}
/>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Recording ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</div>
);
};
export default RecordingTable;
@@ -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<number | ''>().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<number | ''>().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<string>()
.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<typeof RecordingFormSchema>;
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,
},
],
});
File diff suppressed because it is too large Load Diff
@@ -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,
};
};
+7 -2
View File
@@ -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' },
];
+14 -3
View File
@@ -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,
+4
View File
@@ -8,4 +8,8 @@
--step-bg: var(--color-error);
--step-fg: var(--color-error-content);
}
.table :where(th, td) {
vertical-align: top;
}
}
+4
View File
@@ -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<CreateInventoryAdjustmentPayload>;
+4 -4
View File
@@ -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;
export type UpdateFlockPayload = CreateFlockPayload;
+61
View File
@@ -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;