코드 할당 요청 시 폼 데이터 추가: numberingRuleController에서 코드 할당 요청 시 폼 데이터를 포함하도록 수정하였습니다. 이를 통해 날짜 컬럼 기준 생성 시 필요한 정보를 전달할 수 있도록 개선하였습니다.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* UnifiedInput
|
||||
*
|
||||
*
|
||||
* 통합 입력 컴포넌트
|
||||
* - text: 텍스트 입력
|
||||
* - number: 숫자 입력
|
||||
@@ -12,12 +12,14 @@
|
||||
* - button: 버튼 (입력이 아닌 액션)
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
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 { UnifiedInputProps, UnifiedInputType, UnifiedInputFormat } from "@/types/unified-components";
|
||||
import { UnifiedInputProps, UnifiedInputConfig, UnifiedInputFormat } from "@/types/unified-components";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { AutoGenerationConfig } from "@/types/screen";
|
||||
|
||||
// 형식별 입력 마스크 및 검증 패턴
|
||||
const FORMAT_PATTERNS: Record<UnifiedInputFormat, { pattern: RegExp; placeholder: string }> = {
|
||||
@@ -56,46 +58,55 @@ function formatTel(value: string): string {
|
||||
/**
|
||||
* 텍스트 입력 컴포넌트
|
||||
*/
|
||||
const TextInput = forwardRef<HTMLInputElement, {
|
||||
value?: string | number;
|
||||
onChange?: (value: string) => void;
|
||||
format?: UnifiedInputFormat;
|
||||
mask?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
|
||||
const TextInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
value?: string | number;
|
||||
onChange?: (value: string) => void;
|
||||
format?: UnifiedInputFormat;
|
||||
mask?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
|
||||
// 형식에 따른 값 포맷팅
|
||||
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 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);
|
||||
}
|
||||
|
||||
onChange?.(newValue);
|
||||
}, [format, onChange]);
|
||||
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);
|
||||
}
|
||||
|
||||
onChange?.(newValue);
|
||||
},
|
||||
[format, onChange],
|
||||
);
|
||||
|
||||
const displayValue = useMemo(() => {
|
||||
if (value === undefined || value === null) return "";
|
||||
@@ -122,32 +133,38 @@ 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]);
|
||||
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
|
||||
@@ -170,14 +187,17 @@ 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 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 (
|
||||
@@ -195,7 +215,7 @@ const PasswordInput = forwardRef<HTMLInputElement, {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs"
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2 text-xs"
|
||||
>
|
||||
{showPassword ? "숨김" : "보기"}
|
||||
</button>
|
||||
@@ -207,15 +227,18 @@ 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) => {
|
||||
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
|
||||
@@ -227,7 +250,7 @@ const SliderInput = forwardRef<HTMLDivElement, {
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium w-12 text-right">{value ?? min}</span>
|
||||
<span className="w-12 text-right text-sm font-medium">{value ?? min}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -236,12 +259,15 @@ SliderInput.displayName = "SliderInput";
|
||||
/**
|
||||
* 색상 선택 컴포넌트
|
||||
*/
|
||||
const ColorInput = forwardRef<HTMLInputElement, {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>(({ value, onChange, disabled, className }, ref) => {
|
||||
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
|
||||
@@ -250,7 +276,7 @@ const ColorInput = forwardRef<HTMLInputElement, {
|
||||
value={value || "#000000"}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-12 h-full p-1 cursor-pointer"
|
||||
className="h-full w-12 cursor-pointer p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -268,15 +294,18 @@ 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) => {
|
||||
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}
|
||||
@@ -287,8 +316,8 @@ const TextareaInput = forwardRef<HTMLTextAreaElement, {
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -298,155 +327,251 @@ TextareaInput.displayName = "TextareaInput";
|
||||
/**
|
||||
* 메인 UnifiedInput 컴포넌트
|
||||
*/
|
||||
export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
readonly,
|
||||
disabled,
|
||||
style,
|
||||
size,
|
||||
config: configProp,
|
||||
value,
|
||||
onChange,
|
||||
} = props;
|
||||
export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props, ref) => {
|
||||
const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange } = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config: UnifiedInputConfig = configProp || { type: "text" };
|
||||
// formData 추출 (채번규칙 날짜 컬럼 기준 생성 시 사용)
|
||||
const formData = (props as any).formData || {};
|
||||
const columnName = (props as any).columnName;
|
||||
|
||||
// 조건부 렌더링 체크
|
||||
// TODO: conditional 처리 로직 추가
|
||||
|
||||
// 타입별 입력 컴포넌트 렌더링
|
||||
const renderInput = () => {
|
||||
const inputType = config.type || "text";
|
||||
switch (inputType) {
|
||||
case "text":
|
||||
return (
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
format={config.format}
|
||||
mask={config.mask}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
// config가 없으면 기본값 사용
|
||||
const config = (configProp || { type: "text" }) as UnifiedInputConfig & {
|
||||
inputType?: string;
|
||||
rows?: number;
|
||||
autoGeneration?: AutoGenerationConfig;
|
||||
};
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<NumberInput
|
||||
value={typeof value === "number" ? value : undefined}
|
||||
onChange={(v) => onChange?.(v ?? 0)}
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
step={config.step}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
// 자동생성 설정 추출
|
||||
const autoGeneration: AutoGenerationConfig = (props as any).autoGeneration ||
|
||||
(config as any).autoGeneration || {
|
||||
type: "none",
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
case "password":
|
||||
return (
|
||||
<PasswordInput
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
// 자동생성 상태 관리
|
||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string | null>(null);
|
||||
const isGeneratingRef = useRef(false);
|
||||
const hasGeneratedRef = useRef(false);
|
||||
const lastFormDataRef = useRef<string>(""); // 마지막 formData 추적 (채번 규칙용)
|
||||
|
||||
case "slider":
|
||||
return (
|
||||
<SliderInput
|
||||
value={typeof value === "number" ? value : config.min ?? 0}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
step={config.step}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
// 수정 모드 여부 확인
|
||||
const originalData = (props as any).originalData || (props as any)._originalData;
|
||||
const isEditMode = originalData && Object.keys(originalData).length > 0;
|
||||
|
||||
case "color":
|
||||
return (
|
||||
<ColorInput
|
||||
value={typeof value === "string" ? value : "#000000"}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
// 채번 규칙인 경우 formData 변경 감지 (자기 자신 필드 제외)
|
||||
const formDataForNumbering = useMemo(() => {
|
||||
if (autoGeneration.type !== "numbering_rule") return "";
|
||||
// 자기 자신의 값은 제외 (무한 루프 방지)
|
||||
const { [columnName]: _, ...rest } = formData;
|
||||
return JSON.stringify(rest);
|
||||
}, [autoGeneration.type, formData, columnName]);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<TextareaInput
|
||||
value={value}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
placeholder={config.placeholder}
|
||||
rows={config.rows}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
// 자동생성 로직
|
||||
useEffect(() => {
|
||||
const generateValue = async () => {
|
||||
// 자동생성 비활성화 또는 생성 중
|
||||
if (!autoGeneration.enabled || isGeneratingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
// 수정 모드에서는 자동생성 안함
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
generateValue();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoGeneration.enabled, autoGeneration.type, isEditMode, formDataForNumbering]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
// 실제 표시할 값 (자동생성 값 또는 props value)
|
||||
const displayValue = autoGeneratedValue ?? value;
|
||||
|
||||
// 조건부 렌더링 체크
|
||||
// TODO: conditional 처리 로직 추가
|
||||
|
||||
// 타입별 입력 컴포넌트 렌더링
|
||||
const renderInput = () => {
|
||||
const inputType = config.inputType || config.type || "text";
|
||||
switch (inputType) {
|
||||
case "text":
|
||||
return (
|
||||
<TextInput
|
||||
value={displayValue}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null); // 사용자 입력 시 자동생성 값 초기화
|
||||
onChange?.(v);
|
||||
}}
|
||||
className="text-sm font-medium flex-shrink-0"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderInput()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
format={config.format}
|
||||
mask={config.mask}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<TextInput
|
||||
value={displayValue}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v);
|
||||
}}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
}}
|
||||
className="flex-shrink-0 text-sm font-medium"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{renderInput()}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UnifiedInput.displayName = "UnifiedInput";
|
||||
|
||||
export default UnifiedInput;
|
||||
|
||||
|
||||
@@ -5,23 +5,99 @@
|
||||
* 통합 입력 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
interface UnifiedInputConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용)
|
||||
}
|
||||
|
||||
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ config, onChange }) => {
|
||||
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
|
||||
// 채번 규칙 목록 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
||||
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
|
||||
const [parentMenus, setParentMenus] = useState<any[]>([]);
|
||||
const [loadingMenus, setLoadingMenus] = useState(false);
|
||||
|
||||
// 선택된 메뉴 OBJID
|
||||
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
|
||||
return config.autoGeneration?.selectedMenuObjid || menuObjid;
|
||||
});
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// 부모 메뉴 목록 로드 (사용자 메뉴의 레벨 2만)
|
||||
useEffect(() => {
|
||||
const loadMenus = async () => {
|
||||
setLoadingMenus(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get("/admin/menus");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const allMenus = response.data.data;
|
||||
|
||||
// 사용자 메뉴(menu_type='1')의 레벨 2만 필터링
|
||||
const level2UserMenus = allMenus.filter((menu: any) =>
|
||||
menu.menu_type === '1' && menu.lev === 2
|
||||
);
|
||||
|
||||
setParentMenus(level2UserMenus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("부모 메뉴 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingMenus(false);
|
||||
}
|
||||
};
|
||||
loadMenus();
|
||||
}, []);
|
||||
|
||||
// 채번 규칙 목록 로드 (선택된 메뉴 기준)
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
if (config.autoGeneration?.type !== "numbering_rule") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedMenuObjid) {
|
||||
setNumberingRules([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
const response = await getAvailableNumberingRules(selectedMenuObjid);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
setNumberingRules([]);
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRules();
|
||||
}, [selectedMenuObjid, config.autoGeneration?.type]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 입력 타입 */}
|
||||
@@ -143,6 +219,229 @@ export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = (
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]"># = 숫자, A = 문자, * = 모든 문자</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 자동생성 기능 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="autoGenerationEnabled"
|
||||
checked={config.autoGeneration?.enabled || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
|
||||
updateConfig("autoGeneration", {
|
||||
...currentConfig,
|
||||
enabled: checked as boolean,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="autoGenerationEnabled" className="text-xs font-medium cursor-pointer">
|
||||
자동생성 활성화
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 자동생성 타입 선택 */}
|
||||
{config.autoGeneration?.enabled && (
|
||||
<div className="space-y-3 pl-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">자동생성 타입</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.type || "none"}
|
||||
onValueChange={(value: AutoGenerationType) => {
|
||||
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
|
||||
updateConfig("autoGeneration", {
|
||||
...currentConfig,
|
||||
type: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="자동생성 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">자동생성 없음</SelectItem>
|
||||
<SelectItem value="uuid">UUID 생성</SelectItem>
|
||||
<SelectItem value="current_user">현재 사용자 ID</SelectItem>
|
||||
<SelectItem value="current_time">현재 시간</SelectItem>
|
||||
<SelectItem value="sequence">순차 번호</SelectItem>
|
||||
<SelectItem value="numbering_rule">채번 규칙</SelectItem>
|
||||
<SelectItem value="random_string">랜덤 문자열</SelectItem>
|
||||
<SelectItem value="random_number">랜덤 숫자</SelectItem>
|
||||
<SelectItem value="company_code">회사 코드</SelectItem>
|
||||
<SelectItem value="department">부서 코드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 선택된 타입 설명 */}
|
||||
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 채번 규칙 선택 */}
|
||||
{config.autoGeneration?.type === "numbering_rule" && (
|
||||
<>
|
||||
{/* 부모 메뉴 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">
|
||||
대상 메뉴 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedMenuObjid?.toString() || ""}
|
||||
onValueChange={(value) => {
|
||||
const menuId = parseInt(value);
|
||||
setSelectedMenuObjid(menuId);
|
||||
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
selectedMenuObjid: menuId,
|
||||
});
|
||||
}}
|
||||
disabled={loadingMenus}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentMenus.length === 0 ? (
|
||||
<SelectItem value="no-menus" disabled>
|
||||
사용 가능한 메뉴가 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
parentMenus.map((menu) => (
|
||||
<SelectItem key={menu.objid} value={menu.objid.toString()}>
|
||||
{menu.menu_name_kor}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 채번 규칙 선택 */}
|
||||
{selectedMenuObjid ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">
|
||||
채번 규칙 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.options?.numberingRuleId || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
numberingRuleId: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={loadingRules}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numberingRules.length === 0 ? (
|
||||
<SelectItem value="no-rules" disabled>
|
||||
사용 가능한 규칙이 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
numberingRules.map((rule) => (
|
||||
<SelectItem key={rule.ruleId} value={rule.ruleId}>
|
||||
{rule.ruleName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
|
||||
먼저 대상 메뉴를 선택하세요
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 자동생성 옵션 (랜덤/순차용) */}
|
||||
{config.autoGeneration?.type &&
|
||||
["random_string", "random_number", "sequence"].includes(config.autoGeneration.type) && (
|
||||
<div className="space-y-2">
|
||||
{/* 길이 설정 */}
|
||||
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">길이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={config.autoGeneration?.options?.length || 8}
|
||||
onChange={(e) => {
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
length: parseInt(e.target.value) || 8,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 접두사 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">접두사</Label>
|
||||
<Input
|
||||
value={config.autoGeneration?.options?.prefix || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
prefix: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="예: INV-"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 접미사 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">접미사</Label>
|
||||
<Input
|
||||
value={config.autoGeneration?.options?.suffix || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
suffix: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">미리보기</Label>
|
||||
<div className="rounded border bg-muted p-2 text-xs font-mono">
|
||||
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user