- Introduced a new state flag `isCreateModeFlag` to determine the mode (INSERT or UPDATE) directly from the event, enhancing clarity in the modal's behavior. - Updated the logic for initializing `originalData` and determining the mode, ensuring that the modal correctly identifies whether to create or update based on the provided data. - Refactored the update logic to send the entire `formData` without relying on `originalData`, streamlining the update process. - Enhanced logging for better debugging and understanding of the modal's state during operations.
1031 lines
36 KiB
TypeScript
1031 lines
36 KiB
TypeScript
"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<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="flex h-full w-full flex-col">
|
|
<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 mt-1 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) {
|
|
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
|
const columnsResponse = await getTableColumns(tableName);
|
|
|
|
if (!columnsResponse.success || !columnsResponse.data) {
|
|
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
|
|
return;
|
|
}
|
|
|
|
const columns = columnsResponse.data.columns || columnsResponse.data;
|
|
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
|
|
|
|
if (!targetColumn) {
|
|
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
|
|
return;
|
|
}
|
|
|
|
// detailSettings에서 numberingRuleId 추출
|
|
if (targetColumn.detailSettings) {
|
|
try {
|
|
// 문자열이면 파싱, 객체면 그대로 사용
|
|
const parsed = typeof targetColumn.detailSettings === "string"
|
|
? JSON.parse(targetColumn.detailSettings)
|
|
: targetColumn.detailSettings;
|
|
numberingRuleIdRef.current = parsed.numberingRuleId || null;
|
|
|
|
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
|
|
if (parsed.numberingRuleId && onFormDataChange && columnName) {
|
|
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
|
|
}
|
|
} catch {
|
|
// JSON 파싱 실패
|
|
}
|
|
}
|
|
}
|
|
|
|
const numberingRuleId = numberingRuleIdRef.current;
|
|
|
|
if (!numberingRuleId) {
|
|
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { 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":
|
|
return (
|
|
<NumberInput
|
|
value={typeof displayValue === "number" ? displayValue : undefined}
|
|
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":
|
|
return (
|
|
<SliderInput
|
|
value={typeof displayValue === "number" ? displayValue : (config.min ?? 0)}
|
|
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="flex h-full items-center rounded-md border">
|
|
{/* 고정 접두어 */}
|
|
{templatePrefix && (
|
|
<span className="text-muted-foreground bg-muted flex h-full items-center 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 bg-transparent px-2 text-sm focus-visible:outline-none"
|
|
disabled={disabled || isGeneratingNumbering}
|
|
style={inputTextStyle}
|
|
/>
|
|
{/* 고정 접미어 */}
|
|
{templateSuffix && (
|
|
<span className="text-muted-foreground bg-muted flex h-full items-center 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}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
|
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
|
|
const actualLabel = label || style?.labelText;
|
|
const showLabel = actualLabel && style?.labelDisplay === true;
|
|
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
|
const componentWidth = size?.width || style?.width;
|
|
const componentHeight = size?.height || style?.height;
|
|
|
|
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
|
|
|
|
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
|
|
// RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만,
|
|
// 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함
|
|
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
|
const hasCustomBackground = !!style?.backgroundColor;
|
|
const hasCustomRadius = !!style?.borderRadius;
|
|
|
|
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
|
|
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;
|
|
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
|
|
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className="relative"
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
|
|
{showLabel && (
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
position: "absolute",
|
|
top: `-${estimatedLabelHeight}px`,
|
|
left: 0,
|
|
fontSize: style?.labelFontSize || "14px",
|
|
color: style?.labelColor || "#64748b",
|
|
fontWeight: style?.labelFontWeight || "500",
|
|
}}
|
|
className="text-sm font-medium whitespace-nowrap"
|
|
>
|
|
{actualLabel}
|
|
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
|
</Label>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"h-full w-full",
|
|
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
|
|
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
|
|
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
|
|
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
|
|
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
|
|
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
|
|
)}
|
|
style={hasCustomText ? customTextStyle : undefined}
|
|
>
|
|
{renderInput()}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
V2Input.displayName = "V2Input";
|
|
|
|
export default V2Input;
|