"use client"; import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; /** * V2Input * * 통합 입력 컴포넌트 * - text: 텍스트 입력 * - number: 숫자 입력 * - password: 비밀번호 입력 * - slider: 슬라이더 입력 * - color: 색상 선택 * - button: 버튼 (입력이 아닌 액션) */ import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@/components/ui/input"; import { Slider } from "@/components/ui/slider"; import { Label } from "@/components/ui/label"; import { formatNumber as centralFormatNumber } from "@/lib/formatting"; import { cn } from "@/lib/utils"; import { V2InputProps, V2InputConfig, V2InputFormat } from "@/types/v2-components"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; import { AutoGenerationConfig } from "@/types/screen"; import { previewNumberingCode } from "@/lib/api/numberingRule"; // 형식별 입력 마스크 및 검증 패턴 const FORMAT_PATTERNS: Record = { none: { pattern: /.*/, placeholder: "", errorMessage: "" }, email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com", errorMessage: "올바른 이메일 형식이 아닙니다", }, tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678", errorMessage: "올바른 전화번호 형식이 아닙니다", }, url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com", errorMessage: "올바른 URL 형식이 아닙니다 (https://로 시작)", }, currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000", errorMessage: "숫자만 입력 가능합니다" }, biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890", errorMessage: "올바른 사업자번호 형식이 아닙니다", }, }; // 형식 검증 함수 (외부에서도 사용 가능) export function validateInputFormat(value: string, format: V2InputFormat): { isValid: boolean; errorMessage: string } { if (!value || value.trim() === "" || format === "none") { return { isValid: true, errorMessage: "" }; } const formatConfig = FORMAT_PATTERNS[format]; if (!formatConfig) return { isValid: true, errorMessage: "" }; const isValid = formatConfig.pattern.test(value); return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage }; } // 통화 형식 변환 (공통 formatNumber 사용) function formatCurrency(value: string | number): string { const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value; if (isNaN(num)) return ""; return centralFormatNumber(num); } // 사업자번호 형식 변환 function formatBizNo(value: string): string { const digits = value.replace(/\D/g, ""); if (digits.length <= 3) return digits; if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`; return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 10)}`; } // 전화번호 형식 변환 function formatTel(value: string): string { const digits = value.replace(/\D/g, ""); if (digits.length <= 3) return digits; if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`; if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`; return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`; } /** * 텍스트 입력 컴포넌트 */ const TextInput = forwardRef< HTMLInputElement, { value?: string | number; onChange?: (value: string) => void; format?: V2InputFormat; mask?: string; placeholder?: string; readonly?: boolean; disabled?: boolean; className?: string; columnName?: string; inputStyle?: React.CSSProperties; } >(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName, inputStyle }, ref) => { // 검증 상태 const [hasBlurred, setHasBlurred] = useState(false); const [validationError, setValidationError] = useState(""); // 형식에 따른 값 포맷팅 const formatValue = useCallback( (val: string): string => { switch (format) { case "currency": return formatCurrency(val); case "biz_no": return formatBizNo(val); case "tel": return formatTel(val); default: return val; } }, [format], ); const handleChange = useCallback( (e: React.ChangeEvent) => { let newValue = e.target.value; // 형식에 따른 자동 포맷팅 if (format === "currency") { // 숫자와 쉼표만 허용 newValue = newValue.replace(/[^\d,]/g, ""); newValue = formatCurrency(newValue); } else if (format === "biz_no") { newValue = formatBizNo(newValue); } else if (format === "tel") { newValue = formatTel(newValue); } // 입력 중 에러 표시 해제 (입력 중에는 관대하게) if (hasBlurred && validationError) { const { isValid } = validateInputFormat(newValue, format); if (isValid) { setValidationError(""); } } onChange?.(newValue); }, [format, onChange, hasBlurred, validationError], ); // blur 시 형식 검증 const handleBlur = useCallback(() => { setHasBlurred(true); const currentValue = value !== undefined && value !== null ? String(value) : ""; if (currentValue && format !== "none") { const { isValid, errorMessage } = validateInputFormat(currentValue, format); setValidationError(isValid ? "" : errorMessage); } else { setValidationError(""); } }, [value, format]); // 값 변경 시 검증 상태 업데이트 useEffect(() => { if (hasBlurred) { const currentValue = value !== undefined && value !== null ? String(value) : ""; if (currentValue && format !== "none") { const { isValid, errorMessage } = validateInputFormat(currentValue, format); setValidationError(isValid ? "" : errorMessage); } else { setValidationError(""); } } }, [value, format, hasBlurred]); // 글로벌 폼 검증 이벤트 리스너 (저장 시 호출) useEffect(() => { if (format === "none" || !columnName) return; const handleValidateForm = (event: CustomEvent) => { const currentValue = value !== undefined && value !== null ? String(value) : ""; if (currentValue) { const { isValid, errorMessage } = validateInputFormat(currentValue, format); if (!isValid) { setHasBlurred(true); setValidationError(errorMessage); // 검증 결과를 이벤트에 기록 if (event.detail?.errors) { event.detail.errors.push({ columnName, message: errorMessage, }); } } } }; window.addEventListener("validateFormInputs", handleValidateForm as EventListener); return () => { window.removeEventListener("validateFormInputs", handleValidateForm as EventListener); }; }, [format, value, columnName]); const displayValue = useMemo(() => { if (value === undefined || value === null) return ""; return formatValue(String(value)); }, [value, formatValue]); const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder; const hasError = hasBlurred && !!validationError; return (
{hasError &&

{validationError}

}
); }); TextInput.displayName = "TextInput"; /** * 숫자를 콤마 포맷 문자열로 변환 (입력 중 실시간 표시용) * 소수점 입력 중인 경우(끝이 "."이거나 ".0" 등)를 보존 */ function toCommaDisplay(raw: string): string { if (raw === "" || raw === "-") return raw; const negative = raw.startsWith("-"); const abs = negative ? raw.slice(1) : raw; const dotIdx = abs.indexOf("."); const intPart = dotIdx >= 0 ? abs.slice(0, dotIdx) : abs; const decPart = dotIdx >= 0 ? abs.slice(dotIdx) : ""; const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ","); return (negative ? "-" : "") + formatted + decPart; } /** * 숫자 입력 컴포넌트 - 입력 중에도 실시간 천단위 콤마 표시 */ const NumberInput = forwardRef< HTMLInputElement, { value?: number; onChange?: (value: number | undefined) => void; min?: number; max?: number; step?: number; placeholder?: string; readonly?: boolean; disabled?: boolean; className?: string; inputStyle?: React.CSSProperties; } >(({ value, onChange, min, max, placeholder, readonly, disabled, className, inputStyle }, ref) => { const innerRef = useRef(null); const combinedRef = (node: HTMLInputElement | null) => { (innerRef as React.MutableRefObject).current = node; if (typeof ref === "function") ref(node); else if (ref) (ref as React.MutableRefObject).current = node; }; // 콤마 포함된 표시 문자열을 내부 상태로 관리 const [displayValue, setDisplayValue] = useState(() => { if (value === undefined || value === null) return ""; return centralFormatNumber(value); }); // 외부 value가 변경되면 표시 값 동기화 (포커스 아닐 때만) const isFocusedRef = useRef(false); useEffect(() => { if (isFocusedRef.current) return; if (value === undefined || value === null) { setDisplayValue(""); } else { setDisplayValue(centralFormatNumber(value)); } }, [value]); const handleChange = useCallback( (e: React.ChangeEvent) => { const input = e.target; const cursorPos = input.selectionStart ?? 0; const oldVal = displayValue; const rawInput = e.target.value; // 콤마 제거하여 순수 숫자 문자열 추출 const stripped = rawInput.replace(/,/g, ""); // 빈 값 처리 if (stripped === "" || stripped === "-") { setDisplayValue(stripped); onChange?.(undefined); return; } // 숫자 + 소수점만 허용 (입력 중 "123." 같은 중간 상태도 허용) if (!/^-?\d*\.?\d*$/.test(stripped)) return; // 새 콤마 포맷 생성 const newDisplay = toCommaDisplay(stripped); setDisplayValue(newDisplay); // 콤마 개수 차이로 커서 위치 보정 const oldCommas = (oldVal.slice(0, cursorPos).match(/,/g) || []).length; const newCommas = (newDisplay.slice(0, cursorPos).match(/,/g) || []).length; const adjustedCursor = cursorPos + (newCommas - oldCommas); requestAnimationFrame(() => { if (innerRef.current) { innerRef.current.setSelectionRange(adjustedCursor, adjustedCursor); } }); // 실제 숫자 값 전달 (소수점 입력 중이면 아직 전달하지 않음) if (stripped.endsWith(".") || stripped.endsWith("-")) return; let num = parseFloat(stripped); if (isNaN(num)) return; if (min !== undefined && num < min) num = min; if (max !== undefined && num > max) num = max; onChange?.(num); }, [min, max, onChange, displayValue], ); const handleFocus = useCallback(() => { isFocusedRef.current = true; }, []); const handleBlur = useCallback(() => { isFocusedRef.current = false; // 블러 시 최종 포맷 정리 const stripped = displayValue.replace(/,/g, ""); if (stripped === "" || stripped === "-" || stripped === ".") { setDisplayValue(""); onChange?.(undefined); return; } const num = parseFloat(stripped); if (!isNaN(num)) { setDisplayValue(centralFormatNumber(num)); } }, [displayValue, onChange]); return ( ); }); NumberInput.displayName = "NumberInput"; /** * 비밀번호 입력 컴포넌트 */ const PasswordInput = forwardRef< HTMLInputElement, { value?: string; onChange?: (value: string) => void; placeholder?: string; readonly?: boolean; disabled?: boolean; className?: string; inputStyle?: React.CSSProperties; } >(({ value, onChange, placeholder, readonly, disabled, className, inputStyle }, ref) => { const [showPassword, setShowPassword] = useState(false); return (
onChange?.(e.target.value)} placeholder={placeholder || "비밀번호 입력"} readOnly={readonly} disabled={disabled} className={cn("h-full w-full pr-10", className)} style={inputStyle} />
); }); PasswordInput.displayName = "PasswordInput"; /** * 슬라이더 입력 컴포넌트 */ const SliderInput = forwardRef< HTMLDivElement, { value?: number; onChange?: (value: number) => void; min?: number; max?: number; step?: number; disabled?: boolean; className?: string; } >(({ value, onChange, min = 0, max = 100, step = 1, disabled, className }, ref) => { return (
onChange?.(values[0])} min={min} max={max} step={step} disabled={disabled} className="flex-1" /> {value ?? min}
); }); SliderInput.displayName = "SliderInput"; /** * 색상 선택 컴포넌트 */ const ColorInput = forwardRef< HTMLInputElement, { value?: string; onChange?: (value: string) => void; disabled?: boolean; className?: string; } >(({ value, onChange, disabled, className }, ref) => { return (
onChange?.(e.target.value)} disabled={disabled} className="h-full w-12 cursor-pointer p-1" /> onChange?.(e.target.value)} disabled={disabled} className="h-full flex-1 uppercase" maxLength={7} />
); }); ColorInput.displayName = "ColorInput"; /** * 여러 줄 텍스트 입력 컴포넌트 */ const TextareaInput = forwardRef< HTMLTextAreaElement, { value?: string; onChange?: (value: string) => void; placeholder?: string; rows?: number; readonly?: boolean; disabled?: boolean; className?: string; inputStyle?: React.CSSProperties; } >(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className, inputStyle }, ref) => { return (