Files
vexplor/frontend/components/v2/V2Input.tsx
syc0123 65026f14e4 docs: Add documentation for category dropdown depth separation
- Introduced new documents detailing the implementation of visual separation for three-level category dropdowns.
- Updated the `flattenTree` function in both `V2Select.tsx` and `UnifiedSelect.tsx` to use Non-Breaking Space (`\u00A0`) for indentation, ensuring proper visual hierarchy.
- Included a checklist to track the implementation progress and verification of the changes.
- Documented the rationale behind the changes, including the issues with HTML whitespace collapsing and the decisions made to enhance user experience.

These updates aim to improve the clarity and usability of the category selection interface in the application.
2026-03-11 15:53:01 +09:00

1103 lines
38 KiB
TypeScript

"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 { 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<V2InputFormat, { pattern: RegExp; placeholder: string; errorMessage: string }> = {
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<string>("");
// 형식에 따른 값 포맷팅
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<HTMLInputElement>) => {
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 (
<div className="relative h-full w-full">
<Input
ref={ref}
type="text"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={inputPlaceholder}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", hasError && "border-destructive focus-visible:ring-destructive", className)}
style={inputStyle}
/>
{hasError && <p className="text-destructive absolute top-full left-0 mt-0.5 text-[11px]">{validationError}</p>}
</div>
);
});
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<HTMLInputElement>) => {
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 (
<Input
ref={ref}
type="number"
value={value ?? ""}
onChange={handleChange}
min={min}
max={max}
step={step}
placeholder={placeholder || "숫자 입력"}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", className)}
style={inputStyle}
/>
);
});
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 (
<div className="relative">
<Input
ref={ref}
type={showPassword ? "text" : "password"}
value={value ?? ""}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder || "비밀번호 입력"}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full pr-10", className)}
style={inputStyle}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2 text-xs"
>
{showPassword ? "숨김" : "보기"}
</button>
</div>
);
});
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 (
<div ref={ref} className={cn("flex items-center gap-4", className)}>
<Slider
value={[value ?? min]}
onValueChange={(values) => onChange?.(values[0])}
min={min}
max={max}
step={step}
disabled={disabled}
className="flex-1"
/>
<span className="w-12 text-right text-sm font-medium">{value ?? min}</span>
</div>
);
});
SliderInput.displayName = "SliderInput";
/**
* 색상 선택 컴포넌트
*/
const ColorInput = forwardRef<
HTMLInputElement,
{
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, disabled, className }, ref) => {
return (
<div className={cn("flex items-center gap-2", className)}>
<Input
ref={ref}
type="color"
value={value || "#000000"}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className="h-full w-12 cursor-pointer p-1"
/>
<Input
type="text"
value={value || "#000000"}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className="h-full flex-1 uppercase"
maxLength={7}
/>
</div>
);
});
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 (
<textarea
ref={ref}
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
rows={rows}
readOnly={readonly}
disabled={disabled}
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-full w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
style={inputStyle}
/>
);
});
TextareaInput.displayName = "TextareaInput";
/**
* 메인 V2Input 컴포넌트
*/
export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) => {
const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange } = props;
// formData 추출 (채번규칙 날짜 컬럼 기준 생성 시 사용)
const formData = (props as any).formData || {};
const columnName = (props as any).columnName;
// onFormDataChange 추출 (채번 규칙 ID를 formData에 저장하기 위함)
const onFormDataChange = (props as any).onFormDataChange;
// config가 없으면 기본값 사용
const config = (configProp || { type: "text" }) as V2InputConfig & {
inputType?: string;
rows?: number;
autoGeneration?: AutoGenerationConfig;
};
// 자동생성 설정 추출
const autoGeneration: AutoGenerationConfig = (props as any).autoGeneration ||
(config as any).autoGeneration || {
type: "none",
enabled: false,
};
// 자동생성 상태 관리
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string | null>(null);
const isGeneratingRef = useRef(false);
const hasGeneratedRef = useRef(false);
const lastFormDataRef = useRef<string>(""); // 마지막 formData 추적 (채번 규칙용)
// 채번 타입 자동생성 상태
const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false);
const hasGeneratedNumberingRef = useRef(false);
// formData를 ref로 관리하여 closure 문제 해결 (채번 코드 생성 시 최신 값 사용)
const formDataRef = useRef(formData);
formDataRef.current = formData;
// tableName 추출 (여러 소스에서 확인)
// 1. props에서 직접 전달받은 값
// 2. config에서 설정된 값
// 3. 컴포넌트 overrides에서 설정된 값 (V2 레이아웃)
// 4. screenInfo에서 화면 테이블명
const tableName =
(props as any).tableName ||
(config as any).tableName ||
(props as any).component?.tableName ||
(props as any).component?.overrides?.tableName ||
(props as any).screenInfo?.tableName;
// 수정 모드 여부 확인
const originalData = (props as any).originalData || (props as any)._originalData;
const isEditMode = originalData && Object.keys(originalData).length > 0;
// 채번 규칙인 경우 formData 변경 감지 (자기 자신 필드 제외)
const formDataForNumbering = useMemo(() => {
if (autoGeneration.type !== "numbering_rule") return "";
// 자기 자신의 값은 제외 (무한 루프 방지)
const { [columnName]: _, ...rest } = formData;
return JSON.stringify(rest);
}, [autoGeneration.type, formData, columnName]);
// 자동생성 로직
useEffect(() => {
const generateValue = async () => {
// 자동생성 비활성화 또는 생성 중
if (!autoGeneration.enabled || isGeneratingRef.current) {
return;
}
// 수정 모드에서는 자동생성 안함
if (isEditMode) {
return;
}
// 채번 규칙인 경우: formData가 변경되었는지 확인
const isNumberingRule = autoGeneration.type === "numbering_rule";
const formDataChanged =
isNumberingRule && formDataForNumbering !== lastFormDataRef.current && lastFormDataRef.current !== "";
// 이미 생성되었고, formData 변경이 아닌 경우 스킵
if (hasGeneratedRef.current && !formDataChanged) {
return;
}
// 첫 생성 시: 값이 이미 있으면 스킵 (formData 변경 시에는 강제 재생성)
if (!formDataChanged && value !== undefined && value !== null && value !== "") {
return;
}
isGeneratingRef.current = true;
try {
// formData를 전달하여 날짜 컬럼 기준 생성 지원
const generatedValue = await AutoGenerationUtils.generateValue(autoGeneration, columnName, formData);
if (generatedValue !== null && generatedValue !== undefined) {
setAutoGeneratedValue(generatedValue);
onChange?.(generatedValue);
hasGeneratedRef.current = true;
// formData 기록
if (isNumberingRule) {
lastFormDataRef.current = formDataForNumbering;
}
}
} catch (error) {
console.error("자동생성 실패:", error);
} finally {
isGeneratingRef.current = false;
}
};
generateValue();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoGeneration.enabled, autoGeneration.type, isEditMode, formDataForNumbering]);
// 채번 규칙 ID 캐싱
const numberingRuleIdRef = useRef<string | null>(null);
const lastCategoryValuesRef = useRef<string>("");
// 사용자가 직접 입력 중인지 추적 (재생성 방지)
const userEditedNumberingRef = useRef<boolean>(false);
// 원래 수동 입력 부분이 있었는지 추적 (____가 있었으면 계속 편집 가능)
const hadManualPartRef = useRef<boolean>(false);
// 채번 템플릿 저장 (____가 포함된 원본 형태)
const numberingTemplateRef = useRef<string>("");
// 사용자가 수동 입력한 값 저장
const [manualInputValue, setManualInputValue] = useState<string>("");
// formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시)
// 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지)
// inputType을 여러 소스에서 확인
const propsInputType = (props as any).inputType;
const categoryValuesForNumbering = useMemo(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering") return "";
// formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외)
const categoryFields: Record<string, string> = {};
for (const [key, val] of Object.entries(formData)) {
// 현재 채번 필드(columnName)는 제외
if (key === columnName) continue;
if (typeof val === "string" && val) {
categoryFields[key] = val;
}
}
return JSON.stringify(categoryFields);
}, [propsInputType, config.inputType, config.type, formData, columnName]);
// 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용)
useEffect(() => {
const generateNumberingCode = async () => {
// inputType을 여러 소스에서 확인 (props에서 직접 전달받거나 config에서)
const inputType = (props as any).inputType || config.inputType || config.type || "text";
// numbering 타입이 아니면 스킵
if (inputType !== "numbering") {
return;
}
// 수정 모드에서는 자동생성 안함
if (isEditMode) {
return;
}
// 생성 중이면 스킵
if (isGeneratingNumbering) {
return;
}
// 사용자가 직접 편집한 경우 재생성 안함 (단, 카테고리 변경 시에는 재생성)
const categoryChanged = categoryValuesForNumbering !== lastCategoryValuesRef.current;
if (userEditedNumberingRef.current && !categoryChanged) {
return;
}
// 이미 생성되었고 카테고리 값이 변경되지 않았으면 스킵
if (hasGeneratedNumberingRef.current && !categoryChanged) {
return;
}
// 첫 생성 시: 값이 이미 있고 카테고리 변경이 아니면 스킵
if (!categoryChanged && value !== undefined && value !== null && value !== "") {
return;
}
// tableName과 columnName이 필요
if (!tableName || !columnName) {
console.warn("채번 타입: tableName 또는 columnName이 없습니다", { tableName, columnName });
return;
}
setIsGeneratingNumbering(true);
try {
// 채번 규칙 ID 캐싱 (한 번만 조회)
if (!numberingRuleIdRef.current) {
// table_name + column_name 기반으로 채번 규칙 조회
try {
const { apiClient } = await import("@/lib/api/client");
const ruleResponse = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (ruleResponse.data?.success && ruleResponse.data?.data?.ruleId) {
numberingRuleIdRef.current = ruleResponse.data.data.ruleId;
if (onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
}
}
} catch {
// by-column 조회 실패 시 detailSettings fallback
try {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName);
if (columnsResponse.success && columnsResponse.data) {
const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (targetColumn?.detailSettings) {
const parsed =
typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null;
}
}
} catch {
/* ignore */
}
}
}
const numberingRuleId = numberingRuleIdRef.current;
if (!numberingRuleId) {
console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", {
tableName,
columnName,
});
return;
}
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
const currentFormData = formDataRef.current;
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
if (previewResponse.success && previewResponse.data?.generatedCode) {
const generatedCode = previewResponse.data.generatedCode;
hasGeneratedNumberingRef.current = true;
lastCategoryValuesRef.current = categoryValuesForNumbering;
// 수동 입력 부분이 있는 경우
if (generatedCode.includes("____")) {
hadManualPartRef.current = true;
const oldTemplate = numberingTemplateRef.current;
numberingTemplateRef.current = generatedCode;
// 카테고리 변경으로 템플릿이 바뀌었을 때 기존 사용자 입력값 유지
if (oldTemplate && oldTemplate !== generatedCode) {
// 템플릿이 변경되었지만 사용자 입력값은 유지
const templateParts = generatedCode.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 기존 manualInputValue를 사용하여 새 값 조합 (상태는 유지)
// 참고: setManualInputValue는 호출하지 않음 (기존 값 유지)
const finalValue = templatePrefix + (userEditedNumberingRef.current ? "" : "") + templateSuffix;
// 사용자가 입력한 적이 없으면 템플릿 그대로
if (!userEditedNumberingRef.current) {
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
}
// 사용자가 입력한 적이 있으면 입력값 유지하며 템플릿만 변경
// (manualInputValue 상태는 유지되므로 UI에서 자동 반영)
} else {
// 첫 생성
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
userEditedNumberingRef.current = false;
}
} else {
// 수동 입력 부분 없음
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
userEditedNumberingRef.current = false;
}
// 채번 코드 생성 성공
} else {
console.warn("채번 코드 생성 실패:", previewResponse);
}
} catch (error) {
console.error("채번 자동생성 오류:", error);
} finally {
setIsGeneratingNumbering(false);
}
};
generateNumberingCode();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, columnName, isEditMode, categoryValuesForNumbering]);
// 🆕 beforeFormSave 이벤트 리스너 - 저장 직전에 현재 조합된 값을 formData에 주입
useEffect(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering" || !columnName) return;
const handleBeforeFormSave = (event: CustomEvent) => {
const template = numberingTemplateRef.current;
if (!template || !template.includes("____")) return;
// 템플릿에서 prefix와 suffix 추출
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 현재 조합된 값 생성
const currentValue = templatePrefix + manualInputValue + templateSuffix;
// formData에 직접 주입
if (event.detail?.formData && columnName) {
event.detail.formData[columnName] = currentValue;
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;
// 조건부 렌더링 체크
// TODO: conditional 처리 로직 추가
// 타입별 입력 컴포넌트 렌더링
const renderInput = () => {
const inputType = propsInputType || config.inputType || config.type || "text";
switch (inputType) {
case "text":
return (
<TextInput
value={displayValue}
onChange={(v) => {
setAutoGeneratedValue(null); // 사용자 입력 시 자동생성 값 초기화
onChange?.(v);
}}
format={config.format}
mask={config.mask}
placeholder={config.placeholder}
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
disabled={disabled}
columnName={columnName}
inputStyle={inputTextStyle}
/>
);
case "number":
// DB에서 문자열("325")로 반환되는 경우도 숫자로 변환하여 표시
const numValue =
typeof displayValue === "number"
? displayValue
: displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue))
? Number(displayValue)
: undefined;
return (
<NumberInput
value={numValue}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v ?? 0);
}}
min={config.min}
max={config.max}
step={config.step}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
case "password":
return (
<PasswordInput
value={typeof displayValue === "string" ? displayValue : ""}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
case "slider":
// DB에서 문자열로 반환되는 경우도 숫자로 변환
const sliderValue =
typeof displayValue === "number"
? displayValue
: displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue))
? Number(displayValue)
: (config.min ?? 0);
return (
<SliderInput
value={sliderValue}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
min={config.min}
max={config.max}
step={config.step}
disabled={disabled}
/>
);
case "color":
return (
<ColorInput
value={typeof displayValue === "string" ? displayValue : "#000000"}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
disabled={disabled}
/>
);
case "textarea":
return (
<TextareaInput
value={displayValue as string}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
placeholder={config.placeholder}
rows={config.rows}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
case "numbering": {
// 채번 타입: ____ 부분만 편집 가능하게 처리
const template = numberingTemplateRef.current;
const canEdit = hadManualPartRef.current && template;
// 채번 필드 렌더링
// 템플릿이 없으면 읽기 전용 (아직 생성 전이거나 수동 입력 부분 없음)
if (!canEdit) {
return (
<TextInput
value={displayValue || ""}
onChange={() => {}}
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
readonly={true}
disabled={disabled || isGeneratingNumbering}
inputStyle={inputTextStyle}
/>
);
}
// 템플릿에서 prefix와 suffix 추출
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
return (
<div className="numbering-segment border-input flex h-full items-center rounded-md border outline-none transition-[color,box-shadow]">
{/* 고정 접두어 */}
{templatePrefix && (
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-l-[5px] px-2 text-sm">
{templatePrefix}
</span>
)}
{/* 편집 가능한 부분 */}
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const newUserInput = e.target.value;
setManualInputValue(newUserInput);
// 전체 값 조합
const newValue = templatePrefix + newUserInput + templateSuffix;
userEditedNumberingRef.current = true;
setAutoGeneratedValue(newValue);
// 모든 방법으로 formData 업데이트 시도
onChange?.(newValue);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newValue);
}
// 커스텀 이벤트로도 전달 (최후의 보루)
if (typeof window !== "undefined" && columnName) {
window.dispatchEvent(
new CustomEvent("numberingValueChanged", {
detail: { columnName, value: newValue },
}),
);
}
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm ring-0"
disabled={disabled || isGeneratingNumbering}
style={{ ...inputTextStyle, outline: 'none' }}
/>
{/* 고정 접미어 */}
{templateSuffix && (
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-r-[5px] px-2 text-sm">
{templateSuffix}
</span>
)}
</div>
);
}
default:
return (
<TextInput
value={displayValue}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
format={config.format}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
columnName={columnName}
inputStyle={inputTextStyle}
/>
);
}
};
const actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0;
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom"
? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 }
: {}),
fontSize: style?.labelFontSize || "14px",
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-amber-500">*</span>}
</Label>
) : null;
const inputContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "[&_.border]:border-0! [&_input]:border-0! [&_textarea]:border-0!",
(hasCustomBorder || hasCustomRadius) &&
"[&_.rounded-md]:rounded-none! [&_input]:rounded-none! [&_textarea]:rounded-none!",
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderInput()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
className={cn(
"flex flex-col gap-1",
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
)}
style={{
width: componentWidth,
height: componentHeight,
gap: labelGapValue,
}}
>
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize || "14px",
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500",
}}
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
>
{actualLabel}
{required && <span className="ml-0.5 text-amber-500">*</span>}
</Label>
<div
className={cn(
"w-full min-w-0 flex-1",
hasCustomBorder && "[&_.border]:border-0! [&_input]:border-0! [&_textarea]:border-0!",
(hasCustomBorder || hasCustomRadius) &&
"[&_.rounded-md]:rounded-none! [&_input]:rounded-none! [&_textarea]:rounded-none!",
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}) }}
>
{renderInput()}
</div>
</div>
);
}
return (
<div
ref={ref}
id={id}
className="relative"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{labelElement}
{inputContent}
</div>
);
});
V2Input.displayName = "V2Input";
export default V2Input;