Merge branch 'feat/FE/US-76/TASK-114-129-136-slicing-ui-and-validation-create-edit-daily-recording-growing-form' into 'feat/FE/US-76/daily-recording-growing'

[FEAT/FE][US#76/TASK#114-129-130-131-136] Slicing UI Feature Daily Recording Growing

See merge request mbugroup/lti-web-client!29
This commit is contained in:
Adnan Zahir
2025-10-24 09:34:51 +07:00
28 changed files with 3897 additions and 218 deletions
+26 -1
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",
@@ -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"
+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",
@@ -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",
+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;
+150
View File
@@ -0,0 +1,150 @@
'use client';
import {
HTMLAttributes,
ReactNode,
} from 'react';
import { cn } from '@/lib/helper';
export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, '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 (
<div className={getCardClasses()} {...props}>
<figure>
<img
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
}
return (
<div className={getCardClasses()} {...props}>
{image && (
<figure>
<img
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
};
export default Card;
+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
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { HTMLProps, useEffect, useRef } from 'react';
import { cn } from '@/lib/helper';
interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
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<HTMLInputElement>(null!);
useEffect(() => {
if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !rest.checked && indeterminate;
}
}, [ref, indeterminate]);
return (
<div
className={cn('flex flex-col items-center gap-1', classNames?.wrapper)}
>
<div
className={cn(
'flex flex-row justify-center items-center gap-2',
classNames?.inputWrapper
)}
>
<input
type='checkbox'
ref={ref}
id={name}
name={name}
className={cn(
'checkbox cursor-pointer',
{
'border-error': isError,
'border-success': isValid,
},
className,
classNames?.checkbox
)}
{...rest}
/>
{label && (
<label
htmlFor={name}
className={cn(
'text-inherit',
{
'text-error': isError,
'text-success': isValid,
},
classNames?.label
)}
>
{label}
</label>
)}
</div>
{errorMessage && <span className='text-error'>{errorMessage}</span>}
</div>
);
};
export default CheckboxInput;
+415
View File
@@ -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<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
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<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);
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 (
<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>
);
};
export default NumberInput;
+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-lg! 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-lg! 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>
+1 -1
View File
@@ -87,7 +87,7 @@ const TextInput = ({
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200',
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white',
{
'border-error': isError,
'border-success!': isValid,
@@ -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' },
];
+15 -4
View File
@@ -1,11 +1,22 @@
import { BaseApiService } from './base';
import {
ProjectFlock,
CreateProjectFlockPayload,
ProjectFlock,
UpdateProjectFlockPayload,
} from '@/types/api/production/project-flock';
import { BaseApiService } from '@/services/api/base';
import {
CreateRecordingPayload,
Recording,
UpdateRecordingPayload,
} from '@/types/api/production/recording';
export const ProjectFlockApi = new BaseApiService<
ProjectFlock,
CreateProjectFlockPayload,
unknown
>('/production/project_flocks');
UpdateProjectFlockPayload
>('/production/project_flocks');
export const RecordingApi = new BaseApiService<
Recording,
CreateRecordingPayload,
UpdateRecordingPayload
>('/flock/recordings');
+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;
+11 -12
View File
@@ -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;
export type UpdateProjectFlockPayload = CreateProjectFlockPayload;
+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;