"use client"; /** * 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 { 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 }; } // 통화 형식 변환 function formatCurrency(value: string | number): string { const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value; if (isNaN(num)) return ""; return num.toLocaleString("ko-KR"); } // 사업자번호 형식 변환 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"; /** * 숫자 입력 컴포넌트 */ 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, step = 1, placeholder, readonly, disabled, className, inputStyle }, ref) => { const handleChange = useCallback( (e: React.ChangeEvent) => { const val = e.target.value; if (val === "") { onChange?.(undefined); return; } let num = parseFloat(val); // 범위 제한 if (min !== undefined && num < min) num = min; if (max !== undefined && num > max) num = max; onChange?.(num); }, [min, max, 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 (