Merge pull request 'common/feat/dashboard-map' (#291) from common/feat/dashboard-map into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/291
This commit is contained in:
hyeonsu 2025-12-15 18:01:58 +09:00
commit 0ac5402b0b
3 changed files with 156 additions and 109 deletions

View File

@ -40,32 +40,33 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [screenInfo, setScreenInfo] = useState<any>(null); const [screenInfo, setScreenInfo] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작 const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용)
// 컴포넌트 참조 맵 // 컴포넌트 참조 맵
const componentRefs = useRef<Map<string, DataReceivable>>(new Map()); const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
const splitPanelContext = useSplitPanelContext(); const splitPanelContext = useSplitPanelContext();
// 🆕 사용자 정보 가져오기 (저장 액션에 필요) // 🆕 사용자 정보 가져오기 (저장 액션에 필요)
const { userId, userName, companyCode } = useAuth(); const { userId, userName, companyCode } = useAuth();
// 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해) // 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해)
const contentBounds = React.useMemo(() => { const contentBounds = React.useMemo(() => {
if (layout.length === 0) return { width: 0, height: 0 }; if (layout.length === 0) return { width: 0, height: 0 };
let maxRight = 0; let maxRight = 0;
let maxBottom = 0; let maxBottom = 0;
layout.forEach((component) => { layout.forEach((component) => {
const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component; const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component;
const right = (compPosition.x || 0) + (size.width || 200); const right = (compPosition.x || 0) + (size.width || 200);
const bottom = (compPosition.y || 0) + (size.height || 40); const bottom = (compPosition.y || 0) + (size.height || 40);
if (right > maxRight) maxRight = right; if (right > maxRight) maxRight = right;
if (bottom > maxBottom) maxBottom = bottom; if (bottom > maxBottom) maxBottom = bottom;
}); });
return { width: maxRight, height: maxBottom }; return { width: maxRight, height: maxBottom };
}, [layout]); }, [layout]);
@ -92,26 +93,49 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
}, [initialFormData]); }, [initialFormData]);
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영 // 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) // 🆕 좌측 선택 데이터 (분할 패널 컨텍스트에서 직접 참조)
const selectedLeftData = splitPanelContext?.selectedLeftData;
// 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트
useEffect(() => { useEffect(() => {
// 우측 화면인 경우에만 적용 // 우측 화면인 경우에만 적용
if (position !== "right" || !splitPanelContext) return; if (position !== "right" || !splitPanelContext) {
// 자동 데이터 전달이 비활성화된 경우 스킵
if (splitPanelContext.disableAutoDataTransfer) {
console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달");
return; return;
} }
const mappedData = splitPanelContext.getMappedParentData(); // 자동 데이터 전달이 비활성화된 경우 스킵
if (Object.keys(mappedData).length > 0) { if (splitPanelContext.disableAutoDataTransfer) {
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData); return;
setFormData((prev) => ({
...prev,
...mappedData,
}));
} }
}, [position, splitPanelContext, splitPanelContext?.selectedLeftData]);
// 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집
const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string);
// 🆕 모든 필드를 빈 값으로 초기화한 후, selectedLeftData로 덮어쓰기
const initializedFormData: Record<string, any> = {};
// 먼저 모든 컬럼을 빈 문자열로 초기화
allColumnNames.forEach((colName) => {
initializedFormData[colName] = "";
});
// selectedLeftData가 있으면 해당 값으로 덮어쓰기
if (selectedLeftData && Object.keys(selectedLeftData).length > 0) {
Object.keys(selectedLeftData).forEach((key) => {
// null/undefined는 빈 문자열로, 나머지는 그대로
initializedFormData[key] = selectedLeftData[key] ?? "";
});
}
console.log("🔗 [EmbeddedScreen] 우측 폼 데이터 교체:", {
allColumnNames,
selectedLeftDataKeys: selectedLeftData ? Object.keys(selectedLeftData) : [],
initializedFormDataKeys: Object.keys(initializedFormData),
});
setFormData(initializedFormData);
setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링
}, [position, splitPanelContext, selectedLeftData, layout]);
// 선택 변경 이벤트 전파 // 선택 변경 이벤트 전파
useEffect(() => { useEffect(() => {
@ -377,15 +401,15 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
<p className="text-muted-foreground text-sm"> .</p> <p className="text-muted-foreground text-sm"> .</p>
</div> </div>
) : ( ) : (
<div <div
className="relative w-full" className="relative w-full"
style={{ style={{
minHeight: contentBounds.height + 20, // 여유 공간 추가 minHeight: contentBounds.height + 20, // 여유 공간 추가
}} }}
> >
{layout.map((component) => { {layout.map((component) => {
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
// 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정 // 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정
// 부모 컨테이너의 100%를 기준으로 계산 // 부모 컨테이너의 100%를 기준으로 계산
const componentStyle: React.CSSProperties = { const componentStyle: React.CSSProperties = {
@ -397,13 +421,9 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
// 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정 // 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정
maxWidth: `calc(100% - ${compPosition.x || 0}px)`, maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
}; };
return ( return (
<div <div key={`${component.id}-${formDataVersion}`} className="absolute" style={componentStyle}>
key={component.id}
className="absolute"
style={componentStyle}
>
<DynamicComponentRenderer <DynamicComponentRenderer
component={component} component={component}
isInteractive={true} isInteractive={true}

View File

@ -919,21 +919,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 🆕 분할 패널 우측이면 screenContext.formData와 props.formData를 병합 // 🆕 분할 패널 우측이면 여러 소스에서 formData를 병합
// screenContext.formData: RepeaterFieldGroup 등 컴포넌트가 직접 업데이트한 데이터 // 우선순위: props.formData > screenContext.formData > splitPanelParentData
// props.formData: 부모에서 전달된 폼 데이터
const screenContextFormData = screenContext?.formData || {}; const screenContextFormData = screenContext?.formData || {};
const propsFormData = formData || {}; const propsFormData = formData || {};
// 병합: props.formData를 기본으로 하고, screenContext.formData로 오버라이드 // 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드
// (RepeaterFieldGroup 데이터는 screenContext에만 있음) // (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
const effectiveFormData = { ...propsFormData, ...screenContextFormData }; let effectiveFormData = { ...propsFormData, ...screenContextFormData };
// 🆕 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
effectiveFormData = { ...splitPanelParentData };
console.log("🔍 [ButtonPrimary] 분할 패널 우측 - splitPanelParentData 사용:", Object.keys(effectiveFormData));
}
console.log("🔍 [ButtonPrimary] formData 선택:", { console.log("🔍 [ButtonPrimary] formData 선택:", {
hasScreenContextFormData: Object.keys(screenContextFormData).length > 0, hasScreenContextFormData: Object.keys(screenContextFormData).length > 0,
screenContextKeys: Object.keys(screenContextFormData), screenContextKeys: Object.keys(screenContextFormData),
hasPropsFormData: Object.keys(propsFormData).length > 0, hasPropsFormData: Object.keys(propsFormData).length > 0,
propsFormDataKeys: Object.keys(propsFormData), propsFormDataKeys: Object.keys(propsFormData),
hasSplitPanelParentData: !!splitPanelParentData && Object.keys(splitPanelParentData).length > 0,
splitPanelPosition, splitPanelPosition,
effectiveFormDataKeys: Object.keys(effectiveFormData), effectiveFormDataKeys: Object.keys(effectiveFormData),
}); });

View File

@ -53,7 +53,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// 자동생성된 값 상태 // 자동생성된 값 상태
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>(""); const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
// API 호출 중복 방지를 위한 ref // API 호출 중복 방지를 위한 ref
const isGeneratingRef = React.useRef(false); const isGeneratingRef = React.useRef(false);
const hasGeneratedRef = React.useRef(false); const hasGeneratedRef = React.useRef(false);
@ -104,7 +104,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
const currentFormValue = formData?.[component.columnName]; const currentFormValue = formData?.[component.columnName];
const currentComponentValue = component.value; const currentComponentValue = component.value;
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성 // 자동생성된 값이 없고, 현재 값도 없을 때만 생성
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) { if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
isGeneratingRef.current = true; // 생성 시작 플래그 isGeneratingRef.current = true; // 생성 시작 플래그
@ -145,7 +144,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
if (isInteractive && onFormDataChange && component.columnName) { if (isInteractive && onFormDataChange && component.columnName) {
console.log("📝 formData 업데이트:", component.columnName, generatedValue); console.log("📝 formData 업데이트:", component.columnName, generatedValue);
onFormDataChange(component.columnName, generatedValue); onFormDataChange(component.columnName, generatedValue);
// 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함) // 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함)
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) { if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) {
const ruleIdKey = `${component.columnName}_numberingRuleId`; const ruleIdKey = `${component.columnName}_numberingRuleId`;
@ -181,12 +180,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%", width: "100%",
// 숨김 기능: 편집 모드에서만 연하게 표시 // 숨김 기능: 편집 모드에서만 연하게 표시
...(isHidden && ...(isHidden &&
isDesignMode && { isDesignMode && {
opacity: 0.4, opacity: 0.4,
backgroundColor: "hsl(var(--muted))", backgroundColor: "hsl(var(--muted))",
pointerEvents: "auto", pointerEvents: "auto",
}), }),
}; };
// 디자인 모드 스타일 // 디자인 모드 스타일
@ -361,7 +360,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}> <div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground"> <label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label} {component.label}
{component.required && <span className="text-destructive">*</span>} {component.required && <span className="text-destructive">*</span>}
</label> </label>
@ -386,15 +385,17 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}} }}
className={cn( className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none", "h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground", componentConfig.disabled
"disabled:cursor-not-allowed" ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)} )}
/> />
{/* @ 구분자 */} {/* @ 구분자 */}
<span className="text-base font-medium text-muted-foreground">@</span> <span className="text-muted-foreground text-base font-medium">@</span>
{/* 도메인 선택/입력 (Combobox) */} {/* 도메인 선택/입력 (Combobox) */}
<Popover open={emailDomainOpen} onOpenChange={setEmailDomainOpen}> <Popover open={emailDomainOpen} onOpenChange={setEmailDomainOpen}>
@ -406,14 +407,18 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
disabled={componentConfig.disabled || false} disabled={componentConfig.disabled || false}
className={cn( className={cn(
"flex h-full flex-1 items-center justify-between rounded-md border px-3 py-2 text-sm transition-all duration-200", "flex h-full flex-1 items-center justify-between rounded-md border px-3 py-2 text-sm transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "cursor-not-allowed bg-muted text-muted-foreground opacity-50" : "bg-background text-foreground", componentConfig.disabled
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"hover:border-ring/80", "hover:border-ring/80",
emailDomainOpen && "border-ring ring-2 ring-ring/50", emailDomainOpen && "border-ring ring-ring/50 ring-2",
)} )}
> >
<span className={cn("truncate", !emailDomain && "text-muted-foreground")}>{emailDomain || "도메인 선택"}</span> <span className={cn("truncate", !emailDomain && "text-muted-foreground")}>
{emailDomain || "도메인 선택"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
@ -470,7 +475,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}> <div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground"> <label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label} {component.label}
{component.required && <span className="text-destructive">*</span>} {component.required && <span className="text-destructive">*</span>}
</label> </label>
@ -496,14 +501,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}} }}
className={cn( className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none", "h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground", componentConfig.disabled
"disabled:cursor-not-allowed" ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)} )}
/> />
<span className="text-base font-medium text-muted-foreground">-</span> <span className="text-muted-foreground text-base font-medium">-</span>
{/* 두 번째 부분 */} {/* 두 번째 부분 */}
<input <input
@ -524,14 +531,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}} }}
className={cn( className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none", "h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground", componentConfig.disabled
"disabled:cursor-not-allowed" ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)} )}
/> />
<span className="text-base font-medium text-muted-foreground">-</span> <span className="text-muted-foreground text-base font-medium">-</span>
{/* 세 번째 부분 */} {/* 세 번째 부분 */}
<input <input
@ -552,10 +561,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}} }}
className={cn( className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none", "h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground", componentConfig.disabled
"disabled:cursor-not-allowed" ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)} )}
/> />
</div> </div>
@ -569,7 +580,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}> <div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground"> <label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label} {component.label}
{component.required && <span className="text-destructive">*</span>} {component.required && <span className="text-destructive">*</span>}
</label> </label>
@ -591,10 +602,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}} }}
className={cn( className={cn(
"h-full w-[100px] cursor-pointer rounded-md border px-2 py-2 text-sm transition-all duration-200 outline-none", "h-full w-[100px] cursor-pointer rounded-md border px-2 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground", componentConfig.disabled
"disabled:cursor-not-allowed" ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)} )}
> >
<option value="https://">https://</option> <option value="https://">https://</option>
@ -619,10 +632,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}} }}
className={cn( className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none", "h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground", componentConfig.disabled
"disabled:cursor-not-allowed" ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)} )}
/> />
</div> </div>
@ -636,7 +651,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}> <div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground"> <label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label} {component.label}
{component.required && <span className="text-destructive">*</span>} {component.required && <span className="text-destructive">*</span>}
</label> </label>
@ -669,11 +684,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}} }}
className={cn( className={cn(
"box-border h-full w-full max-w-full resize-none rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none", "box-border h-full w-full max-w-full resize-none rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground", componentConfig.disabled
"disabled:cursor-not-allowed" ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)} )}
/> />
</div> </div>
@ -692,13 +709,15 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
{/* 수동/자동 모드 표시 배지 */} {/* 수동/자동 모드 표시 배지 */}
{testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && ( {testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1"> <div className="absolute top-1/2 right-2 flex -translate-y-1/2 items-center gap-1">
<span className={cn( <span
"text-[10px] px-2 py-0.5 rounded-full font-medium", className={cn(
isManualMode "rounded-full px-2 py-0.5 text-[10px] font-medium",
? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" isManualMode
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
)}> : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
)}
>
{isManualMode ? "수동" : "자동"} {isManualMode ? "수동" : "자동"}
</span> </span>
</div> </div>
@ -706,12 +725,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<input <input
type={inputType} type={inputType}
defaultValue={(() => { value={(() => {
let displayValue = ""; let displayValue = "";
if (isInteractive && formData && component.columnName) { if (isInteractive && formData && component.columnName) {
// 인터랙티브 모드: formData 우선, 없으면 자동생성 값 // 인터랙티브 모드: formData 우선, 없으면 자동생성 값
const rawValue = formData[component.columnName] || autoGeneratedValue || ""; const rawValue = formData[component.columnName] ?? autoGeneratedValue ?? "";
// 객체인 경우 빈 문자열로 변환 (에러 방지) // 객체인 경우 빈 문자열로 변환 (에러 방지)
displayValue = typeof rawValue === "object" ? "" : String(rawValue); displayValue = typeof rawValue === "object" ? "" : String(rawValue);
} else { } else {
@ -724,31 +743,33 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
})()} })()}
placeholder={ placeholder={
testAutoGeneration.enabled && testAutoGeneration.type !== "none" testAutoGeneration.enabled && testAutoGeneration.type !== "none"
? isManualMode ? isManualMode
? "수동 입력 모드" ? "수동 입력 모드"
: `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}` : `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
: componentConfig.placeholder || defaultPlaceholder : componentConfig.placeholder || defaultPlaceholder
} }
pattern={validationPattern} pattern={validationPattern}
title={ title={
webType === "tel" webType === "tel"
? "전화번호 형식: 010-1234-5678" ? "전화번호 형식: 010-1234-5678"
: isManualMode : isManualMode
? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)` ? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)`
: component.label : component.label
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}` ? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
: component.columnName || undefined : component.columnName || undefined
} }
disabled={componentConfig.disabled || false} disabled={componentConfig.disabled || false}
required={componentConfig.required || false} required={componentConfig.required || false}
readOnly={componentConfig.readonly || false} readOnly={componentConfig.readonly || false}
className={cn( className={cn(
"box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none", "box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground", componentConfig.disabled
"disabled:cursor-not-allowed" ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)} )}
onClick={(e) => { onClick={(e) => {
handleClick(e); handleClick(e);
@ -774,9 +795,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
console.log("🔄 수동 모드로 전환:", { console.log("🔄 수동 모드로 전환:", {
field: component.columnName, field: component.columnName,
original: originalAutoGeneratedValue, original: originalAutoGeneratedValue,
modified: newValue modified: newValue,
}); });
// 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함) // 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
if (isInteractive && onFormDataChange && component.columnName) { if (isInteractive && onFormDataChange && component.columnName) {
const ruleIdKey = `${component.columnName}_numberingRuleId`; const ruleIdKey = `${component.columnName}_numberingRuleId`;
@ -789,9 +810,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
setIsManualMode(false); setIsManualMode(false);
console.log("🔄 자동 모드로 복구:", { console.log("🔄 자동 모드로 복구:", {
field: component.columnName, field: component.columnName,
value: newValue value: newValue,
}); });
// 채번 규칙 ID 복구 // 채번 규칙 ID 복구
if (isInteractive && onFormDataChange && component.columnName) { if (isInteractive && onFormDataChange && component.columnName) {
const ruleId = testAutoGeneration.options?.numberingRuleId; const ruleId = testAutoGeneration.options?.numberingRuleId;