diff --git a/package-lock.json b/package-lock.json index 6b7c09e6..e1f28d3e 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", @@ -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", diff --git a/package.json b/package.json index 2e806ddd..b371e4e7 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", @@ -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", diff --git a/src/app/production/chickin/detail/page.tsx b/src/app/production/chickin/detail/page.tsx index aef7c4b7..96647c55 100644 --- a/src/app/production/chickin/detail/page.tsx +++ b/src/app/production/chickin/detail/page.tsx @@ -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); }; 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 index 1895c2c5..06438390 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -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, "className"> { +export interface CardProps extends Omit, '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 (
- {imageAlt
@@ -155,11 +129,9 @@ const Card = ({
{image && (
- {imageAlt
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/NumberInput.tsx b/src/components/input/NumberInput.tsx index 4375ca20..132688d7 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -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 { +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; - 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 - | 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(null); + const inputmaskRef = useRef(null); + const [maskComplete, setMaskComplete] = useState(false); + const [maskIncomplete, setMaskIncomplete] = useState(false); + const [maskCleared, setMaskCleared] = useState(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) => { + 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}

+ )} +
); }; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index cbb0c9db..9b57cabc 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 d8d58929..0e7891f7 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} -