diff --git a/package-lock.json b/package-lock.json index a39060ef..4a583dbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", + "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -29,6 +30,7 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", + "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1640,6 +1642,13 @@ "@types/react": "*" } }, + "node_modules/@types/inputmask": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz", + "integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1675,6 +1684,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1744,6 +1754,7 @@ "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/types": "8.43.0", @@ -2261,6 +2272,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2829,7 +2841,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/daisyui": { "version": "5.1.12", @@ -3263,6 +3276,7 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3437,6 +3451,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4229,6 +4244,12 @@ "node": ">=0.8.19" } }, + "node_modules/inputmask": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz", + "integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5766,6 +5787,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5775,6 +5797,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6591,6 +6614,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6758,6 +6782,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index e970499c..70e5737f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", + "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", @@ -31,6 +32,7 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", + "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/production/recording/add/page.tsx b/src/app/production/recording/add/page.tsx new file mode 100644 index 00000000..d41fc183 --- /dev/null +++ b/src/app/production/recording/add/page.tsx @@ -0,0 +1,11 @@ +import RecordingForm from '@/components/pages/production/recording/form/RecordingForm'; + +const AddRecording = () => { + return ( +
+ +
+ ); +}; + +export default AddRecording; diff --git a/src/app/production/recording/detail/edit/page.tsx b/src/app/production/recording/detail/edit/page.tsx new file mode 100644 index 00000000..de53a354 --- /dev/null +++ b/src/app/production/recording/detail/edit/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import RecordingForm from '@/components/pages/production/recording/form/RecordingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const RecordingEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId, + (id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || isResponseError(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && isResponseSuccess(recording) && ( + + )} +
+ ); +}; + +export default RecordingEdit; diff --git a/src/app/production/recording/detail/layout.tsx b/src/app/production/recording/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/recording/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/recording/detail/page.tsx b/src/app/production/recording/detail/page.tsx new file mode 100644 index 00000000..77b82a68 --- /dev/null +++ b/src/app/production/recording/detail/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import RecordingForm from '@/components/pages/production/recording/form/RecordingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const RecordingDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId, + (id: number) => RecordingApi.getSingle(id) + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || isResponseError(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && isResponseSuccess(recording) && ( + + )} +
+ ); +}; + +export default RecordingDetail; diff --git a/src/app/production/recording/page.tsx b/src/app/production/recording/page.tsx new file mode 100644 index 00000000..f31ac19a --- /dev/null +++ b/src/app/production/recording/page.tsx @@ -0,0 +1,11 @@ +import RecordingTable from '@/components/pages/production/recording/RecordingTable'; + +const Recording = () => { + return ( +
+ +
+ ); +}; + +export default Recording; diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx new file mode 100644 index 00000000..5dc5022d --- /dev/null +++ b/src/components/Badge.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { HTMLAttributes, ReactNode } from 'react'; + +import { cn } from '@/lib/helper'; + +export interface BadgeProps + extends Omit, 'className'> { + children?: ReactNode; + className?: { + badge?: string; + }; + variant?: 'default' | 'outline' | 'ghost' | 'soft' | 'dash'; + color?: + | 'neutral' + | 'primary' + | 'secondary' + | 'accent' + | 'info' + | 'success' + | 'warning' + | 'error'; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +} + +const Badge = ({ + children, + className, + variant = 'default', + color, + size = 'md', + ...props +}: BadgeProps) => { + const getBadgeClasses = () => { + const baseClasses = 'badge'; + + const variantClasses = { + default: '', + outline: 'badge-outline', + ghost: 'badge-ghost', + soft: 'badge-soft', + dash: 'badge-dash', + }; + + const colorClasses = { + neutral: 'badge-neutral', + primary: 'badge-primary', + secondary: 'badge-secondary', + accent: 'badge-accent', + info: 'badge-info', + success: 'badge-success', + warning: 'badge-warning', + error: 'badge-error', + }; + + const sizeClasses = { + xs: 'badge-xs', + sm: 'badge-sm', + md: 'badge-md', + lg: 'badge-lg', + xl: 'badge-xl', + }; + + return cn( + baseClasses, + variantClasses[variant], + color && colorClasses[color], + sizeClasses[size], + className?.badge + ); + }; + + return ( + + {children} + + ); +}; + +export default Badge; diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 00000000..ba573dfb --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { + HTMLAttributes, + ReactNode, +} from 'react'; + +import { cn } from '@/lib/helper'; + +export interface CardProps extends Omit, 'className'> { + title?: string; + subtitle?: string; + image?: string; + imageAlt?: string; + actions?: ReactNode; + footer?: ReactNode; + className?: { + wrapper?: string; + image?: string; + body?: string; + title?: string; + subtitle?: string; + actions?: string; + footer?: string; + }; + variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full'; + size?: 'sm' | 'md' | 'lg'; +} + +const Card = ({ + title, + subtitle, + image, + imageAlt, + actions, + footer, + className, + variant = 'default', + size = 'md', + children, + ...props +}: CardProps) => { + const getCardClasses = () => { + const baseClasses = 'card bg-base-100'; + + const variantClasses = { + 'default': '', + 'compact': 'card-compact', + 'bordered': 'border border-base-300', + 'shadow': 'shadow-xl', + 'image-full': 'card-side card-compact shadow-xl', + }; + + const sizeClasses = { + 'sm': 'w-64', + 'md': 'w-96', + 'lg': 'w-[28rem]', + }; + + return cn( + baseClasses, + variantClasses[variant], + variant !== 'image-full' ? sizeClasses[size] : '', + className?.wrapper + ); + }; + + const getImageClasses = () => { + if (variant === 'image-full') { + return cn('w-32 h-32 object-cover', className?.image); + } + return cn('h-48 object-cover', className?.image); + }; + + const getBodyClasses = () => { + const baseClasses = 'card-body'; + + if (variant === 'compact' || variant === 'image-full') { + return cn(baseClasses, 'p-4', className?.body); + } + + return cn(baseClasses, 'p-6', className?.body); + }; + + const getTitleClasses = () => { + const sizeClasses = { + 'sm': 'text-lg', + 'md': 'text-xl', + 'lg': 'text-2xl', + }; + + return cn('card-title font-bold', sizeClasses[size], className?.title); + }; + + const getSubtitleClasses = () => { + return cn('text-base-content/70 text-sm mt-1', className?.subtitle); + }; + + const getActionsClasses = () => { + return cn('card-actions justify-end mt-4', className?.actions); + }; + + const getFooterClasses = () => { + return cn('border-t border-base-300 mt-4 pt-4', className?.footer); + }; + + if (variant === 'image-full' && image) { + return ( +
+
+ {imageAlt +
+
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} + {children} + {actions &&
{actions}
} +
+ {footer &&
{footer}
} +
+ ); + } + + return ( +
+ {image && ( +
+ {imageAlt +
+ )} +
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} + {children} + {actions &&
{actions}
} +
+ {footer &&
{footer}
} +
+ ); +}; + +export default Card; \ No newline at end of file diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index cb05d5b0..8506f65c 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -68,7 +68,7 @@ export const Collapse = ({ 'collapse', variant === 'arrow' && 'collapse-arrow', variant === 'plus' && 'collapse-plus', - bordered && 'border base-content/20 border-opacity-20 rounded-box', + bordered && 'border base-content/20 border-opacity-20 rounded', disabled && 'opacity-60 pointer-events-none', !open && 'w-fit', className diff --git a/src/components/input/CheckboxInput.tsx b/src/components/input/CheckboxInput.tsx new file mode 100644 index 00000000..fb0c95c7 --- /dev/null +++ b/src/components/input/CheckboxInput.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { HTMLProps, useEffect, useRef } from 'react'; +import { cn } from '@/lib/helper'; + +interface CheckboxInputProps extends HTMLProps { + name: string; + label?: string; + indeterminate?: boolean; + classNames?: { + wrapper?: string; + inputWrapper?: string; + checkbox?: string; + label?: string; + }; + isError?: boolean; + isValid?: boolean; + errorMessage?: string; +} + +const CheckboxInput = ({ + indeterminate, + name, + label, + className, + classNames, + isValid, + isError, + errorMessage, + ...rest +}: CheckboxInputProps) => { + const ref = useRef(null!); + + useEffect(() => { + if (typeof indeterminate === 'boolean') { + ref.current.indeterminate = !rest.checked && indeterminate; + } + }, [ref, indeterminate]); + + return ( +
+
+ + + {label && ( + + )} +
+ + {errorMessage && {errorMessage}} +
+ ); +}; + +export default CheckboxInput; diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx new file mode 100644 index 00000000..5b2188ee --- /dev/null +++ b/src/components/input/NumberInput.tsx @@ -0,0 +1,415 @@ +'use client'; + +import { + ChangeEvent, + ChangeEventHandler, + FocusEventHandler, + ReactNode, + useEffect, + useRef, + useState, +} from 'react'; + +import { cn } from '@/lib/helper'; +import Inputmask from 'inputmask'; + +const createInputMask = ( + maskType: MaskType, + decimals: number, + thousandSeparator: string, + decimalSeparator: string, + allowNegative: boolean, + oncomplete?: () => void, + onincomplete?: () => void, + oncleared?: () => void +): Inputmask.Instance => { + const options: Inputmask.Options = { + alias: 'numeric', + groupSeparator: thousandSeparator, + radixPoint: decimalSeparator, + digits: decimals, + allowMinus: allowNegative, + rightAlign: false, + insertMode: true, + autoUnmask: false, + clearMaskOnLostFocus: false, + digitsOptional: decimals > 0, + placeholder: '0', + numericInput: false, + positionCaretOnClick: 'radixFocus', + greedy: true, + oncomplete, + onincomplete, + oncleared + }; + + return new Inputmask(options); +}; + +export type MaskType = 'currency' | 'weight' | 'decimal' | 'number' | 'text'; + +export interface NumberInputProps { + label?: string; + bottomLabel?: string; + name: string; + value?: number | string; + placeholder?: string; + + className?: { + wrapper?: string; + label?: string; + inputWrapper?: string; + input?: string; + }; + + isError?: boolean; + isValid?: boolean; + errorMessage?: string; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + isLoading?: boolean; + + startAdornment?: ReactNode; + endAdornment?: ReactNode; + + onChange?: ChangeEventHandler; + onBlur?: FocusEventHandler; + onFocus?: FocusEventHandler; + + maskType?: MaskType; + decimals?: number; + thousandSeparator?: string; + decimalSeparator?: string; + currencyPrefix?: string; + weightUnit?: string; + + min?: number; + max?: number; + allowNegative?: boolean; + + oncomplete?: () => void; + onincomplete?: () => void; + oncleared?: () => void; +} + +const NumberInput = ({ + label, + bottomLabel, + name, + value, + placeholder, + className, + isError, + isValid, + errorMessage, + startAdornment, + endAdornment, + disabled = false, + required = false, + onChange, + onBlur, + onFocus, + readOnly = false, + isLoading = false, + maskType = 'number', + decimals = 0, + thousandSeparator = ',', + decimalSeparator = '.', + currencyPrefix = 'Rp ', + weightUnit = 'kg', + allowNegative = false, + oncomplete, + onincomplete, + oncleared, + }: NumberInputProps) => { + const inputRef = useRef(null); + const inputmaskRef = useRef(null); + const [maskComplete, setMaskComplete] = useState(false); + const [maskIncomplete, setMaskIncomplete] = useState(false); + const [maskCleared, setMaskCleared] = useState(false); + + const getInputPrefix = (): string => { + switch (maskType) { + case 'currency': + return currencyPrefix; + default: + return ''; + } + }; + + const getInputSuffix = (): string => { + switch (maskType) { + case 'weight': + return weightUnit; + default: + return ''; + } + }; + + useEffect(() => { + if (inputRef.current && !readOnly && !disabled) { + if (inputmaskRef.current) { + try { + inputmaskRef.current.remove(); + } catch (error) { + console.warn('Error removing Inputmask:', error); + } + } + + const handleComplete = () => { + setMaskComplete(true); + setMaskIncomplete(false); + setMaskCleared(false); + if (oncomplete) oncomplete(); + }; + + const handleIncomplete = () => { + setMaskIncomplete(true); + setMaskComplete(false); + setMaskCleared(false); + if (onincomplete) onincomplete(); + }; + + const handleCleared = () => { + setMaskCleared(true); + setMaskComplete(false); + setMaskIncomplete(false); + if (oncleared) oncleared(); + }; + + const im = createInputMask( + maskType, + decimals, + ',', + '.', + allowNegative, + handleComplete, + handleIncomplete, + handleCleared + ); + + try { + im.mask(inputRef.current); + inputmaskRef.current = im; + } catch (error) { + console.warn('Error applying Inputmask:', error); + inputmaskRef.current = null; + } + } + + return () => { + if (inputmaskRef.current) { + try { + inputmaskRef.current.remove(); + } catch (error) { + console.warn('Error removing Inputmask on cleanup:', error); + } + } + }; + }, [maskType, decimals, thousandSeparator, decimalSeparator, allowNegative, readOnly, disabled, oncomplete, onincomplete, oncleared]); + + useEffect(() => { + if (inputRef.current && value !== undefined) { + if (value === null || value === '') { + inputRef.current.value = ''; + } else { + inputRef.current.value = String(value); + } + } + }, [value]); + + const handleKeyUp = (e: React.KeyboardEvent) => { + const currentValue = (e.currentTarget as HTMLInputElement).value; + console.log('✅ After format:', currentValue); + + if (onChange) { + const syntheticEvent = { + target: { + name, + value: currentValue, + }, + } as ChangeEvent; + onChange(syntheticEvent); + } + }; + + const inputPrefix = getInputPrefix(); + const inputSuffix = getInputSuffix(); + + return ( +
+ {label && ( + + )} + +
+ {inputPrefix && ( +
+ + {inputPrefix} + +
+ )} + +
+ {startAdornment && startAdornment} + + + + {(isLoading || endAdornment) && ( +
+ {isLoading && } + {endAdornment && endAdornment} +
+ )} +
+ + {inputSuffix && ( +
+ + {inputSuffix} + +
+ )} +
+ + {(maskType === 'text' || (oncomplete || onincomplete || oncleared)) && ( +
+ + Complete + + + Incomplete + + + Cleared + +
+ )} + + {!isError && bottomLabel && ( +

{bottomLabel}

+ )} + {isError && errorMessage && ( +

{errorMessage}

+ )} +
+ ); +}; + +export default NumberInput; + diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index b35ad7dd..28eb9786 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -104,7 +104,7 @@ const SelectInput = (props: SelectInputProps) => { const SelectComponent = createables ? CreatableSelect : Select; /** 🎯 handleChange tanpa any */ - const handleChange = (val: MultiValue | SingleValue | null): void => { + const handleChange = (val: MultiValue | SingleValue): void => { if (!val) { onChange?.(null); return; diff --git a/src/components/input/TextArea.tsx b/src/components/input/TextArea.tsx index e9517277..03bf7c0b 100644 --- a/src/components/input/TextArea.tsx +++ b/src/components/input/TextArea.tsx @@ -1,10 +1,6 @@ 'use client'; -import { - ChangeEventHandler, - FocusEventHandler, - ReactNode, -} from 'react'; +import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; import { cn } from '@/lib/helper'; @@ -52,7 +48,7 @@ const TextArea = ({ onBlur, readOnly = false, isLoading = false, - rows = 3 + rows = 3, }: TextAreaProps) => { return (
)} - {startAdornment && startAdornment} + {startAdornment && startAdornment} -