- Added support for custom border, background, and text styles in V2Input and V2Select components, allowing for greater flexibility in styling based on user-defined configurations. - Updated the input and select components to conditionally apply styles based on the presence of custom properties, improving the overall user experience and visual consistency. - Refactored the FileUploadComponent to handle chunked file uploads, enhancing the file upload process by allowing multiple files to be uploaded in batches, improving performance and user feedback during uploads.
1014 lines
36 KiB
TypeScript
1014 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;
|
|
}
|
|
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName }, 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,
|
|
)}
|
|
/>
|
|
{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;
|
|
}
|
|
>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, 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)}
|
|
/>
|
|
);
|
|
});
|
|
NumberInput.displayName = "NumberInput";
|
|
|
|
/**
|
|
* 비밀번호 입력 컴포넌트
|
|
*/
|
|
const PasswordInput = forwardRef<
|
|
HTMLInputElement,
|
|
{
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
placeholder?: string;
|
|
readonly?: boolean;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
>(({ value, onChange, placeholder, readonly, disabled, className }, 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)}
|
|
/>
|
|
<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;
|
|
}
|
|
>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, 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,
|
|
)}
|
|
/>
|
|
);
|
|
});
|
|
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}
|
|
/>
|
|
);
|
|
|
|
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}
|
|
/>
|
|
);
|
|
|
|
case "password":
|
|
return (
|
|
<PasswordInput
|
|
value={typeof displayValue === "string" ? displayValue : ""}
|
|
onChange={(v) => {
|
|
setAutoGeneratedValue(null);
|
|
onChange?.(v);
|
|
}}
|
|
placeholder={config.placeholder}
|
|
readonly={readonly}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
|
|
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}
|
|
/>
|
|
);
|
|
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 템플릿에서 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}
|
|
/>
|
|
{/* 고정 접미어 */}
|
|
{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}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
|
// 🔧 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;
|
|
|
|
// 텍스트 스타일 오버라이드 (CSS 상속으로 내부 input에 전달)
|
|
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;
|
|
|
|
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;
|