feat: Enhance BOM and UI components with improved label handling and data mapping

- Updated the BOM service to include additional fields in the BOM header retrieval, enhancing data richness.
- Enhanced the EditModal to automatically map foreign key fields to dot notation, improving data handling and user experience.
- Improved the rendering of labels in various components, allowing for customizable label positions and styles, enhancing UI flexibility.
- Added new properties for label positioning and spacing in the V2 component styles, allowing for better layout control.
- Enhanced the BomTreeComponent to support additional data mapping for entity joins, improving data accessibility and management.
This commit is contained in:
DDD1542
2026-02-27 07:33:54 +09:00
parent 385a10e2e7
commit bfc89501ba
12 changed files with 409 additions and 145 deletions

View File

@@ -59,7 +59,10 @@ export async function getBomHeader(bomId: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom");
const sql = `
SELECT b.*,
i.item_name, i.item_number, i.division as item_type, i.unit
i.item_name, i.item_number, i.division as item_type,
COALESCE(b.unit, i.unit) as unit,
i.unit as item_unit,
i.division, i.size, i.material
FROM ${table} b
LEFT JOIN item_info i ON b.item_id = i.id
WHERE b.id = $1

View File

@@ -274,7 +274,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
// 편집 데이터로 폼 데이터 초기화
setFormData(editData || {});
// entity join 필드(xxx_yyy)를 dot notation(table.column)으로도 매핑
const enriched = { ...(editData || {}) };
if (editData) {
Object.keys(editData).forEach((key) => {
// item_id_item_name → item_info.item_name 패턴 변환
const match = key.match(/^(.+?)_([a-z_]+)$/);
if (match && editData[key] != null) {
const [, fkCol, fieldName] = match;
// FK가 _id로 끝나면 참조 테이블명 추론 (item_id → item_info)
if (fkCol.endsWith("_id")) {
const refTable = fkCol.replace(/_id$/, "_info");
const dotKey = `${refTable}.${fieldName}`;
if (!(dotKey in enriched)) {
enriched[dotKey] = editData[key];
}
}
}
});
}
setFormData(enriched);
// originalData: changedData 계산(PATCH)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {});

View File

@@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
};
// 라벨 렌더링
const labelPos = widget.style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const renderLabel = () => {
if (hideLabel) return null;
const labelStyle = widget.style || {};
const ls = widget.style || {};
const labelElement = (
<label
className={`mb-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
className={`text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
style={{
fontSize: labelStyle.labelFontSize || "14px",
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
fontWeight: labelStyle.labelFontWeight || "500",
fontFamily: labelStyle.labelFontFamily,
textAlign: labelStyle.labelTextAlign || "left",
backgroundColor: labelStyle.labelBackgroundColor,
padding: labelStyle.labelPadding,
borderRadius: labelStyle.labelBorderRadius,
marginBottom: labelStyle.labelMarginBottom || "8px",
fontSize: ls.labelFontSize || "14px",
color: hasError ? "hsl(var(--destructive))" : ls.labelColor || undefined,
fontWeight: ls.labelFontWeight || "500",
fontFamily: ls.labelFontFamily,
textAlign: ls.labelTextAlign || "left",
backgroundColor: ls.labelBackgroundColor,
padding: ls.labelPadding,
borderRadius: ls.labelBorderRadius,
...(isHorizLabel
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
: { marginBottom: labelPos === "top" ? (ls.labelMarginBottom || "8px") : undefined,
marginTop: labelPos === "bottom" ? (ls.labelMarginBottom || "8px") : undefined }),
}}
>
{widget.label}
@@ -332,11 +338,28 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
}
};
const labelElement = renderLabel();
const widgetElement = renderByWebType();
const validationElement = renderFieldValidation();
if (isHorizLabel && labelElement) {
return (
<div key={comp.id}>
<div style={{ display: "flex", flexDirection: labelPos === "left" ? "row" : "row-reverse", alignItems: "center", gap: widget.style?.labelGap || "8px" }}>
{labelElement}
<div style={{ flex: 1, minWidth: 0 }}>{widgetElement}</div>
</div>
{validationElement}
</div>
);
}
return (
<div key={comp.id} className="space-y-2">
{renderLabel()}
{renderByWebType()}
{renderFieldValidation()}
<div key={comp.id}>
{labelPos === "top" && labelElement}
{widgetElement}
{labelPos === "bottom" && labelElement}
{validationElement}
</div>
);
};

View File

@@ -2208,15 +2208,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
});
}
// 라벨 스타일 적용
const labelStyle = {
// 라벨 위치 및 스타일
const labelPosition = component.style?.labelPosition || "top";
const isHorizontalLabel = labelPosition === "left" || labelPosition === "right";
const labelGap = component.style?.labelGap || "8px";
const labelStyle: React.CSSProperties = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#212121",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px",
...(isHorizontalLabel
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
: { marginBottom: component.style?.labelMarginBottom || "4px" }),
};
@@ -2452,18 +2458,45 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{/* 메인 컨텐츠 - 라벨 위치에 따라 flex 방향 변경 */}
<div
className="h-full flex-1"
style={{
width: '100%',
...(shouldShowLabel && isHorizontalLabel
? { display: 'flex', flexDirection: labelPosition === 'left' ? 'row' : 'row-reverse', alignItems: 'center', gap: labelGap }
: {}),
}}
>
{/* 라벨: top 또는 left일 때 위젯보다 먼저 렌더링 */}
{shouldShowLabel && (labelPosition === "top" || labelPosition === "left") && (
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={labelStyle}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
{/* 실제 위젯 */}
<div className="h-full" style={{ width: '100%', height: '100%', ...(isHorizontalLabel ? { flex: 1, minWidth: 0 } : {}) }}>
{renderInteractiveWidget(componentForRendering)}
</div>
{/* 라벨: bottom 또는 right일 때 위젯 뒤에 렌더링 */}
{shouldShowLabel && (labelPosition === "bottom" || labelPosition === "right") && (
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={{
...labelStyle,
...(labelPosition === "bottom" ? { marginBottom: 0, marginTop: component.style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
</div>
</div>

View File

@@ -1078,17 +1078,21 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
const compType = (component as any).componentType || "";
const isV2InputComponent =
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false &&
(style?.labelText || (component as any).label);
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
const labelPos = style?.labelPosition || "top";
const isVerticalLabel = labelPos === "top" || labelPos === "bottom";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || "";
@@ -1238,10 +1242,56 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return unsubscribe;
}, [component.id, position?.x, size?.width, type]);
// 라벨 위치가 top이 아닌 경우: 외부에서 라벨을 렌더링하고 내부 라벨은 숨김
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 ? (
<label
className="text-sm font-medium leading-none"
style={{
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
...(isHorizLabel ? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" } : {}),
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
) : null;
const componentToRender = needsExternalLabel
? { ...splitAdjustedComponent, style: { ...splitAdjustedComponent.style, labelDisplay: false } }
: splitAdjustedComponent;
return (
<>
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{renderInteractiveWidget(splitAdjustedComponent)}
{needsExternalLabel ? (
<div
style={{
display: "flex",
flexDirection: isHorizLabel ? (labelPos === "left" ? "row" : "row-reverse") : "column-reverse",
alignItems: isHorizLabel ? "center" : undefined,
gap: isHorizLabel ? labelGapValue : undefined,
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0, height: isHorizLabel ? "100%" : undefined }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
) : (
renderInteractiveWidget(componentToRender)
)}
</div>
{/* 팝업 화면 렌더링 */}

View File

@@ -839,6 +839,44 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.style?.labelPosition || "top"}
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={
(selectedComponent.style?.labelPosition === "left" || selectedComponent.style?.labelPosition === "right")
? (selectedComponent.style?.labelGap || "8px")
: (selectedComponent.style?.labelMarginBottom || "4px")
}
onChange={(e) => {
const pos = selectedComponent.style?.labelPosition;
if (pos === "left" || pos === "right") {
handleUpdate("style.labelGap", e.target.value);
} else {
handleUpdate("style.labelMarginBottom", e.target.value);
}
}}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
@@ -860,12 +898,21 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={selectedComponent.style?.labelMarginBottom || "4px"}
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
<Label className="text-xs"></Label>
<Select
value={selectedComponent.style?.labelFontWeight || "500"}
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="400"></SelectItem>
<SelectItem value="500"></SelectItem>
<SelectItem value="600"></SelectItem>
<SelectItem value="700"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2 pt-5">
<Checkbox

View File

@@ -466,10 +466,56 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
) : null;
const dateContent = (
<div className={isHorizLabel ? "min-w-0 flex-1" : "h-full w-full"} style={isHorizLabel ? { height: "100%" } : undefined}>
{renderDatePicker()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{dateContent}
</div>
);
}
return (
<div
@@ -481,27 +527,8 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div className="h-full w-full">
{renderDatePicker()}
</div>
{labelElement}
{dateContent}
</div>
);
});

View File

@@ -961,36 +961,83 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}
};
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
const actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay === true;
// size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
// RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만,
// 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함
// 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0;
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
) : null;
const inputContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderInput()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{inputContent}
</div>
);
}
return (
<div
ref={ref}
@@ -1001,38 +1048,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div
className={cn(
"h-full w-full",
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={hasCustomText ? customTextStyle : undefined}
>
{renderInput()}
</div>
{labelElement}
{inputContent}
</div>
);
});

View File

@@ -1041,17 +1041,19 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
// 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (CSS 상속)
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
@@ -1059,6 +1061,58 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
) : null;
const selectContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderSelect()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
className={cn(isDesignMode && "pointer-events-none")}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{selectContent}
</div>
);
}
return (
<div
ref={ref}
@@ -1069,38 +1123,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div
className={cn(
"h-full w-full",
// 커스텀 테두리 설정 시, 내부 select trigger의 기본 테두리 제거
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
// 커스텀 배경 설정 시, 내부 요소를 투명하게
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
)}
style={hasCustomText ? customTextStyle : undefined}
>
{renderSelect()}
</div>
{labelElement}
{selectContent}
</div>
);
}

View File

@@ -68,9 +68,7 @@ export function BomDetailEditModal({
} else {
setFormData({
quantity: node.quantity || "",
unit: node.unit || node.detail_unit || "",
process_type: node.process_type || "",
base_qty: node.base_qty || "",
loss_rate: node.loss_rate || "",
remark: node.remark || "",
});
@@ -151,11 +149,19 @@ export function BomDetailEditModal({
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.unit}
onChange={(e) => handleChange("unit", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
{isRootNode ? (
<Input
value={formData.unit}
onChange={(e) => handleChange("unit", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
value={node?.child_unit || node?.unit || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
</div>
</div>

View File

@@ -291,6 +291,7 @@ export function BomTreeComponent({
item_name: raw.item_name || "",
item_code: raw.item_number || raw.item_code || "",
item_type: raw.item_type || raw.division || "",
unit: raw.unit || raw.item_unit || "",
} as BomHeaderInfo;
}
} catch (e) {
@@ -376,6 +377,18 @@ export function BomTreeComponent({
detail.editData[key] = (headerInfo as any)[key];
}
});
// entity join된 필드를 dot notation으로도 매핑 (item_info.xxx 형식)
const h = headerInfo as Record<string, any>;
if (h.item_name) detail.editData["item_info.item_name"] = h.item_name;
if (h.item_type) detail.editData["item_info.division"] = h.item_type;
if (h.item_code || h.item_number) detail.editData["item_info.item_number"] = h.item_code || h.item_number;
if (h.unit) detail.editData["item_info.unit"] = h.unit;
// entity join alias 형식도 매핑
if (h.item_name) detail.editData["item_id_item_name"] = h.item_name;
if (h.item_type) detail.editData["item_id_division"] = h.item_type;
if (h.item_code || h.item_number) detail.editData["item_id_item_number"] = h.item_code || h.item_number;
if (h.unit) detail.editData["item_id_unit"] = h.unit;
};
// capture: true → EditModal 리스너(bubble)보다 반드시 먼저 실행
window.addEventListener("openEditModal", handler, true);

View File

@@ -153,10 +153,12 @@ export interface CommonStyle {
// 라벨 스타일
labelDisplay?: boolean; // 라벨 표시 여부
labelText?: string; // 라벨 텍스트
labelPosition?: "top" | "left" | "right" | "bottom"; // 라벨 위치 (기본: top)
labelFontSize?: string;
labelColor?: string;
labelFontWeight?: string;
labelMarginBottom?: string;
labelGap?: string; // 라벨-위젯 간격 (좌/우 배치 시 사용)
// 레이아웃
display?: string;