Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
e2d88f01e3
|
|
@ -5083,8 +5083,8 @@ export class ScreenManagementService {
|
||||||
let layout: { layout_data: any } | null = null;
|
let layout: { layout_data: any } | null = null;
|
||||||
|
|
||||||
// 🆕 기본 레이어(layer_id=1)를 우선 로드
|
// 🆕 기본 레이어(layer_id=1)를 우선 로드
|
||||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
// SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin || companyCode === "*") {
|
||||||
// 1. 화면 정의의 회사 코드 + 기본 레이어
|
// 1. 화면 정의의 회사 코드 + 기본 레이어
|
||||||
layout = await queryOne<{ layout_data: any }>(
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,25 @@ function ScreenViewPage() {
|
||||||
} else {
|
} else {
|
||||||
// V1 레이아웃 또는 빈 레이아웃
|
// V1 레이아웃 또는 빈 레이아웃
|
||||||
const layoutData = await screenApi.getLayout(screenId);
|
const layoutData = await screenApi.getLayout(screenId);
|
||||||
setLayout(layoutData);
|
if (layoutData?.components?.length > 0) {
|
||||||
|
setLayout(layoutData);
|
||||||
|
} else {
|
||||||
|
console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId);
|
||||||
|
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
|
||||||
|
if (baseLayerData && isValidV2Layout(baseLayerData)) {
|
||||||
|
const converted = convertV2ToLegacy(baseLayerData);
|
||||||
|
if (converted) {
|
||||||
|
setLayout({
|
||||||
|
...converted,
|
||||||
|
screenResolution: baseLayerData.screenResolution || converted.screenResolution,
|
||||||
|
} as LayoutData);
|
||||||
|
} else {
|
||||||
|
setLayout(layoutData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLayout(layoutData);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (layoutError) {
|
} catch (layoutError) {
|
||||||
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
||||||
|
|
|
||||||
|
|
@ -413,9 +413,28 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
|
|
||||||
// V2 없으면 기존 API fallback
|
// V2 없으면 기존 API fallback
|
||||||
if (!layoutData) {
|
if (!layoutData) {
|
||||||
|
console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId);
|
||||||
layoutData = await screenApi.getLayout(screenId);
|
layoutData = await screenApi.getLayout(screenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드
|
||||||
|
if (!layoutData || !layoutData.components || layoutData.components.length === 0) {
|
||||||
|
console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId);
|
||||||
|
try {
|
||||||
|
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
|
||||||
|
if (baseLayerData && isValidV2Layout(baseLayerData)) {
|
||||||
|
layoutData = convertV2ToLegacy(baseLayerData);
|
||||||
|
if (layoutData) {
|
||||||
|
layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution;
|
||||||
|
}
|
||||||
|
} else if (baseLayerData?.components) {
|
||||||
|
layoutData = baseLayerData;
|
||||||
|
}
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (screenInfo && layoutData) {
|
if (screenInfo && layoutData) {
|
||||||
const components = layoutData.components || [];
|
const components = layoutData.components || [];
|
||||||
|
|
||||||
|
|
@ -1440,7 +1459,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
<div className="flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -1455,7 +1474,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-screen-runtime="true"
|
data-screen-runtime="true"
|
||||||
className="relative bg-white"
|
className="relative m-auto bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: screenDimensions?.width || 800,
|
||||||
// 조건부 레이어가 활성화되면 높이 자동 확장
|
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||||
|
|
|
||||||
|
|
@ -2191,10 +2191,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 라벨 표시 여부 계산
|
// 라벨 표시 여부 계산
|
||||||
const shouldShowLabel =
|
const shouldShowLabel =
|
||||||
!hideLabel && // hideLabel이 true면 라벨 숨김
|
!hideLabel &&
|
||||||
(component.style?.labelDisplay ?? true) &&
|
(component.style?.labelDisplay ?? true) !== false &&
|
||||||
|
component.style?.labelDisplay !== "false" &&
|
||||||
(component.label || component.style?.labelText) &&
|
(component.label || component.style?.labelText) &&
|
||||||
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
|
!templateTypes.includes(component.type);
|
||||||
|
|
||||||
const labelText = component.style?.labelText || component.label || "";
|
const labelText = component.style?.labelText || component.label || "";
|
||||||
|
|
||||||
|
|
@ -2232,8 +2233,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
...component,
|
...component,
|
||||||
style: {
|
style: {
|
||||||
...component.style,
|
...component.style,
|
||||||
labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김
|
labelDisplay: false,
|
||||||
|
labelPosition: "top" as const,
|
||||||
|
...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}),
|
||||||
},
|
},
|
||||||
|
...(isHorizontalLabel ? {
|
||||||
|
size: {
|
||||||
|
...component.size,
|
||||||
|
width: undefined as unknown as number,
|
||||||
|
height: undefined as unknown as number,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
}
|
}
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1109,7 +1109,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
|
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
|
||||||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
|
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
|
||||||
const hasVisibleLabel = isV2InputComponent &&
|
const hasVisibleLabel = isV2InputComponent &&
|
||||||
style?.labelDisplay !== false &&
|
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
|
||||||
(style?.labelText || (component as any).label);
|
(style?.labelText || (component as any).label);
|
||||||
|
|
||||||
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
|
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
|
||||||
|
|
@ -1119,6 +1119,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
|
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||||
|
|
||||||
|
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
|
||||||
|
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
|
||||||
|
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||||
|
const labelText = style?.labelText || (component as any).label || "";
|
||||||
|
const labelGapValue = style?.labelGap || "8px";
|
||||||
|
|
||||||
const calculateCanvasSplitX = (): { x: number; w: number } => {
|
const calculateCanvasSplitX = (): { x: number; w: number } => {
|
||||||
const compType = (component as any).componentType || "";
|
const compType = (component as any).componentType || "";
|
||||||
const isSplitLine = type === "component" && compType === "v2-split-line";
|
const isSplitLine = type === "component" && compType === "v2-split-line";
|
||||||
|
|
@ -1194,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
|
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
|
||||||
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
|
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
|
||||||
|
|
||||||
|
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
|
||||||
|
const cleanedStyle = (isHorizLabel && needsExternalLabel)
|
||||||
|
? (() => {
|
||||||
|
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
|
||||||
|
return rest;
|
||||||
|
})()
|
||||||
|
: safeStyleWithoutSize;
|
||||||
|
|
||||||
const componentStyle = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
...safeStyleWithoutSize,
|
...cleanedStyle,
|
||||||
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
|
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
|
||||||
left: adjustedX,
|
left: adjustedX,
|
||||||
top: position?.y || 0,
|
top: position?.y || 0,
|
||||||
|
|
@ -1267,11 +1281,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [component.id, position?.x, size?.width, type]);
|
}, [component.id, position?.x, size?.width, type]);
|
||||||
|
|
||||||
// 라벨 위치가 top이 아닌 경우: 외부에서 라벨을 렌더링하고 내부 라벨은 숨김
|
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
|
||||||
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
|
|
||||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
|
||||||
const labelText = style?.labelText || (component as any).label || "";
|
|
||||||
const labelGapValue = style?.labelGap || "8px";
|
|
||||||
|
|
||||||
const externalLabelComponent = needsExternalLabel ? (
|
const externalLabelComponent = needsExternalLabel ? (
|
||||||
<label
|
<label
|
||||||
|
|
@ -1292,28 +1302,77 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const componentToRender = needsExternalLabel
|
const componentToRender = needsExternalLabel
|
||||||
? { ...splitAdjustedComponent, style: { ...splitAdjustedComponent.style, labelDisplay: false } }
|
? {
|
||||||
|
...splitAdjustedComponent,
|
||||||
|
style: {
|
||||||
|
...splitAdjustedComponent.style,
|
||||||
|
labelDisplay: false,
|
||||||
|
labelPosition: "top" as const,
|
||||||
|
...(isHorizLabel ? {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderWidth: undefined,
|
||||||
|
borderColor: undefined,
|
||||||
|
borderStyle: undefined,
|
||||||
|
border: undefined,
|
||||||
|
borderRadius: undefined,
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
...(isHorizLabel ? {
|
||||||
|
size: {
|
||||||
|
...splitAdjustedComponent.size,
|
||||||
|
width: undefined as unknown as number,
|
||||||
|
height: undefined as unknown as number,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
}
|
||||||
: splitAdjustedComponent;
|
: splitAdjustedComponent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
||||||
{needsExternalLabel ? (
|
{needsExternalLabel ? (
|
||||||
<div
|
isHorizLabel ? (
|
||||||
style={{
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||||
display: "flex",
|
<label
|
||||||
flexDirection: isHorizLabel ? (labelPos === "left" ? "row" : "row-reverse") : "column-reverse",
|
className="text-sm font-medium leading-none"
|
||||||
alignItems: isHorizLabel ? "center" : undefined,
|
style={{
|
||||||
gap: isHorizLabel ? labelGapValue : undefined,
|
position: "absolute",
|
||||||
width: "100%",
|
top: "50%",
|
||||||
height: "100%",
|
transform: "translateY(-50%)",
|
||||||
}}
|
...(labelPos === "left"
|
||||||
>
|
? { right: "100%", marginRight: labelGapValue }
|
||||||
{externalLabelComponent}
|
: { left: "100%", marginLeft: labelGapValue }),
|
||||||
<div style={{ flex: 1, minWidth: 0, height: isHorizLabel ? "100%" : undefined }}>
|
fontSize: style?.labelFontSize || "14px",
|
||||||
{renderInteractiveWidget(componentToRender)}
|
color: style?.labelColor || "#212121",
|
||||||
|
fontWeight: style?.labelFontWeight || "500",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labelText}
|
||||||
|
{((component as any).required || (component as any).componentConfig?.required) && (
|
||||||
|
<span className="ml-1 text-destructive">*</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div style={{ width: "100%", height: "100%" }}>
|
||||||
|
{renderInteractiveWidget(componentToRender)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column-reverse",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{externalLabelComponent}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{renderInteractiveWidget(componentToRender)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
renderInteractiveWidget(componentToRender)
|
renderInteractiveWidget(componentToRender)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
const origWidth = size?.width || 100;
|
const origWidth = size?.width || 100;
|
||||||
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
|
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
|
||||||
|
|
||||||
|
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리)
|
||||||
|
const isV2HorizLabel = !!(
|
||||||
|
componentStyle &&
|
||||||
|
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
|
||||||
|
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
|
||||||
|
);
|
||||||
|
const safeComponentStyle = isV2HorizLabel
|
||||||
|
? (() => {
|
||||||
|
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
|
||||||
|
return rest;
|
||||||
|
})()
|
||||||
|
: componentStyle;
|
||||||
|
|
||||||
const baseStyle = {
|
const baseStyle = {
|
||||||
left: `${adjustedPositionX}px`,
|
left: `${adjustedPositionX}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
...componentStyle,
|
...safeComponentStyle,
|
||||||
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
|
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
|
||||||
height: displayHeight,
|
height: displayHeight,
|
||||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||||
|
|
|
||||||
|
|
@ -700,7 +700,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showLabel = label && style?.labelDisplay !== false;
|
const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
|
||||||
const componentWidth = size?.width || style?.width;
|
const componentWidth = size?.width || style?.width;
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -962,7 +962,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
const actualLabel = label || style?.labelText;
|
const actualLabel = label || style?.labelText;
|
||||||
const showLabel = actualLabel && style?.labelDisplay === true;
|
const showLabel = actualLabel && style?.labelDisplay !== false && style?.labelDisplay !== "false";
|
||||||
const componentWidth = size?.width || style?.width;
|
const componentWidth = size?.width || style?.width;
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1145,7 +1145,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showLabel = label && style?.labelDisplay !== false;
|
const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
|
||||||
const componentWidth = size?.width || style?.width;
|
const componentWidth = size?.width || style?.width;
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -371,15 +371,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
try {
|
try {
|
||||||
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
|
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
|
||||||
const fieldName = columnName || component.id;
|
const fieldName = columnName || component.id;
|
||||||
const currentValue = props.formData?.[fieldName] || "";
|
|
||||||
|
|
||||||
const handleChange = (value: any) => {
|
// 수평 라벨 감지
|
||||||
if (props.onFormDataChange) {
|
const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
||||||
props.onFormDataChange(fieldName, value);
|
const catLabelPosition = component.style?.labelPosition;
|
||||||
}
|
const catLabelText = (catLabelDisplay === true || catLabelDisplay === "true")
|
||||||
};
|
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
|
||||||
|
: undefined;
|
||||||
|
const catNeedsExternalHorizLabel = !!(
|
||||||
|
catLabelText &&
|
||||||
|
(catLabelPosition === "left" || catLabelPosition === "right")
|
||||||
|
);
|
||||||
|
|
||||||
// V2SelectRenderer용 컴포넌트 데이터 구성
|
|
||||||
const selectComponent = {
|
const selectComponent = {
|
||||||
...component,
|
...component,
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
|
|
@ -395,6 +398,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
webType: "category",
|
webType: "category",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const catStyle = catNeedsExternalHorizLabel
|
||||||
|
? {
|
||||||
|
...(component as any).style,
|
||||||
|
labelDisplay: false,
|
||||||
|
labelPosition: "top" as const,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderWidth: undefined,
|
||||||
|
borderColor: undefined,
|
||||||
|
borderStyle: undefined,
|
||||||
|
border: undefined,
|
||||||
|
borderRadius: undefined,
|
||||||
|
}
|
||||||
|
: (component as any).style;
|
||||||
|
const catSize = catNeedsExternalHorizLabel
|
||||||
|
? { ...(component as any).size, width: undefined, height: undefined }
|
||||||
|
: (component as any).size;
|
||||||
|
|
||||||
const rendererProps = {
|
const rendererProps = {
|
||||||
component: selectComponent,
|
component: selectComponent,
|
||||||
formData: props.formData,
|
formData: props.formData,
|
||||||
|
|
@ -402,12 +423,47 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
isDesignMode: props.isDesignMode,
|
isDesignMode: props.isDesignMode,
|
||||||
isInteractive: props.isInteractive ?? !props.isDesignMode,
|
isInteractive: props.isInteractive ?? !props.isDesignMode,
|
||||||
tableName,
|
tableName,
|
||||||
style: (component as any).style,
|
style: catStyle,
|
||||||
size: (component as any).size,
|
size: catSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
const rendererInstance = new V2SelectRenderer(rendererProps);
|
const rendererInstance = new V2SelectRenderer(rendererProps);
|
||||||
return rendererInstance.render();
|
const renderedCatSelect = rendererInstance.render();
|
||||||
|
|
||||||
|
if (catNeedsExternalHorizLabel) {
|
||||||
|
const labelGap = component.style?.labelGap || "8px";
|
||||||
|
const labelFontSize = component.style?.labelFontSize || "14px";
|
||||||
|
const labelColor = component.style?.labelColor || "#64748b";
|
||||||
|
const labelFontWeight = component.style?.labelFontWeight || "500";
|
||||||
|
const isRequired = component.required || (component as any).required;
|
||||||
|
const isLeft = catLabelPosition === "left";
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
...(isLeft
|
||||||
|
? { right: "100%", marginRight: labelGap }
|
||||||
|
: { left: "100%", marginLeft: labelGap }),
|
||||||
|
fontSize: labelFontSize,
|
||||||
|
color: labelColor,
|
||||||
|
fontWeight: labelFontWeight,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{catLabelText}
|
||||||
|
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
<div style={{ width: "100%", height: "100%" }}>
|
||||||
|
{renderedCatSelect}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderedCatSelect;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ V2SelectRenderer 로드 실패:", error);
|
console.error("❌ V2SelectRenderer 로드 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -619,18 +675,39 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
componentType === "modal-repeater-table" ||
|
componentType === "modal-repeater-table" ||
|
||||||
componentType === "v2-input";
|
componentType === "v2-input";
|
||||||
|
|
||||||
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
|
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
|
||||||
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
||||||
const effectiveLabel = labelDisplay === true
|
const effectiveLabel = (labelDisplay === true || labelDisplay === "true")
|
||||||
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
|
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리
|
||||||
|
const labelPosition = component.style?.labelPosition;
|
||||||
|
const isV2Component = componentType?.startsWith("v2-");
|
||||||
|
const needsExternalHorizLabel = !!(
|
||||||
|
isV2Component &&
|
||||||
|
effectiveLabel &&
|
||||||
|
(labelPosition === "left" || labelPosition === "right")
|
||||||
|
);
|
||||||
|
|
||||||
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
|
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
|
||||||
const mergedStyle = {
|
const mergedStyle = {
|
||||||
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
|
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
|
||||||
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
|
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
|
||||||
width: finalStyle.width,
|
width: finalStyle.width,
|
||||||
height: finalStyle.height,
|
height: finalStyle.height,
|
||||||
|
// 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리)
|
||||||
|
...(needsExternalHorizLabel ? {
|
||||||
|
labelDisplay: false,
|
||||||
|
labelPosition: "top" as const,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderWidth: undefined,
|
||||||
|
borderColor: undefined,
|
||||||
|
borderStyle: undefined,
|
||||||
|
border: undefined,
|
||||||
|
borderRadius: undefined,
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
|
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
|
||||||
|
|
@ -649,7 +726,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
size: component.size || newComponent.defaultSize,
|
size: needsExternalHorizLabel
|
||||||
|
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
|
||||||
|
: (component.size || newComponent.defaultSize),
|
||||||
position: component.position,
|
position: component.position,
|
||||||
config: mergedComponentConfig,
|
config: mergedComponentConfig,
|
||||||
componentConfig: mergedComponentConfig,
|
componentConfig: mergedComponentConfig,
|
||||||
|
|
@ -657,8 +736,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
...(mergedComponentConfig || {}),
|
...(mergedComponentConfig || {}),
|
||||||
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
||||||
style: mergedStyle,
|
style: mergedStyle,
|
||||||
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
|
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
|
||||||
label: effectiveLabel,
|
label: needsExternalHorizLabel ? undefined : effectiveLabel,
|
||||||
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
|
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
|
||||||
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
|
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
|
||||||
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
||||||
|
|
@ -759,16 +838,51 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
NewComponentRenderer.prototype &&
|
NewComponentRenderer.prototype &&
|
||||||
NewComponentRenderer.prototype.render;
|
NewComponentRenderer.prototype.render;
|
||||||
|
|
||||||
|
let renderedElement: React.ReactElement;
|
||||||
if (isClass) {
|
if (isClass) {
|
||||||
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
|
|
||||||
const rendererInstance = new NewComponentRenderer(rendererProps);
|
const rendererInstance = new NewComponentRenderer(rendererProps);
|
||||||
return rendererInstance.render();
|
renderedElement = rendererInstance.render();
|
||||||
} else {
|
} else {
|
||||||
// 함수형 컴포넌트
|
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
||||||
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
|
|
||||||
|
|
||||||
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움
|
||||||
|
if (needsExternalHorizLabel) {
|
||||||
|
const labelGap = component.style?.labelGap || "8px";
|
||||||
|
const labelFontSize = component.style?.labelFontSize || "14px";
|
||||||
|
const labelColor = component.style?.labelColor || "#64748b";
|
||||||
|
const labelFontWeight = component.style?.labelFontWeight || "500";
|
||||||
|
const isRequired = component.required || (component as any).required;
|
||||||
|
const isLeft = labelPosition === "left";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
...(isLeft
|
||||||
|
? { right: "100%", marginRight: labelGap }
|
||||||
|
: { left: "100%", marginLeft: labelGap }),
|
||||||
|
fontSize: labelFontSize,
|
||||||
|
color: labelColor,
|
||||||
|
fontWeight: labelFontWeight,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{effectiveLabel}
|
||||||
|
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
<div style={{ width: "100%", height: "100%" }}>
|
||||||
|
{renderedElement}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderedElement;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,74 @@ import { TableListConfig, ColumnConfig } from "./types";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react";
|
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, X } from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴)
|
||||||
|
*/
|
||||||
|
function SortableColumnRow({
|
||||||
|
id,
|
||||||
|
col,
|
||||||
|
index,
|
||||||
|
isEntityJoin,
|
||||||
|
onLabelChange,
|
||||||
|
onWidthChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
col: ColumnConfig;
|
||||||
|
index: number;
|
||||||
|
isEntityJoin?: boolean;
|
||||||
|
onLabelChange: (value: string) => void;
|
||||||
|
onWidthChange: (value: number) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||||
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
|
||||||
|
isDragging && "z-50 opacity-50 shadow-md",
|
||||||
|
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||||
|
<GripVertical className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
{isEntityJoin ? (
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={col.displayName || col.columnName}
|
||||||
|
onChange={(e) => onLabelChange(e.target.value)}
|
||||||
|
placeholder="표시명"
|
||||||
|
className="h-6 min-w-0 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={col.width || ""}
|
||||||
|
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||||
|
placeholder="너비"
|
||||||
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface TableListConfigPanelProps {
|
export interface TableListConfigPanelProps {
|
||||||
config: TableListConfig;
|
config: TableListConfig;
|
||||||
|
|
@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
|
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
|
||||||
if (existingColumn) return;
|
if (existingColumn) return;
|
||||||
|
|
||||||
// tableColumns에서 해당 컬럼의 라벨 정보 찾기
|
// tableColumns → availableColumns 순서로 한국어 라벨 찾기
|
||||||
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
|
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
|
||||||
|
const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName);
|
||||||
|
|
||||||
// 라벨명 우선 사용, 없으면 컬럼명 사용
|
const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName;
|
||||||
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
|
|
||||||
|
|
||||||
const newColumn: ColumnConfig = {
|
const newColumn: ColumnConfig = {
|
||||||
columnName,
|
columnName,
|
||||||
|
|
@ -1213,6 +1276,62 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 컬럼 순서 변경 (DnD) */}
|
||||||
|
{config.columns && config.columns.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">표시할 컬럼 ({config.columns.length}개 선택)</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const columns = [...(config.columns || [])];
|
||||||
|
const oldIndex = columns.findIndex((c) => c.columnName === active.id);
|
||||||
|
const newIndex = columns.findIndex((c) => c.columnName === over.id);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
const reordered = arrayMove(columns, oldIndex, newIndex);
|
||||||
|
reordered.forEach((col, idx) => { col.order = idx; });
|
||||||
|
handleChange("columns", reordered);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={(config.columns || []).map((c) => c.columnName)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(config.columns || []).map((column, idx) => {
|
||||||
|
const resolvedLabel =
|
||||||
|
column.displayName && column.displayName !== column.columnName
|
||||||
|
? column.displayName
|
||||||
|
: availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName;
|
||||||
|
|
||||||
|
const colWithLabel = { ...column, displayName: resolvedLabel };
|
||||||
|
return (
|
||||||
|
<SortableColumnRow
|
||||||
|
key={column.columnName}
|
||||||
|
id={column.columnName}
|
||||||
|
col={colWithLabel}
|
||||||
|
index={idx}
|
||||||
|
isEntityJoin={!!column.isEntityJoin}
|
||||||
|
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
|
||||||
|
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
|
||||||
|
onRemove={() => removeColumn(column.columnName)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 🆕 데이터 필터링 설정 */}
|
{/* 🆕 데이터 필터링 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1240,3 +1359,4 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -937,19 +937,38 @@ export function BomItemEditorComponent({
|
||||||
setItemSearchOpen(true);
|
setItemSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 이미 추가된 품목 ID 목록 (중복 방지용)
|
// 같은 레벨(형제) 품목 ID 목록 (동일 레벨 중복 방지, 하위 레벨은 허용)
|
||||||
const existingItemIds = useMemo(() => {
|
const existingItemIds = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
const collect = (nodes: BomItemNode[]) => {
|
const fkField = cfg.dataSource?.foreignKey || "child_item_id";
|
||||||
for (const n of nodes) {
|
|
||||||
const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"];
|
if (addTargetParentId === null) {
|
||||||
|
// 루트 레벨 추가: 루트 노드의 형제들만 체크
|
||||||
|
for (const n of treeData) {
|
||||||
|
const fk = n.data[fkField];
|
||||||
if (fk) ids.add(fk);
|
if (fk) ids.add(fk);
|
||||||
collect(n.children);
|
|
||||||
}
|
}
|
||||||
};
|
} else {
|
||||||
collect(treeData);
|
// 하위 추가: 해당 부모의 직속 자식들만 체크
|
||||||
|
const findParent = (nodes: BomItemNode[]): BomItemNode | null => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.tempId === addTargetParentId) return n;
|
||||||
|
const found = findParent(n.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const parent = findParent(treeData);
|
||||||
|
if (parent) {
|
||||||
|
for (const child of parent.children) {
|
||||||
|
const fk = child.data[fkField];
|
||||||
|
if (fk) ids.add(fk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ids;
|
return ids;
|
||||||
}, [treeData, cfg]);
|
}, [treeData, cfg, addTargetParentId]);
|
||||||
|
|
||||||
// 루트 품목 추가 시작
|
// 루트 품목 추가 시작
|
||||||
const handleAddRoot = useCallback(() => {
|
const handleAddRoot = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,76 @@ import {
|
||||||
Database,
|
Database,
|
||||||
Table2,
|
Table2,
|
||||||
Link2,
|
Link2,
|
||||||
|
GripVertical,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴)
|
||||||
|
*/
|
||||||
|
function SortableColumnRow({
|
||||||
|
id,
|
||||||
|
col,
|
||||||
|
index,
|
||||||
|
isEntityJoin,
|
||||||
|
onLabelChange,
|
||||||
|
onWidthChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
col: ColumnConfig;
|
||||||
|
index: number;
|
||||||
|
isEntityJoin?: boolean;
|
||||||
|
onLabelChange: (value: string) => void;
|
||||||
|
onWidthChange: (value: number) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||||
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
|
||||||
|
isDragging && "z-50 opacity-50 shadow-md",
|
||||||
|
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||||
|
<GripVertical className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
{isEntityJoin ? (
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={col.displayName || col.columnName}
|
||||||
|
onChange={(e) => onLabelChange(e.target.value)}
|
||||||
|
placeholder="표시명"
|
||||||
|
className="h-6 min-w-0 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={col.width || ""}
|
||||||
|
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||||
|
placeholder="너비"
|
||||||
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface TableListConfigPanelProps {
|
export interface TableListConfigPanelProps {
|
||||||
config: TableListConfig;
|
config: TableListConfig;
|
||||||
|
|
@ -366,11 +431,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
|
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
|
||||||
if (existingColumn) return;
|
if (existingColumn) return;
|
||||||
|
|
||||||
// tableColumns에서 해당 컬럼의 라벨 정보 찾기
|
// tableColumns → availableColumns 순서로 한국어 라벨 찾기
|
||||||
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
|
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
|
||||||
|
const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName);
|
||||||
|
|
||||||
// 라벨명 우선 사용, 없으면 컬럼명 사용
|
const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName;
|
||||||
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
|
|
||||||
|
|
||||||
const newColumn: ColumnConfig = {
|
const newColumn: ColumnConfig = {
|
||||||
columnName,
|
columnName,
|
||||||
|
|
@ -1458,6 +1523,63 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 컬럼 순서 변경 (DnD) */}
|
||||||
|
{config.columns && config.columns.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">표시할 컬럼 ({config.columns.length}개 선택)</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const columns = [...(config.columns || [])];
|
||||||
|
const oldIndex = columns.findIndex((c) => c.columnName === active.id);
|
||||||
|
const newIndex = columns.findIndex((c) => c.columnName === over.id);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
const reordered = arrayMove(columns, oldIndex, newIndex);
|
||||||
|
reordered.forEach((col, idx) => { col.order = idx; });
|
||||||
|
handleChange("columns", reordered);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={(config.columns || []).map((c) => c.columnName)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(config.columns || []).map((column, idx) => {
|
||||||
|
// displayName이 columnName과 같으면 한국어 라벨 미설정 → availableColumns에서 찾기
|
||||||
|
const resolvedLabel =
|
||||||
|
column.displayName && column.displayName !== column.columnName
|
||||||
|
? column.displayName
|
||||||
|
: availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName;
|
||||||
|
|
||||||
|
const colWithLabel = { ...column, displayName: resolvedLabel };
|
||||||
|
return (
|
||||||
|
<SortableColumnRow
|
||||||
|
key={column.columnName}
|
||||||
|
id={column.columnName}
|
||||||
|
col={colWithLabel}
|
||||||
|
index={idx}
|
||||||
|
isEntityJoin={!!column.isEntityJoin}
|
||||||
|
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
|
||||||
|
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
|
||||||
|
onRemove={() => removeColumn(column.columnName)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 🆕 데이터 필터링 설정 */}
|
{/* 🆕 데이터 필터링 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1484,3 +1606,4 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3173,16 +3173,16 @@ export class ButtonActionExecutor {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 화면 설명 가져오기
|
// 1. 화면 정보 가져오기 (제목/설명이 미설정 시 화면명에서 가져옴)
|
||||||
let description = config.modalDescription || "";
|
let screenInfo: any = null;
|
||||||
if (!description) {
|
if (!config.modalTitle || !config.modalDescription) {
|
||||||
try {
|
try {
|
||||||
const screenInfo = await screenApi.getScreen(config.targetScreenId);
|
screenInfo = await screenApi.getScreen(config.targetScreenId);
|
||||||
description = screenInfo?.description || "";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("화면 설명을 가져오지 못했습니다:", error);
|
console.warn("화면 정보를 가져오지 못했습니다:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let description = config.modalDescription || screenInfo?.description || "";
|
||||||
|
|
||||||
// 2. 데이터 소스 및 선택된 데이터 수집
|
// 2. 데이터 소스 및 선택된 데이터 수집
|
||||||
let selectedData: any[] = [];
|
let selectedData: any[] = [];
|
||||||
|
|
@ -3288,7 +3288,7 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 동적 모달 제목 생성
|
// 3. 동적 모달 제목 생성
|
||||||
let finalTitle = config.modalTitle || "화면";
|
let finalTitle = config.modalTitle || screenInfo?.screenName || "데이터 등록";
|
||||||
|
|
||||||
// 블록 기반 제목 처리
|
// 블록 기반 제목 처리
|
||||||
if (config.modalTitleBlocks?.length) {
|
if (config.modalTitleBlocks?.length) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue